Skip to content

deck: deck.media, a clip's bytes for Quick Deck's player - #515

Merged
ymote merged 3 commits into
mainfrom
feat/deck-media-playback
Oct 13, 2026
Merged

ymote merged 3 commits into
mainfrom
feat/deck-media-playback

Conversation

@ymote

@ymote ymote commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Why

The maintainer approved playback on 13 Oct 2026 ("Add playback"). Quick Deck plays a deck's video and audio with the runtime's Video widget, which hands a file to the operating system's own player. So the app needs each clip as a file in its storage. This PR adds the one service method that writes it, deck.media. It never decodes, probes or transcodes anything. The app side is being built in parallel.

What

  • deck.media {doc, slide, id} answers {path, kind, ext, bytes, autoplay, loop, poster}, as the contract says (src/media.rs).
    • Bytes: the clip's bytes come from the open document's model: the item the shape's MediaClip names (Presentation::media). They're written as they are to .cache/media/<SHA-256 of the bytes>.<ext>.
    • No engine: it runs no engine command and calls no engine function.
    • Model fields: kind, autoplay, loop and poster are the model's own fields.
    • ext: the deck's own extension for the clip, if it's one of mp4, m4v, mov, mp3, m4a, aac or wav. Otherwise it comes from the declared content type, with any parameters dropped and in any case. A clip with neither is unsupported:.
    • Wrong shape: any shape but a clip is unknown_id:, and so is a shape or slide the deck doesn't have.
  • The media cache: 32 MiB and 8 clips per storage.
    • When it needs room, the clip used longest ago goes first.
    • A clip already there with the same bytes is reused. Its bytes are compared in full before the path is given out.
    • Writes are staged inside the storage and placed by a hard link that refuses anything already there. A write never replaces a file.
    • If a clip doesn't fit what's left of the storage, the render cache and then the media cache are cleared first. A clip that still doesn't fit is storage_full: ("There isn't room in Quick Deck's storage to play this clip.").
    • Only regular files directly in .cache/media are counted, reused or removed, and a link in the folder path is refused.
  • A document's clips go when it closes, and when it's released with its renders: isolate close, a changed storage scope, idle, or an engine panic. Each open document keeps a list of the clips it was handed (OpenDoc::clips). A clip that another document open in the same storage still uses stays.
  • deck.slide reports media: {id, video, autoplay, loop}. It didn't before: the engine's slide.inspect gives the key, but shape_json didn't copy it. It now does.
    • media.id is the clip's media item id in the deck. Two shapes playing the same item share it.
    • deck.media takes the shape's own id.
  • Contract: the approved section is appended verbatim to apps/quickdeck/design/SERVICE.md, in its own commit (SERVICE.md: playback (deck.media)). A second commit (SERVICE.md: playback as built (#515)) adds the settled choices below and the facts for the app, as bullets at the end of that section. The deck paragraph in apps/AGENTS.md now describes deck.media.
  • Dependency: sha2 = "0.10", the workspace's own, as Mail's and the shell's. It's already in Cargo.lock, so the lock gains one line.

Example

The test deck's video, which has a poster:

{"path": ".cache/media/93caaec9284191ce54f9ac92482dd8a271ce1fd0fc292b98a1b31638aea45444.mp4",
 "kind": "video", "ext": "mp4", "bytes": 42, "autoplay": false, "loop": false, "poster": true}

Audit: every call into the decoders, and what the service runs

This is #511's audit at deckcraft d0e57d7e, with the new method's row. The decoders live in deckcraft-media. Only engine and ui-egui depend on it.

Call into deckcraft-media Reached by Verdict
engine/src/cmd/media.rs:100, deckcraft_media::probe media.info only held by the door (HELD_BACK, #448); no method builds it
engine/src/cmd/media.rs:123, deckcraft_media::poster_png media.posterFrame (media.rs:139) and insert.video (insert.rs:477) both held
engine/src/cmd/insert.rs:450, deckcraft_media::probe insert.audio and insert.video (insert.rs:511, 514) both held
engine/src/lib.rs:27, pub use deckcraft_media as media a re-export the service never names it
Service path What runs Reaches a decoder?
deck.media no engine command and no engine function: Slide::shape and Presentation::media from deckcraft-model, a SHA-256, then a staged write of the item's bytes bytes only
Import guard the service, plus the zip crate's directory read no
Open a package deckcraft_pptx::import (read/shapes.rs:259 stores the clip's bytes as a MediaClip), then deckcraft_format::repair no
Open a native deck deckcraft_format::load no
deck.cover, deck.view, deck.layouts, the images export the renderer (render/src/lib.rs:559-571: the poster, or a film or speaker glyph) no
deck.view {step}, deck.show deckcraft-anim, then the renderer no
deck.slide, deck.slides, deck.format_state, deck.hit slide.inspect (inspect.rs:99), document.inspect, format.state, Session::hit_test, model reads no
Native save, .pptx, PDF and outline exports the engine's encoders no
Undo, redo edit.undo, edit.redo (a snapshot swap) no
The agent's deck.run the door, where all four media commands are held held

The engine's media.play, media.pause, media.seek, media.stop and media.toggle are classed device, so the door refuses them too, and no method builds them. Playback happens in the app, through the operating system's player.

Choices the contract left open (settled)

The coordinator accepted all six as built, and they're now in SERVICE.md's Playback section ("As built in #515"):

  1. A clip larger than the whole 32 MiB cache is storage_full: straight away, and nothing is cleared, since even an empty cache can't hold it. The guard allows up to 64 MiB an entry, so such a clip can open.
  2. A clip the deck holds no bytes for is unsupported:, with its own words. That's a clip linked to a file elsewhere (the service never follows a link), or one whose media item is missing or empty.
  3. "Oldest first" means used longest ago. A clip reused by a later call counts as just used: its file's modification time is set to now. Without this, if a slide has two clips, the first one reused could be evicted by the second's write before the player opened it.
  4. A stale entry at a clip's name (a file cut short, or a link) is removed like an evicted clip, and the clip is then written create-only. A folder there is invalid:.
  5. Clips a released document left behind (an earlier run's, say) go when a document next opens in that storage, as renders do. A clip two open documents share stays until the second one closes.
  6. A clip inside a group is found by deck.media too (Slide::shape searches groups). deck.slide lists only top-level shapes, as before.

For the app: what the model holds for a .pptx

Also recorded in SERVICE.md's Playback section, with the note that deck.slide's media.id is the clip's media item id while deck.media takes the shape's own id.

  • No autoplay or loop from a .pptx. deckcraft's package reader and writer at d0e57d7e never read or write a clip's autoplay or loop: nothing in crates/pptx names them. PowerPoint keeps them in the slide's timing tree. So a .pptx deck's clips answer autoplay: false and loop: false, and Present's autoplay won't start them. A native deck keeps both settings, and the test checks that.
  • Every clip gets a poster once saved as a .pptx. deckcraft's package writer gives every clip a picture, so after a .pptx round trip even a sound answers poster: true. poster reports what the model holds, as the contract says.

Tests

On the Linux build host (x86_64), at 98358fe8 on main 3f700a2d. The head, d50cd423, adds only the SERVICE.md bullets, which no test or CI step reads.

  • cargo test --locked -p octosense-deck-service -p octosense-engine-area:
    • deck service lib: 96 passed (88 before, plus 8 new) and 1 ignored (the timing test);
    • first_call and skill: 1 passed each;
    • engine-area: 28 passed.
    • The deck service lib suite then passed six more times in a row.
  • cargo clippy --locked -p octosense-deck-service -p octosense-engine-area --all-targets --no-deps: no findings in either crate.

New, in src/doc_tests.rs:

  • deck_media_writes_the_clips_bytes_as_the_deck_stores_them: a video (.mp4, with a poster) and a sound (.m4a, autoplay and loop), from a native deck and a .pptx.
    • The answer is exact, with the path .cache/media/<SHA-256>.<ext>.
    • The written file equals the deck's bytes.
    • deck.slide reports each clip's media.
    • The engine's read counter doesn't move.
  • deck_media_hands_out_a_clip_already_written_again: the same answer, one file and the same inode, from the same document and from a second one on the same deck. A file cut short at the clip's name is written anew, whole.
  • deck_media_answers_unknown_id_for_another_shape_and_unsupported_for_an_unknown_kind:
    • .bin with application/octet-stream is unsupported:, and the words list the seven extensions.
    • A linked clip is unsupported:.
    • ext comes from the type (mov, mp3, and wav from Audio/X-WAV; rate=44100) or from the name (Film.M4V, and aac with no type). A .wma name with an audio/mpeg type gives mp3.
    • A placeholder, a missing shape and a missing slide are unknown_id:.
    • No file is written for a refusal.
  • the_media_cache_holds_32_mib_and_8_clips_the_oldest_going_first:
    • Nine 4 KiB clips leave eight, and the first one is gone.
    • Reusing clip 1 makes it the newest, so the next write evicts clip 2 instead.
    • Of three 12 MiB clips, the last two stay, at most 32 MiB.
  • a_documents_clips_go_when_it_closes:
    • Closing a document takes its sound but not a video the other open document uses. Closing that second document takes the video.
    • A document released while idle takes its clip.
    • A leftover file goes when a document opens.
  • a_closed_isolates_clips_go_with_its_renders: a real isolate opens the deck, gets a clip and draws a slide. services::cancel_heap removes the clip and the renders.
  • the_render_cache_then_the_media_cache_are_cleared_before_storage_full:
    • A 32 MiB + 1 clip is storage_full: straight away, and the renders and the cached clip stay.
    • Under a tight quota, clip B fits once the render cache is cleared, and clip A stays.
    • Clip C fits only once the media cache is cleared too.
    • Clip D, under a zero quota, is storage_full: in the contract's exact words, with no staging folder left behind.
  • a_hostile_clip_is_written_byte_for_byte_and_never_decoded: deck: decks with audio or video open #511's hostile MP4 (boxes claiming 4 GiB and 16 EiB, four billion samples) and hostile WAV, from a .pptx and a native deck.
    • Both are written byte for byte.
    • The read counter doesn't move.
    • The session's media total, the number the 128 MiB ceiling checks, is the same before and after.
    • src/media.rs's code, comments aside, names none of deckcraft_engine, deckcraft_media, deckcraft_render, deckcraft_pptx, gate::, Session or execute(.
    • The four media commands are still in HELD_BACK.
  • deck.media is added to the unknown-handle test and the unknown-key test.
  • The media ceiling: deck: decks with audio or video open #511's a_deck_past_the_media_ceiling_is_too_large_before_the_engine_reads passes unchanged. deck.media adds nothing to a session.

Not run: no shell, desktop or Quick Deck UI, and no player. The app side is a separate PR.

🤖 Generated with Claude Code

ymote and others added 3 commits October 13, 2026 11:23
The maintainer approved playback on 13 Oct 2026: Quick Deck plays a
deck's video and audio with the runtime's Video widget, which hands the
file to the operating system's own player. The service adds one method,
deck.media {doc, slide, id}, which writes the clip's bytes from the open
document's model, as they are, to .cache/media/, and never decodes a
clip; media.info, media.posterFrame, insert.audio and insert.video stay
held (#448). The section is appended verbatim as approved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
deck.media {doc, slide, id} answers {path, kind, ext, bytes, autoplay,
loop, poster}, as SERVICE.md "Playback" says. The clip's bytes come from
the open document's model, the item the shape's MediaClip names, and are
written as they are to .cache/media/<SHA-256>.<ext>, staged and placed by
a link that refuses anything already there. No engine command runs and
no engine function is called, so nothing decodes a clip; media.info,
media.posterFrame, insert.audio and insert.video stay held (#448).

- ext: the deck's own extension for the clip when it is one of mp4, m4v,
  mov, mp3, m4a, aac or wav, else the one its declared content type
  names; neither is unsupported:, and so is a clip whose bytes the deck
  doesn't hold (linked). Any shape but a clip is unknown_id:.
- The media cache holds 32 MiB and 8 clips per storage; the clip used
  longest ago goes first, and a clip already there with the same bytes
  (compared in full) is handed out again and counts as just used.
- A document's clips go when it closes or is released with its renders,
  unless another document open in the same storage uses them; clips no
  open document uses go when a document next opens.
- Before a clip would not fit the storage, the render cache and then the
  media cache are cleared; one that still doesn't fit, or one larger than
  the whole media cache, is storage_full: in the contract's words.
- deck.slide now reports a clip's media {id, video, autoplay, loop}: the
  engine's slide.inspect gave it, but shape_json didn't copy it.

The deck paragraph in apps/AGENTS.md describes it. sha2 is the
workspace's own 0.10, already in Cargo.lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The coordinator accepted the six choices the Playback section left open,
as #515 built them, and asked for them here with three facts the app
needs:

- deck.slide's media.id is the clip's media item id; deck.media takes
  the shape's own id.
- A clip larger than the whole 32 MiB media cache is storage_full: at
  once, with nothing cleared; one the deck holds no bytes for (linked,
  missing or empty) is unsupported:.
- Oldest first means used longest ago (a reused clip's modification time
  is set to now); a stale entry at a clip's name goes and the clip is
  written anew (a folder there is invalid:); clips no open document uses
  go when a document next opens; a clip inside a group is found too.
- deckcraft at d0e57d7e reads no autoplay or loop from a .pptx, and its
  .pptx writer gives a clip without a poster a placeholder picture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ymote
ymote marked this pull request as ready for review October 13, 2026 03:30
@ymote

ymote commented Oct 13, 2026

Copy link
Copy Markdown
Contributor Author

Local CI passed on d50cd42 (tools/ci-local.sh --only all --linux-host --offload, 18m22s, Darwin arm64; the ubuntu jobs and the macOS jobs' portable steps (their macOS steps ran here) on the Linux build host, Linux x86_64).

GitHub would run for this PR: desktop.yml, phone.yml, apps.yml. Merged on this local pass (macOS runner queue); GitHub CI runs on the merge commit on main.

Job Step Result Time
ci-local / drift The local mapping fits the workflows PASS -
desktop.yml / desktop Prepare the pinned framework sources PASS 1s
desktop.yml / desktop Compile the desktop with the shared runtime PASS 9m28s
desktop.yml / desktop Desktop tools and scripts PASS 19s
desktop.yml / desktop (linux) Prepare the pinned framework sources PASS 1s
desktop.yml / desktop (linux) One copy of every shell source PASS -
desktop.yml / desktop (linux) In-app updater and installer handoff PASS 2m12s
desktop.yml / desktop (linux) One Makepad, App Hub, octos and Rinx in the locked graph PASS 3s
desktop.yml / desktop (linux) The native apps' generated places match native-apps.json PASS 0s
desktop.yml / native-host-api Prepare the pinned framework sources PASS 1s
desktop.yml / native-host-api Build the native acceptance host and pinned Hub CLI PASS 39s
desktop.yml / native-host-api Signed app tool calls a real OS permission API PASS 3s
phone.yml / home Prepare pinned sources and the reviewed runtime patch PASS 1s
phone.yml / home Validate launcher icons and Apple asset catalogs PASS 3s
phone.yml / home (linux) Prepare pinned sources and the reviewed runtime patch PASS 0s
phone.yml / home (linux) Verify the single runtime graph PASS 2s
phone.yml / home (linux) The native apps' generated places match native-apps.json PASS 0s
phone.yml / home (linux) The pinned octos revision PASS 0s
phone.yml / home (linux) Build octos (unless cached) PASS -
phone.yml / home (linux) Build Home, App Hub admission and runtime policy tests PASS 4m09s
phone.yml / home (linux) Test Home, App Hub admission and runtime policy PASS 44s
phone.yml / home (linux) Two-lane scenario (real kernel, scripted model) PASS 27s
phone.yml / home (linux) Build opt-in AppCard tests PASS 2m58s
phone.yml / home (linux) Test opt-in AppCard PASS 1s
phone.yml / home (linux) Build craft-engine relay and system-chat tests PASS 3m12s
phone.yml / home (linux) Test craft-engine relay and system chat PASS 5s
phone.yml / home (linux) Build linked Terminal tests PASS 2m35s
phone.yml / home (linux) Test linked Terminal PASS 1s
phone.yml / home (linux) Build Wasm and runtime policy tests PASS 1m19s
phone.yml / home (linux) Test Wasm and runtime policy PASS 11s
phone.yml / home Compile Home and its bundled modules PASS 7m46s
apps.yml / services (linux) python3 tools/setup.py PASS 0s
apps.yml / services (linux) Shared system app interface PASS 0s
apps.yml / services (linux) Kernel service PASS 19s
apps.yml / services (linux) OctoSense release updater PASS 7s
apps.yml / services (linux) App peers PASS 36s
apps.yml / services (linux) AI providers config PASS 8s
apps.yml / services (linux) Connected provider protocols and Linux host adapter PASS 1m13s
apps.yml / services (linux) L0 in-card chat (sys.chat, host side) PASS 12s
apps.yml / services (linux) System toolbox (workflow templates) PASS 1m41s
apps.yml / kernel-security (linux) python3 tools/setup.py PASS 0s
apps.yml / kernel-security (linux) The pinned octos revision PASS 0s
apps.yml / kernel-security (linux) Build octos (unless cached) PASS -
apps.yml / kernel-security (linux) Real-kernel tests PASS 40s
apps.yml / apps (linux) python3 tools/setup.py PASS 0s
apps.yml / apps (linux) Host services (Mail, AI providers, News) PASS 2m44s
apps.yml / apps (linux) Connected accounts and Rinx editor PASS 1m49s
apps.yml / apps (linux) Shell AI services (crates/ai-host) PASS 3m17s
apps.yml / apps (linux) AppCard runtime setup safeguards PASS 0s
apps.yml / apps (linux) AppCard clippy (own crates; shared runtime compiles) PASS 29s
apps.yml / apps (linux) AppCard transport and store PASS 8s
apps.yml / apps (linux) One Makepad, App Hub, octos and Rinx in the locked graph PASS 3s
rom.yml / product (linux) python3 -m unittest discover -s tests -v SKIPPED (!) -
rom.yml / product (linux) Check generated Agent Binder client SKIPPED (!) -
rom.yml / product (linux) bash -n scripts/stage-forks.sh scripts/publish-release.sh scripts/apply-to-tree.sh scripts/build-rom.sh PASS -
rom.yml / web-installer (linux) npm ci --ignore-scripts --no-audit --no-fund SKIPPED (!) -
rom.yml / web-installer (linux) npm test SKIPPED (!) -
rom.yml / web-installer (linux) npx playwright install --with-deps chromium SKIPPED (!) -
rom.yml / web-installer (linux) npm run test:browser SKIPPED (!) -
rom.yml / web-installer (linux) python3 -m unittest discover -s tests -p test_installer_manifest.py -v SKIPPED (!) -
rom.yml / web-installer (linux) bash -n scripts/release.sh SKIPPED (!) -
linux-host / sandbox (linux) python3 tools/setup.py PASS 0s
linux-host / sandbox (linux) Process sandbox on Linux (Landlock, seccomp) PASS 2m21s

Skipped (these did NOT pass; (!) marks a skip that blocks tools/ci-local-merge.sh):

  • rom.yml / product (linux): python3 -m unittest discover -s tests -v: javac -version does not work here: install a JDK (17 or newer) and put its bin/ on PATH or set JAVA_HOME
  • rom.yml / product (linux): Check generated Agent Binder client: needs build-tools/35.0.0/aidl, platforms/android-35/framework.aidl in the Android SDK (no Android SDK found): install the SDK packages 'build-tools;35.0.0' and 'platforms;android-35' (sdkmanager) and set ANDROID_HOME
  • rom.yml / web-installer (linux): npm ci --ignore-scripts --no-audit --no-fund: no node, npm, npx on PATH (the workflow uses node 24); put node on PATH
  • rom.yml / web-installer (linux): npm test: no node, npm, npx on PATH (the workflow uses node 24); put node on PATH
  • rom.yml / web-installer (linux): npx playwright install --with-deps chromium: no node, npm, npx on PATH (the workflow uses node 24); put node on PATH
  • rom.yml / web-installer (linux): npm run test:browser: no node, npm, npx on PATH (the workflow uses node 24); put node on PATH
  • rom.yml / web-installer (linux): python3 -m unittest discover -s tests -p test_installer_manifest.py -v: no node, npm, npx on PATH (the workflow uses node 24); put node on PATH
  • rom.yml / web-installer (linux): bash -n scripts/release.sh: no node, npm, npx on PATH (the workflow uses node 24); put node on PATH

Notes:

  • GitHub runs Python 3.12; this run used Python 3.9.6
  • GitHub runs Python 3.12; the Linux host used Python 3.14.4

Native GitHub acceptance passed on the same head: embedded-browser.yml.

@ymote
ymote merged commit 083f89f into main Oct 13, 2026
4 of 9 checks passed
@ymote
ymote deleted the feat/deck-media-playback branch October 13, 2026 03:51
ymote added a commit that referenced this pull request Oct 13, 2026
Brings #512 up to main after #515 (deck.media) merged; no conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RoooyHe pushed a commit to woshuoduijiushidui/OctoSense that referenced this pull request Oct 13, 2026
OctoSense-org#515's review found one minor issue and four nits.

- A link at a clip's own name, pointing outside storage, blocked that
  clip for good: the path went through crate::inside(), which follows a
  final link and refused before the stale-entry repair ran, and eviction,
  clear, forget and prune remove only regular files. The path is now the
  checked plain .cache/media folder joined with the service-built name (a
  hex SHA-256 and an extension from EXTS), so the repair removes whatever
  is there, a link itself and never what it points to, and writes the
  clip anew.
- Eviction takes clips no open document uses first, and an open
  document's clips only when that is not enough, each the one used
  longest ago first; deck.media reads the set before the call takes the
  documents table.
- Only what can fit is staged: after the render cache and then the media
  cache are cleared, a clip still bigger than the room left is
  storage_full: before any staging, and writing.rs::place (save, export)
  clears the render cache and refuses in the commit's own words before it
  stages. Both stage through crate::stage, counted in tests.
- The 32 MiB cap is checked before the clip is hashed.

New tests: a link at a clip's name (to a file outside storage, and to one
holding the clip's very bytes) goes, the clip is written as a plain file
and the file outside is untouched; eviction takes a newer clip no
document uses before an open document's oldest. The storage_full tests
check that nothing is staged. The deck paragraph in apps/AGENTS.md
follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RoooyHe pushed a commit to woshuoduijiushidui/OctoSense that referenced this pull request Oct 13, 2026
"Writes are staged and never replace a file the call didn't create" now
says that a stale entry at a clip's own name (a file that doesn't hold
the clip's bytes, or a link, never followed) is removed and the clip
written there anew, as OctoSense-org#515's review asked. The as-built "oldest first"
bullet adds that clips no open document uses go before any that one
does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant