@synap-core/workspace-templates 0.31.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synap-core/workspace-templates",
3
- "version": "0.31.0",
3
+ "version": "0.33.0",
4
4
  "type": "module",
5
5
  "description": "Canonical workspace definitions — YAML source + typed exports for browser, backend, CLI, and marketplace",
6
6
  "repository": {
@@ -18,7 +18,7 @@
18
18
  "kind": "instruction",
19
19
  "scope": "pod",
20
20
  "description": "Turns raw face-camera, talking-head or screen+camera footage into a finished cut: word-level transcript, semantic cuts in a HyperFrames project shown live in HyperFrames Studio, HyperFrames captions and graphics, QA, pod save. Use when editing recorded footage of a person speaking.",
21
- "body": "# Face-camera editing\n\nRaw face-camera or screen+camera footage → a finished cut. The recording is the authority for what was said: remove what failed, never invent speech.\n\n## Hard rules\n\n- Never generate the person: no lip-sync, avatar, face swap or synthetic voice of the speaker unless the user explicitly asks.\n- The person may edit the project in HyperFrames Studio at any time: build on their edits, never overwrite them (§4).\n- Never cut inside a word. Every cut edge snaps to a word boundary from a word-level transcript.\n- Confirm the cut strategy in plain words before touching the timeline. Render the final only after one human review.\n- Revisions change only what was flagged.\n- **The voice is encoded once after the take, never again.** The cut's audio is `media/cut-audio.m4a`, built from the take at 256 kb/s or more (§4). A render's own audio is never shipped: every render the person hears, a pass's preview included, is followed by a remux that copies the cut's audio stream, checked by hash before upload (§8). A re-encode for size touches the video only (`-c:a copy`). Loudness changes only as a step the person approved (§4), never silently. Why (31 Oct, run 22dcabe8): the delivery was re-encoded to fit the upload, the voice's third AAC encode at 125 kb/s, and its coding noise sat about 5 dB under the voice at 12-16 kHz; the founder heard it.\n- **The passes follow the `edit-loop` skill**: its slots (`layer-<pass>`, `studio`, `studio-app`, `needs-tool-<tool>`), its three answers (Approve, Rework, Use my edit) and its rules (a version before every pass, work from the diff, never write while a pass is the person's, acknowledge every answer in the room). Here the passes are `layer-cut`, `layer-background`, `layer-colour`, `layer-captions` and `layer-graphics`; background and colour add Remove this pass. Content Studio draws a pass, its preview and its question only from its `layer-<pass>` slot. Use `ask-<slug>` only for a question that is not a pass (free disk, a go-ahead), and `needs-tool-<tool>` for a missing tool (§1 step 0).\n\n## 1. Recall and set up\n\n0. **Check the tools first, before anything else.** On this computer run `node --version` (20 or later), `ffprobe -version`, `ffmpeg -version` and `HYPERFRAMES_NO_TELEMETRY=1 npx --yes hyperframes --version`. If one is missing or fails, do not work around it and do not start the run's passes: declare ONE `needs-tool-<tool>` slot per missing tool, shaped and answered as the `edit-loop` skill spells out, its \"I installed it\" description naming the one install command for this OS (e.g. `brew install ffmpeg`). Then post one room line naming what is missing, and end the run blocked. Never install anything yourself.\n Also note whether the HyperFrames desktop app is on this computer: `test -d /Applications/HyperFrames.app || test -d ~/Applications/HyperFrames.app`. Say it in one room line with the checks' result (`HyperFrames app: installed` or `HyperFrames app: not installed`). A missing app is not a slot by itself: it becomes one only when the person asks for the app (step 6).\n1. `synap_ask` for the brand kit (palette, caption font, lower-third template) and any approved `content-composition` to reuse.\n2. Record with Cap (separate camera, screen, mic tracks); keep originals untouched.\n3. `ffprobe` every source. Pick the delivery frame grid once (default 30 fps; 1080x1920 or 1920x1080) and keep it everywhere.\n4. **One project per video run: `edit/`.** It lives in the run's work folder, `~/synap-work/<run session id>/edit` (the folder `synap worker` gives the run): `HYPERFRAMES_NO_TELEMETRY=1 npx hyperframes init edit --non-interactive --skip-transcribe --resolution <portrait|landscape|square>`, the takes copied into `edit/media/`. A variable frame rate take is made constant once (§6, step 1: `media/take-cfr.mp4`) and the project uses that copy. Every pass happens in this project and every command below runs from `edit/`.\n5. **Start Studio and record its address.** `npx hyperframes preview --background --port <a free port, from 3002 up>`; check that `http://localhost:<port>/#project/edit` answers HTTP 200, then record it on the run as an outcome: `{ key: \"studio\", kind: \"link\", label: \"HyperFrames Studio\", required: false, ref: { url: \"http://localhost:<port>/#project/edit\" } }`. Content Studio draws it as \"Open in HyperFrames Studio\". Studio hot-reloads, so the person watches each pass as you write it. Whenever your preview process stops (the run leaves Draft or ends, the worker stops, or you stop it): `npx hyperframes preview --stop` and `retireOutput: \"studio\"`, so Content Studio never links a dead address. Back at Draft (Review sent it back): start it again and record the new address.\n6. **Studio asks from Content Studio.** \"Studio\" names a place, and Content Studio asks for one in the run's room. Answer between passes; an ask never undoes a pass.\n - **app** (\"Open this edit in the HyperFrames desktop app on this Mac (open the project folder in it), then wait\"). Installed (step 0): `open -a HyperFrames \"$HOME/synap-work/<run session id>/edit\"`, the absolute path of `edit/` as the document to open. That works whether the app is running or not; `open -a HyperFrames --args <dir>` reaches the app only when it is not running yet, and is ignored by a running app. The app adds the folder to its Home as a link (named `edit`, then `edit 2`, `edit 3`… across runs), so the person's edits land in this project, and §4's history turns still hold. Then record `{ key: \"studio-app\", kind: \"app\", label: \"HyperFrames app on <this computer's name>\", required: false }`, `completeOutput: \"studio-app\"`, post one room line and wait for the person. Not installed: declare the human slot `{ key: \"needs-tool-hyperframes-app\", kind: \"decision\", label: \"HyperFrames app on <this computer's name>\", owner: \"human\", blockedReason: \"capability\", why: \"The HyperFrames desktop app isn't installed on <this computer's name>\", unblocks: \"the edit opens in the HyperFrames app\", ask: { mode: \"choose\", options: [{ label: \"I installed it\", recommended: true, description: \"Download it from https://hyperframes.dev/studio/download, then the agent opens the edit in it\" }, { label: \"Use the browser Studio\", description: \"The agent starts Studio in the browser instead\" }] } }`. On \"I installed it\": check again; there, open the edit as above and settle the slot as step 0 does; still missing, say so on the same slot. On \"Use the browser Studio\": `retireOutput: \"needs-tool-hyperframes-app\"` and do **browser**.\n - **browser**: start Studio and record `studio` as in step 5 (an address of yours that still answers HTTP 200 is reused, never a second server).\n\n## 2. Transcribe (word level)\n\n- Word-level, verbatim ASR only: fillers and false starts stay in the text (they are the signal); phrase/SRT output loses the gaps you cut on.\n- One word-level ASR pass per source (`npx hyperframes transcribe <file> --engine parakeet`, or media-use's `transcribe.mjs`), only when the pod's transcript (below) has no word timings yet. Cache per source; never re-transcribe.\n- The pod keeps a take's transcript as ONE `content-asset` (Kind `script`, File ID = the take's file): its body holds the readable text under a `## Transcript` heading, then a `## Words` heading followed by exactly one ```json block of `[{\"text\",\"start\",\"end\"}]` in seconds (`[]` until the precise pass fills it). Find it before transcribing; if it exists, reuse it and only fill the word timings; if not, create it and link it to the take's file with `references`. Never a separate document.\n- Build a reading view: one line per phrase, `[start-end] text`, breaking on any silence of 0.5 s or more.\n\n## 3. Plan cuts on the transcript (semantic first)\n\nThree passes, in this order:\n\n1. **Semantic.** Remove misspeaks, failed starts, repeated takes, production chatter (\"wait, again\"). Keep the last complete, correct take by default; keep deliberate emphasis and rhetorical self-correction. Remove a filler only when context shows it is a standalone filler, not part of a word or a meaningful pause.\n2. **Pauses.** Remove dead air and restart preparation, including pauses inside kept phrases. Silence guide: 400 ms or more = clean cut target; 150-400 ms = usable after a visual check; under 150 ms = mid-phrase, do not cut. For a dB scan, look at -30, -35 and -40 dB with a 0.45 s minimum duration; a scan reports intervals, it does not authorise edits. Leave ambiguous breaths or possibly quiet words for the user to hear.\n3. **Edges.** Only after 1 and 2 are applied, tighten each kept clip to the real sound: first and last sustained activity on the louder channel (measured in 10 ms windows), keep one frame of margin, and back off to lower-level tails rather than clipping a decaying word.\n\n**Invisible cuts (picture continuity).** A cut the viewer notices is a failed cut (founder, 29 Oct). A cut that sits in a pause may slide inside that pause without touching speech, so put each edge where the face matches across it:\n- Score every out/in frame pair inside the pause: face position and size (a face detector), plus the grey-level difference of the face crop. Take the pair with the smallest jump, with a small cost for each extra second of silence kept (about 0.25 per second) so the pause does not grow back, and still remove at least 0.25 s.\n- A head turn inside the pause disappears with the cut: leave on the frame before the turn and come back on a frame already facing the camera, or the reverse. Never cut from mid-turn to facing the camera.\n- Never move an edge into speech, and never drop a word to gain continuity. When no pair matches (a large jump remains), keep the cut and flag it: a punch-in of about 110% on the incoming side hides it, or the user decides.\n- Report each cut's jump before and after. Pilot, 29 Oct (take b36227cd, 13 cuts): the mean jump fell from 0.59 to 0.29, for 4 s more runtime.\n\nPadding: 30-200 ms working window around every kept word (ASR drifts 50-100 ms). A good default is 50 ms before the first kept word and 80 ms after the last; tighter for punchy social, looser for calm explainers. Hand-offs between speakers: 400-600 ms of air. Extend past laughs; the reaction is the beat.\n\nWrite the plan as `{source, start, end, quote, reason}` ranges, state the runtime, show the user a short strategy, and wait for confirmation. In Produce Content this plan is the Script, and its approval (the Draft stage's gate) is the confirmation.\n\n## 4. Apply the cut\n\n- **Editor of record: HyperFrames.** The cut is the project's footage track, never a baked file: one `<video>` clip per kept range on track 0, all on the same source (`media/take-cfr.mp4`), edge to edge, each with an `id` and `data-has-audio=\"true\"`: `data-media-start` = the range's start in the source, `data-duration` = its length, `data-start` = the sum of the kept lengths before it (hyperframes-core's hard-cut recipe). media-use's `transcript-cut.mjs --plan` compiles the kept segments from the words and the removals: lay them as clips, never encode them. Studio shows the cut as it is written, and the person can trim any clip there.\n- Put a 30 ms volume ramp in and out on every clip, in its `data-automation` volume lane (`{\"t\":0,\"v\":0},{\"t\":0.03,\"v\":1},{\"t\":<dur-0.03>,\"v\":1},{\"t\":<dur>,\"v\":0}`), otherwise every cut pops.\n- **The cut's audio, encoded once.** Build `media/cut-audio.m4a` from the take on the footage clips' ranges, with the same 30 ms ramps, in ONE ffmpeg call: one `atrim` per clip in clip order, start = its `data-media-start`, end = start + its `data-duration`:\n ```\n ffmpeg -i media/take-cfr.mp4 -filter_complex \"[0:a]atrim=start=<s0>:end=<e0>,asetpts=PTS-STARTPTS,afade=t=in:d=0.03,afade=t=out:st=<d0-0.03>:d=0.03[a0];[0:a]atrim=…[a1];…;[a0][a1]…concat=n=<N>:v=0:a=1[a]\" -map \"[a]\" -c:a aac -b:a 256k -ar 48000 media/cut-audio.m4a\n ```\n When the footage track starts after 0 (an intro card), add `adelay=<ms>:all=1` after the concat. Rebuild it before every render in which a footage clip changed (your edit or the person's trim in Studio); it takes seconds. Never build it from a file that is already a re-encode of the take.\n- **Loudness is the person's call, asked once.** Measure the cut's audio: `ffmpeg -i media/cut-audio.m4a -af ebur128=peak=true -f null -` (integrated `I:` in LUFS, true peak). When it is more than 2 LU away from -14 LUFS (a Reel or a Short; the take of 31 Oct sat at -25), ask on the slot `ask-loudness`, beside the cut's question: ask `confirm`, prompt \"Raise the voice to about -14 LUFS? It is <I> LUFS now.\", yes recommended. Yes: rebuild the cut's audio once with a two-pass `loudnorm=I=-14:TP=-1:LRA=11` at the end of the same filter (first pass `print_format=json`, second pass with its `measured_I`, `measured_TP`, `measured_LRA`, `measured_thresh` and `offset`), still one encode. No: leave it. Never normalise without that answer, and never on a render.\n- Read the cut back with `npx hyperframes timeline --json`, then `lint` and `check`. The pass's preview is a draft render of the project (`npx hyperframes render --quality draft`).\n- Every cut must work on audio AND picture (check the screen side too).\n- Hand the user the cut to listen to before dressing it.\n\n**One history turn per pass; the person's edits win** (the version before every pass, edit-loop §4).\n- Start each pass with `npx hyperframes history begin --who <your agent name> --label \"<pass>\"`, then `npx hyperframes history --since mine --who <your agent name>` to see what the person changed in Studio since your last turn. Build on their edits; never overwrite them. End the pass with `npx hyperframes history end`.\n- A check failed, or the person says it got worse: `npx hyperframes history undo --who <your agent name>` takes back only your newest turn, never theirs.\n- **Use my edit** (an answer on a pass's question): the person made that pass themselves in Studio. Read what they changed (`history --since mine`), keep it as the pass and change nothing of it, render the pass's preview from the project as it is, point the slot at it and ask again.\n\n## 5. Dress in HyperFrames\n\nUse HyperFrames `embedded-captions` (captions only) or `talking-head-recut` (captions, lower-thirds, graphics, b-roll). The cut is a media layer; captions and graphics are HTML on a paused GSAP timeline. Deterministic only: no wall-clock, no requestAnimationFrame loops, seeded randomness, no CSS transitions or keyframes for motion, entrances via `fromTo`, no infinite repeats, `power3`-style easing, no bounce by default.\n\n**Caption timing.** Map words to the output timeline: `output_time = word.start - segment_start + segment_offset`. Cues are half-open integer frame windows `[startFrame, endFrame)`; adjacent cues may touch, never overlap, fade tails included. Captions are the top layer, added last, so no overlay hides them.\n\n**Caption segmentation.** Decide in this order: keep a word, name, number+unit or fixed phrase whole; break at a clause, punctuation, breath or topic reset; keep the cue readable for its duration; then measure the rendered width.\n- One rendered line per cue. Latin scripts: about 30-32 characters at most; CJK: about 10 characters.\n- Target 0.8-2.5 s per cue, never under 0.5 s; grow a cue rather than flash one shorter than about 0.35 s.\n- Never strand a lone article, particle or conjunction.\n- The concatenated cues must reproduce the approved transcript exactly; terminology corrections are a lexicon applied once to all cues.\n- Caption font = the brand font; small text contrast at least 4.5:1; no opaque bar; vertical default sits about 330 px above the bottom of a 1920 px frame.\n\n**Motion graphics timed to speech.** A graphic adds what captions cannot (evidence, relationship, scale, structure, next action); plain narration stays caption-only.\n- Bind each spoken element to its own keyword onset from the word timestamps: it enters on the first frame at or after that word, never on a fixed stagger or ahead of the speech. Neutral headings may enter at the beat start; connectors may follow a cue by a few frames.\n- Hold the resolved state long enough to read, at least 1 s before exit. On A-roll, one-fact groups replace each other after 1.8-3.0 s; a dependent sequence may build in one scene and exit together.\n- Background cards appear at their final size; content reveals inside them, the card never grows.\n- Lower-thirds: name and role from the pod record, once per speaker. B-roll: reuse a `content-asset` first; for new b-roll see the `collage-broll` skill.\n\n**Density and layout.**\n- Never cover the face. Overlays go in the upper-middle area (around 280 px from the top of 1080x1920), horizontally centred, sized to clear both face and captions.\n- Canvas clearance: 54 px horizontal, 88 px vertical; panel padding 48-72 px.\n- One focal group plus 1-4 supporting elements; layouts hold 1.8-3.5 s before a major change; about 6-8 graphics per 2 minutes as a starting point, not a quota.\n- Too empty: enrich the focal idea. Too crowded: drop support elements before shrinking text.\n\n## 6. Background and colour from the booth look\n\nContent Studio's booth records the RAW take and keeps the look the person picked before recording. Produce Content hands it to the run as the `look` param (`session.metadata.params.look`), a JSON string:\n\n```json\n{ \"background\": { \"assetId\": \"…\", \"fileId\": \"…\" } | { \"mode\": \"blur\" } | null,\n \"colour\": { \"assetId\": \"…\", \"fileId\": \"…\", \"intensity\": 1 } | null }\n```\n\n`assetId` is a Brand `brand-asset` (tag `booth-background`: a JPG or PNG; tag `colour-look`: a 33³ `.cube`); `fileId` is the file at its `asset-file-id`. Fetch a file with `GET /api/hub/files/<fileId>/url` (a signed URL: `{ url, expiresAt, mimeType }`), then download that URL.\n\nWith a look, the Background and Colour passes START from it. They stay passes: built right after the cut, background then colour, before captions and graphics (what is under the person first, then what sits on top), each with its own preview, its own slot (`layer-background`, `layer-colour`) and its own question: Approve, Rework, or Remove this pass. No look, or a `null` part of it: build that pass as you would without a look.\n\n**The raw take is never overwritten.** A pass writes new files beside it, so removing a pass is always possible.\n\n**Background pass.** The cut-out comes from the person's own matting tool (intent `edit_video`); the local matte is the fallback, never the first choice: it squashes each frame to 320×320 and has no memory between frames, so hair flickers and keeps the old room's colour. Whichever made the matte, the person goes over the background the look chose: the photo, or the blurred room for `mode: blur`. A person with no matting tool still gets their photo, with no setup.\n\n1. **Prepare the take once.** Matte the WHOLE raw take, not the cut, so every kept range keeps its source times. A variable frame rate take is made constant first (`ffmpeg -i <raw-take> -vf fps=<fps> -c:v libx264 -crf 16 -c:a copy media/take-cfr.mp4`) and stored in the pod (`synap_store_file`) beside the raw take; a constant-rate take is used as it is. What the tool gets is that take's pod `file` entity id, never a URL you copied.\n2. **Matte it with the person's `edit_video` tool.** `synap_list_capabilities({ intent: \"edit_video\" })` names the verb (Replicate RVM: `replicate_rvm_matte`). Run it with the take's file id in the verb's pod-file parameter and nothing else: RVM takes `{ \"videoFile\": <file entity id> }`. The pod signs the file when the run starts (a verb parameter declared `file:url`), so an approval that lands hours later still sends a live link; never fetch a signed URL yourself for it. A tool that declares no pod-file parameter takes `{ \"video\": <https URL> }`: get one with `GET /api/hub/files/<fileId>/url` just before the run (it lives one hour). A run that sends a pod file always comes back proposed (it sends the person's file out and bills their account): hand them the review link and wait for it. The result:\n - `status: \"succeeded\"`: `alphaUrl` (the matte, white = the person) and `foregroundUrl` (the model's own colour for the person, edges included);\n - `status: \"processing\"`: run its `next` (the read verb and parameters it names) every 30-60 s, for up to 15 min. Never run the matte verb again for the same take: it would bill again.\n Download every URL at once (`media/alpha.mp4`, `media/fgr.mp4`): providers delete them (Replicate after about an hour).\n3. **Gate the matte** before anything uses it. It passes only if all hold:\n - frames: `ffprobe -v error -count_frames -select_streams v:0 -show_entries stream=nb_read_frames,width,height,duration -of json` on the take, the alpha and the foreground: same size, frame counts within 1, durations within one frame;\n - no flicker: `ffmpeg -i media/alpha.mp4 -vf \"format=gray,tblend=all_mode=difference,signalstats,metadata=print:key=lavfi.signalstats.YAVG:file=media/alpha-diff.txt\" -f null -`; the mean change between two frames stays under 4 (of 255) on at least 99% of frames;\n - the person never vanishes: `signalstats` YAVG of the alpha itself never drops under half its median;\n - look at the 3 frames with the largest change (hair, a hand, a held object) and say what you saw.\n These thresholds are a starting point, not measured yet: say when a take fails one by a hair. A failed gate goes to the fallback (step 7) and says which check failed.\n4. **Defocus the background once.** An image look: one still, scaled to cover the frame and blurred, never per frame: `ffmpeg -i <background> -vf \"scale=<W>:<H>:force_original_aspect_ratio=increase,crop=<W>:<H>,gblur=sigma=12\" -frames:v 1 media/bg.png`. `mode: blur`: the real room WITHOUT the person, lightly blurred, like a phone's depth of field: the room's shapes still read. Blurring the take itself smears the person into the room as a dark halo, and a heavy blur reads as an effect, not a lens (founder, 2 Nov 2026: \"even the blur is ugly\"). Weight the take by where the person is not, blur it, and divide by the blurred weight:\n ```\n ffmpeg -i media/take-cfr.mp4 -i media/alpha.mp4 -filter_complex \"\n [1:v]format=gray,negate,split[inv][invw];[inv]format=gbrp[inv3];[0:v]format=gbrp[t];\n [t][inv3]blend=all_mode=multiply,gblur=sigma=9[num];[invw]gblur=sigma=9,format=gbrp[den];\n [num][den]blend=all_expr='if(gt(B\\,3)\\,min(255\\,A*255/B)\\,A)',format=yuv420p[room]\" \\\n -map \"[room]\" -an -c:v libx264 -crf 16 media/room-blur.mp4\n ```\n sigma 9 is for a take 1080 pixels wide: scale it with the width. Where the person hides the room entirely the result is black; it is always under the person.\n5. **Composite the person with ffmpeg**: the model's foreground colour, the matte choked one pixel and feathered, and a light wrap (the background's light bleeding over the person's edge). The person stays its own transparent layer, so HyperFrames can treat the background and the person apart:\n ```\n ffmpeg -i media/fgr.mp4 -i media/alpha.mp4 -loop 1 -framerate <fps> -i media/bg.png -filter_complex \"\n [1:v]format=gray,erosion,gblur=sigma=1.2,split[m][mw];\n [mw]negate,gblur=sigma=8[out];[m]split[m1][m2];[m2][out]blend=all_mode=multiply,lut=c0=val*0.35[band];\n [2:v]gblur=sigma=20,format=yuva420p[glow];[glow][band]alphamerge[wrap];\n [0:v]format=yuva420p[fg];[fg][m1]alphamerge[p];[p][wrap]overlay=shortest=1[person]\" \\\n -map \"[person]\" -c:v libvpx-vp9 -pix_fmt yuva420p -b:v 0 -crf 24 -an media/person.webm\n ```\n For `mode: blur` the third input is `media/room-blur.mp4` without `-loop 1 -framerate <fps>`, and the light wrap is left out (`[p]` is the person, no `[wrap]`): the room's light is already the person's. The chain is a starting point, not yet tuned: check the edge on sample frames, and raise the choke or lower the wrap strength (`0.35`) when a halo shows.\n6. **Lay it in HyperFrames.** Under the person, on the same ranges as the cut: the background (`media/bg.png`, `object-fit: cover`, for the whole video) or the blurred room (cut on the same ranges). The person video (`media/person.webm`) takes the raw take's place in the cut (same source in and out points; the sound still comes from the take); captions and graphics stay above it. Then:\n - grain on the background, so it matches the camera's noise: `npx hyperframes media-treatment --project . --file index.html --selector '<background>' --grading '{\"details\":{\"grain\":0.1}}' --apply --json`;\n - light match on the person: measure both with `ffmpeg -v error -i <file> -vf \"signalstats,metadata=print:file=-\" -f null -` (YAVG for brightness, UAVG/VAVG for the colour cast), then set the person's `adjust` section (exposure and warmth; `npx hyperframes media-treatment --capabilities --json` names the keys) until the person sits in the background's light. Small moves: the Colour pass grades both after this.\n7. **Fallback, in order.** The video is never broken:\n - no `edit_video` tool, a refused or failed run, still processing after 15 min, or a failed gate: matte locally with `npx hyperframes remove-background media/take-cfr.mp4 -o media/person-rb.webm --json` (hyperframes 0.8.211: a transparent VP9 WebM, its alpha in a side stream; the model, u2net_human_seg ~168 MB, downloads once to `~/.cache/hyperframes/background-removal/models/` and the first run loads it for about a minute with no output: it is not hung; then about 0.23 s per 1080×1440 frame on CoreML). Take its matte out as a video: `ffmpeg -c:v libvpx-vp9 -i media/person-rb.webm -vf alphaextract,format=gray -c:v libx264 -crf 12 -pix_fmt yuv420p media/alpha.mp4` (`-c:v libvpx-vp9` goes BEFORE `-i`: ffmpeg's own VP9 decoder drops the alpha). Run the gate (step 3) on it and put its result in the pass's question; a failed gate does not stop this path, the person judges the preview. Then steps 4 to 6 as for the tool's matte, with `media/take-cfr.mp4` as the foreground (in place of `media/fgr.mp4`), over the background the look chose: the photo, or the blurred room for `mode: blur`. Its edge can carry the old room's colour and keep an object beside the person (a lamp, a mic stand): say what you saw on sample frames (step 8). The person can answer Rework (for example \"use the blurred room\") or Remove this pass;\n - that failing too: the raw take stays in the cut, and the pass's preview and question say \"background not applied\" and why.\n Say in the pass's question which path made the preview.\n8. Sample frames before the preview: a segmenter can drop an object the person holds or keep a large one beside them. Say what you saw.\n9. Remove this pass: put the raw take back in the cut and drop the underlay; keep the matte files.\n\n**Colour pass.**\n1. Ingest the `.cube` (never read it into context): `npx hyperframes media-use resolve --type lut --from <downloaded>.cube --project .` validates it and freezes a copy as `.media/luts/lut_NNN.cube` (the `path` it prints).\n2. Grade every footage element of the frame (the person, and the background image or the blurred room) with the grading block's `lut`, keys `src` and `intensity` (0 to 1, the look's intensity): `npx hyperframes media-treatment --project . --file index.html --selector '<element>' --grading '{\"lut\":{\"src\":\".media/luts/lut_NNN.cube\",\"intensity\":<intensity>}}' --apply --json`. It writes `data-color-grading` on that element. An element the Background pass already treated (grain, light match) keeps those sections: send them again with `lut` in the same `--grading` object, so the LUT is added, not swapped in.\n3. Remove this pass: `npx hyperframes media-treatment --project . --file index.html --selector '<element>' --clear` on each element it graded.\n\n## 7. QA before showing anyone\n\n1. `npx hyperframes lint` and `npx hyperframes check` clean.\n2. Snapshot every graphic in its complete state (after its last reveal, before its exit) with `npx hyperframes snapshot` and open the images: whole phrases, no orphan words, no overflow, centred, clear of captions and face.\n3. Snapshot both sides of every cut (cut -0.1 s and +0.2 s): no visual jump, a persistent element sits at the same position on both sides.\n4. Measure the audio: `ffmpeg -i out.mp4 -af ebur128=peak=true -f null -`: the integrated loudness is the cut's audio's (the level the person chose in §4), true peak at or below -1 dBTP; look for spikes at seams. Another level means the audio was mixed or re-encoded after the cut: find where.\n5. `ffprobe` the output: duration matches the plan, audio and video present, stream starts within max(0.1 s, 2/fps).\n6. Confirm the brand font actually loaded (fallbacks fail silently).\nCap self-fix at 3 passes, then report what remains.\n\n## 8. Review, render, save\n\n1. One human review of a preview. Then render with `npx hyperframes render`. Its audio is a fresh AAC encode, and `render` has no flag to keep the source's.\n2. **Put the cut's audio back, then check it**, after every render the person hears, a pass's preview included: `ffmpeg -i <render>.mp4 -i media/cut-audio.m4a -map 0:v:0 -map 1:a:0 -c copy -movflags +faststart <name>-final.mp4`. The decoded audio of the two must hash the same: `ffmpeg -v error -i <name>-final.mp4 -map 0:a:0 -f md5 -`, and the same on `media/cut-audio.m4a`. Different hashes: do not upload; find the step that re-encoded. One exception: a project that adds sound of its own (music, a sound effect, b-roll with sound) ships the render's mix as rendered, said in the pass's question, and that mix is never re-encoded again either.\n3. **Too big to upload?** Re-encode the video only: `ffmpeg -i <name>-final.mp4 -map 0 -c:v libx264 -crf <a higher CRF> -preset slow -c:a copy -movflags +faststart <name>-upload.mp4`, then the hash check again. Never an audio encoder (a `-c:a` other than `copy`) on a render or a delivery.\n4. Upload the final MP4 to the pod (`synap_store_file`) and record it: the composition as `content-composition`, the render as `render-job` / `content-asset`, linked to the content piece. Save reusable pieces (lower-third, caption style) as assets.\n\n## Revisions\n\nStart from the delivered version. Change only what was flagged; keep every settled decision (wording, splits, placement, size, fps). A late cut is one frame-aligned range applied to video, audio, captions and graphics together. Fix a repeated defect across every graphic that shares it, then re-snapshot only those.\n\n## Sources\n\nAdapted from video-use SKILL.md (MIT, © browser-use) and cut-motion docs (Apache-2.0, cut-motion by Endless1936, © 2026 prototech).\n"
21
+ "body": "# Face-camera editing\n\nRaw face-camera or screen+camera footage → a finished cut. The recording is the authority for what was said: remove what failed, never invent speech.\n\n## Hard rules\n\n- Never generate the person: no lip-sync, avatar, face swap or synthetic voice of the speaker unless the user explicitly asks.\n- The person may edit the project in HyperFrames Studio at any time: build on their edits, never overwrite them (§4).\n- Never cut inside a word. Every cut edge snaps to a word boundary from a word-level transcript.\n- Confirm the cut strategy in plain words before touching the timeline. Render the final only after one human review.\n- Revisions change only what was flagged.\n- **The voice is encoded once after the take, never again.** The cut's audio is `media/cut-audio.m4a`, built from the take at 256 kb/s or more (§4). A render's own audio is never shipped: every render the person hears, a pass's preview included, is followed by a remux that copies the cut's audio stream, checked by hash before upload (§8). A re-encode for size touches the video only (`-c:a copy`). Loudness changes only as a step the person approved (§4), never silently. Why (31 Oct, run 22dcabe8): the delivery was re-encoded to fit the upload, the voice's third AAC encode at 125 kb/s, and its coding noise sat about 5 dB under the voice at 12-16 kHz; the founder heard it.\n- **The passes follow the `edit-loop` skill**: its slots (`layer-<pass>`, `studio`, `studio-app`, `needs-tool-<tool>`), its three answers (Approve, Rework, Use my edit) and its rules (a version before every pass, work from the diff, never write while a pass is the person's, acknowledge every answer in the room). Here the passes are `layer-cut`, `layer-background`, `layer-colour`, `layer-captions` and `layer-graphics`; background and colour add Remove this pass. Content Studio draws a pass, its preview and its question only from its `layer-<pass>` slot. Use `ask-<slug>` only for a question that is not a pass (free disk, a go-ahead), and `needs-tool-<tool>` for a missing tool (§1 step 0).\n\n## 1. Recall and set up\n\n0. **Check the tools first, before anything else.** On this computer run `node --version` (20 or later), `ffprobe -version`, `ffmpeg -version` and `HYPERFRAMES_NO_TELEMETRY=1 npx --yes hyperframes --version`. If one is missing or fails, do not work around it and do not start the run's passes: declare ONE `needs-tool-<tool>` slot per missing tool, shaped and answered as the `edit-loop` skill spells out, its \"I installed it\" description naming the one install command for this OS (e.g. `brew install ffmpeg`). Then post one room line naming what is missing, and end the run blocked. Never install anything yourself.\n Also note whether the HyperFrames desktop app is on this computer: `test -d /Applications/HyperFrames.app || test -d ~/Applications/HyperFrames.app`. Say it in one room line with the checks' result (`HyperFrames app: installed` or `HyperFrames app: not installed`). A missing app is not a slot by itself: it becomes one only when the person asks for the app (step 6).\n1. `synap_ask` for the brand kit (palette, caption font, lower-third template) and any approved `content-composition` to reuse.\n2. Record with Cap (separate camera, screen, mic tracks); keep originals untouched.\n3. `ffprobe` every source. Pick the delivery frame grid once (default 30 fps; 1080x1920 or 1920x1080) and keep it everywhere.\n4. **One project per video run: `edit/`.** It lives in the run's work folder, `~/synap-work/<run session id>/edit` (the folder `synap worker` gives the run): `HYPERFRAMES_NO_TELEMETRY=1 npx hyperframes init edit --non-interactive --skip-transcribe --resolution <portrait|landscape|square>`, the takes copied into `edit/media/`. A variable frame rate take is made constant once (§6, step 1: `media/take-cfr.mp4`) and the project uses that copy. Every pass happens in this project and every command below runs from `edit/`.\n5. **Start Studio and record its address.** `npx hyperframes preview --background --port <a free port, from 3002 up>`; check that `http://localhost:<port>/#project/edit` answers HTTP 200, then record it on the run as an outcome: `{ key: \"studio\", kind: \"link\", label: \"HyperFrames Studio\", required: false, ref: { url: \"http://localhost:<port>/#project/edit\" } }`. Content Studio draws it as \"Open in HyperFrames Studio\". Studio hot-reloads, so the person watches each pass as you write it. Whenever your preview process stops (the run leaves Draft or ends, the worker stops, or you stop it): `npx hyperframes preview --stop` and `retireOutput: \"studio\"`, so Content Studio never links a dead address. Back at Draft (Review sent it back): start it again and record the new address.\n6. **Studio asks from Content Studio.** \"Studio\" names a place, and Content Studio asks for one in the run's room. Answer between passes; an ask never undoes a pass.\n - **app** (\"Open this edit in the HyperFrames desktop app on this Mac (open the project folder in it), then wait\"). Installed (step 0): `open -a HyperFrames \"$HOME/synap-work/<run session id>/edit\"`, the absolute path of `edit/` as the document to open. That works whether the app is running or not; `open -a HyperFrames --args <dir>` reaches the app only when it is not running yet, and is ignored by a running app. The app adds the folder to its Home as a link (named `edit`, then `edit 2`, `edit 3`… across runs), so the person's edits land in this project, and §4's history turns still hold. Then record `{ key: \"studio-app\", kind: \"app\", label: \"HyperFrames app on <this computer's name>\", required: false }`, `completeOutput: \"studio-app\"`, post one room line and wait for the person. Not installed: declare the human slot `{ key: \"needs-tool-hyperframes-app\", kind: \"decision\", label: \"HyperFrames app on <this computer's name>\", owner: \"human\", blockedReason: \"capability\", why: \"The HyperFrames desktop app isn't installed on <this computer's name>\", unblocks: \"the edit opens in the HyperFrames app\", ask: { mode: \"choose\", options: [{ label: \"I installed it\", recommended: true, description: \"Download it from https://hyperframes.dev/studio/download, then the agent opens the edit in it\" }, { label: \"Use the browser Studio\", description: \"The agent starts Studio in the browser instead\" }] } }`. On \"I installed it\": check again; there, open the edit as above and settle the slot as step 0 does; still missing, say so on the same slot. On \"Use the browser Studio\": `retireOutput: \"needs-tool-hyperframes-app\"` and do **browser**.\n - **browser**: start Studio and record `studio` as in step 5 (an address of yours that still answers HTTP 200 is reused, never a second server).\n\n## 2. Transcribe (word level)\n\n- Word-level, verbatim ASR only: fillers and false starts stay in the text (they are the signal); phrase/SRT output loses the gaps you cut on.\n- One word-level ASR pass per source (`npx hyperframes transcribe <file> --engine parakeet`, or media-use's `transcribe.mjs`), only when the pod's transcript (below) has no word timings yet. Cache per source; never re-transcribe.\n- The pod keeps a take's transcript as ONE `content-asset` (Kind `script`, File ID = the take's file): its body holds the readable text under a `## Transcript` heading, then a `## Words` heading followed by exactly one ```json block of `[{\"text\",\"start\",\"end\"}]` in seconds (`[]` until the precise pass fills it). Find it before transcribing; if it exists, reuse it and only fill the word timings; if not, create it and link it to the take's file with `references`. Never a separate document.\n- Build a reading view: one line per phrase, `[start-end] text`, breaking on any silence of 0.5 s or more.\n\n## 3. Plan cuts on the transcript (semantic first)\n\nThree passes, in this order:\n\n1. **Semantic.** Remove misspeaks, failed starts, repeated takes, production chatter (\"wait, again\"). Keep the last complete, correct take by default; keep deliberate emphasis and rhetorical self-correction. Remove a filler only when context shows it is a standalone filler, not part of a word or a meaningful pause.\n2. **Pauses.** Remove dead air and restart preparation, including pauses inside kept phrases. Silence guide: 400 ms or more = clean cut target; 150-400 ms = usable after a visual check; under 150 ms = mid-phrase, do not cut. For a dB scan, look at -30, -35 and -40 dB with a 0.45 s minimum duration; a scan reports intervals, it does not authorise edits. Leave ambiguous breaths or possibly quiet words for the user to hear.\n3. **Edges.** Only after 1 and 2 are applied, tighten each kept clip to the real sound: first and last sustained activity on the louder channel (measured in 10 ms windows), keep one frame of margin, and back off to lower-level tails rather than clipping a decaying word.\n\n**Invisible cuts (picture continuity).** A cut the viewer notices is a failed cut (founder, 29 Oct). A cut that sits in a pause may slide inside that pause without touching speech, so put each edge where the face matches across it:\n- Score every out/in frame pair inside the pause: face position and size (a face detector), plus the grey-level difference of the face crop. Take the pair with the smallest jump, with a small cost for each extra second of silence kept (about 0.25 per second) so the pause does not grow back, and still remove at least 0.25 s.\n- A head turn inside the pause disappears with the cut: leave on the frame before the turn and come back on a frame already facing the camera, or the reverse. Never cut from mid-turn to facing the camera.\n- Never move an edge into speech, and never drop a word to gain continuity. When no pair matches (a large jump remains), keep the cut and flag it: a punch-in of about 110% on the incoming side hides it, or the user decides.\n- Report each cut's jump before and after. Pilot, 29 Oct (take b36227cd, 13 cuts): the mean jump fell from 0.59 to 0.29, for 4 s more runtime.\n\nPadding: 30-200 ms working window around every kept word (ASR drifts 50-100 ms). A good default is 50 ms before the first kept word and 80 ms after the last; tighter for punchy social, looser for calm explainers. Hand-offs between speakers: 400-600 ms of air. Extend past laughs; the reaction is the beat.\n\nWrite the plan as `{source, start, end, quote, reason}` ranges, state the runtime, show the user a short strategy, and wait for confirmation. In Produce Content this plan is the Script, and its approval (the Draft stage's gate) is the confirmation.\n\n## 4. Apply the cut\n\n- **Editor of record: HyperFrames.** The cut is the project's footage track, never a baked file: one `<video>` clip per kept range on track 0, all on the same source (`media/take-cfr.mp4`), edge to edge, each with an `id` and `data-has-audio=\"true\"`: `data-media-start` = the range's start in the source, `data-duration` = its length, `data-start` = the sum of the kept lengths before it (hyperframes-core's hard-cut recipe). media-use's `transcript-cut.mjs --plan` compiles the kept segments from the words and the removals: lay them as clips, never encode them. Studio shows the cut as it is written, and the person can trim any clip there.\n- Put a 30 ms volume ramp in and out on every clip, in its `data-automation` volume lane (`{\"t\":0,\"v\":0},{\"t\":0.03,\"v\":1},{\"t\":<dur-0.03>,\"v\":1},{\"t\":<dur>,\"v\":0}`), otherwise every cut pops.\n- **The cut's audio, encoded once.** Build `media/cut-audio.m4a` from the take on the footage clips' ranges, with the same 30 ms ramps, in ONE ffmpeg call: one `atrim` per clip in clip order, start = its `data-media-start`, end = start + its `data-duration`:\n ```\n ffmpeg -i media/take-cfr.mp4 -filter_complex \"[0:a]atrim=start=<s0>:end=<e0>,asetpts=PTS-STARTPTS,afade=t=in:d=0.03,afade=t=out:st=<d0-0.03>:d=0.03[a0];[0:a]atrim=…[a1];…;[a0][a1]…concat=n=<N>:v=0:a=1[a]\" -map \"[a]\" -c:a aac -b:a 256k -ar 48000 media/cut-audio.m4a\n ```\n When the footage track starts after 0 (an intro card), add `adelay=<ms>:all=1` after the concat. Rebuild it before every render in which a footage clip changed (your edit or the person's trim in Studio); it takes seconds. Never build it from a file that is already a re-encode of the take.\n- **Loudness is the person's call, asked once.** Measure the cut's audio: `ffmpeg -i media/cut-audio.m4a -af ebur128=peak=true -f null -` (integrated `I:` in LUFS, true peak). When it is more than 2 LU away from -14 LUFS (a Reel or a Short; the take of 31 Oct sat at -25), ask on the slot `ask-loudness`, beside the cut's question: ask `confirm`, prompt \"Raise the voice to about -14 LUFS? It is <I> LUFS now.\", yes recommended. Yes: rebuild the cut's audio once with a two-pass `loudnorm=I=-14:TP=-1:LRA=11` at the end of the same filter (first pass `print_format=json`, second pass with its `measured_I`, `measured_TP`, `measured_LRA`, `measured_thresh` and `offset`), still one encode. No: leave it. Never normalise without that answer, and never on a render.\n- Read the cut back with `npx hyperframes timeline --json`, then `lint` and `check`. The pass's preview is a draft render of the project (`npx hyperframes render --quality draft`).\n- Every cut must work on audio AND picture (check the screen side too).\n- Hand the user the cut to listen to before dressing it.\n\n**One history turn per pass; the person's edits win** (the version before every pass, edit-loop §4).\n- Start each pass with `npx hyperframes history begin --who <your agent name> --label \"<pass>\"`, then `npx hyperframes history --since mine --who <your agent name>` to see what the person changed in Studio since your last turn. Build on their edits; never overwrite them. End the pass with `npx hyperframes history end`.\n- A check failed, or the person says it got worse: `npx hyperframes history undo --who <your agent name>` takes back only your newest turn, never theirs.\n- **Use my edit** (an answer on a pass's question): the person made that pass themselves in Studio. Read what they changed (`history --since mine`), keep it as the pass and change nothing of it, render the pass's preview from the project as it is, point the slot at it and ask again.\n\n## 5. Dress in HyperFrames\n\nUse HyperFrames `embedded-captions` (captions only) or `talking-head-recut` (captions, lower-thirds, graphics, b-roll). The cut is a media layer; captions and graphics are HTML on a paused GSAP timeline. Deterministic only: no wall-clock, no requestAnimationFrame loops, seeded randomness, no CSS transitions or keyframes for motion, entrances via `fromTo`, no infinite repeats, `power3`-style easing, no bounce by default.\n\n**Caption timing.** Map words to the output timeline: `output_time = word.start - segment_start + segment_offset`. Cues are half-open integer frame windows `[startFrame, endFrame)`; adjacent cues may touch, never overlap, fade tails included. Captions are the top layer, added last, so no overlay hides them.\n\n**Caption segmentation.** Decide in this order: keep a word, name, number+unit or fixed phrase whole; break at a clause, punctuation, breath or topic reset; keep the cue readable for its duration; then measure the rendered width.\n- One rendered line per cue. Latin scripts: about 30-32 characters at most; CJK: about 10 characters.\n- Target 0.8-2.5 s per cue, never under 0.5 s; grow a cue rather than flash one shorter than about 0.35 s.\n- Never strand a lone article, particle or conjunction.\n- The concatenated cues must reproduce the approved transcript exactly; terminology corrections are a lexicon applied once to all cues.\n- Caption font = the brand font; small text contrast at least 4.5:1; no opaque bar; vertical default sits about 330 px above the bottom of a 1920 px frame.\n\n**Motion graphics timed to speech.** A graphic adds what captions cannot (evidence, relationship, scale, structure, next action); plain narration stays caption-only.\n- Bind each spoken element to its own keyword onset from the word timestamps: it enters on the first frame at or after that word, never on a fixed stagger or ahead of the speech. Neutral headings may enter at the beat start; connectors may follow a cue by a few frames.\n- Hold the resolved state long enough to read, at least 1 s before exit. On A-roll, one-fact groups replace each other after 1.8-3.0 s; a dependent sequence may build in one scene and exit together.\n- Background cards appear at their final size; content reveals inside them, the card never grows.\n- Lower-thirds: name and role from the pod record, once per speaker. B-roll: reuse a `content-asset` first; for new b-roll see the `collage-broll` skill.\n\n**Density and layout.**\n- Never cover the face. Overlays go in the upper-middle area (around 280 px from the top of 1080x1920), horizontally centred, sized to clear both face and captions.\n- Canvas clearance: 54 px horizontal, 88 px vertical; panel padding 48-72 px.\n- One focal group plus 1-4 supporting elements; layouts hold 1.8-3.5 s before a major change; about 6-8 graphics per 2 minutes as a starting point, not a quota.\n- Too empty: enrich the focal idea. Too crowded: drop support elements before shrinking text.\n\n## 6. Background and colour from the booth look\n\nContent Studio's booth records the RAW take and keeps the look the person picked before recording. Produce Content hands it to the run as the `look` param (`session.metadata.params.look`), a JSON string:\n\n```json\n{ \"background\": { \"assetId\": \"…\", \"fileId\": \"…\" } | { \"mode\": \"blur\" } | null,\n \"colour\": { \"assetId\": \"…\", \"fileId\": \"…\", \"intensity\": 1 } | null }\n```\n\n`assetId` is a Brand `brand-asset` (tag `booth-background`: a JPG or PNG; tag `colour-look`: a 33³ `.cube`); `fileId` is the file at its `asset-file-id`. Fetch a file with `GET /api/hub/files/<fileId>/url` (a signed URL: `{ url, expiresAt, mimeType }`), then download that URL.\n\nWith a look, the Background and Colour passes START from it. They stay passes: built right after the cut, background then colour, before captions and graphics (what is under the person first, then what sits on top), each with its own preview, its own slot (`layer-background`, `layer-colour`) and its own question: Approve, Rework, or Remove this pass. No look, or a `null` part of it: build that pass as you would without a look.\n\n**The raw take is never overwritten.** A pass writes new files beside it, so removing a pass is always possible.\n\n**Background pass.** Matte the person with the best tool this computer has, in this order, and say in the pass's question which one made the preview:\n- **a. Mac: `synap-matte`** (Apple Vision person segmentation at `accurate`, built on this Mac, free, nothing leaves the computer). Step 2a.\n- **b. Elsewhere: Robust Video Matting installed on this computer** (GPL-3.0: the person installs it, Synap never ships it). Step 2b.\n- **c. The person's `edit_video` tool**, only when they installed it (Replicate RVM: installing it with their own token is the opt-in; each run sends the take to Replicate and bills their account, so it is a proposal they approve with the price in front of them). Step 2c.\n- **d. Last resort: the local low-quality matte** (step 7). It squashes each frame to 320×320 and has no memory between frames, so hair flickers and keeps the old room's colour. When it made the preview, the pass's question says so plainly: \"Low-quality matte: hair may flicker and keep the old room's colour.\"\nWhichever made the matte, the person goes over the background the look chose: the photo, or the blurred room for `mode: blur`. A person with no matting tool still gets their photo, with no setup.\n\n1. **Prepare the take once.** Matte the WHOLE raw take, not the cut, so every kept range keeps its source times. A variable frame rate take is made constant first (`ffmpeg -i <raw-take> -vf fps=<fps> -c:v libx264 -crf 16 -c:a copy media/take-cfr.mp4`) and stored in the pod (`synap_store_file`) beside the raw take; a constant-rate take is used as it is. What a pod tool gets is that take's pod `file` entity id, never a URL you copied. Below, `<matte>` is the matte file you made (white = the person) and `<fg>` is the person's colour: the tool's foreground when it gives one, else `media/take-cfr.mp4`.\n2a. **`synap-matte` on a Mac.** Check it: `~/.synap/bin/synap-matte --version` prints `synap-matte 1.0.0`.\n - Missing: building it is an install, asked once per computer. Skip the ask when `~/.synap/bin/synap-matte.declined` exists (the person said not now on this computer) and go to 2b. Else ask on `{ key: \"ask-build-synap-matte\", kind: \"decision\", label: \"Mac matte on <this computer's name>\", owner: \"human\", blockedReason: \"permission\", why: \"A better background cut-out: Apple's own person matte, built on this Mac in about a minute; nothing leaves the computer\", unblocks: \"the background pass goes on\", ask: { mode: \"choose\", options: [{ label: \"Build it\", recommended: true, description: \"The agent downloads synap-matte's source from the Synap catalog, checks it, and builds it with Apple's compiler\" }, { label: \"Not now\", description: \"The pass uses the next matte in the list\" }] } }`. On \"Not now\": `touch ~/.synap/bin/synap-matte.declined`, `retireOutput: \"ask-build-synap-matte\"`, go to 2b. On \"Build it\":\n ```\n mkdir -p ~/.synap/bin/src && cd ~/.synap/bin/src\n curl -fsSLo synap-matte.swift https://cdn.jsdelivr.net/npm/@synap-core/workspace-templates@0.33.0/skills/facecam-edit/synap-matte.swift\n echo \"86a3ddec67c6dba536ee2c5e8d3a874d1d1e9903d0114ccb311e7b6b5f083b4f synap-matte.swift\" | shasum -a 256 -c -\n swiftc -O synap-matte.swift -o ~/.synap/bin/synap-matte && ~/.synap/bin/synap-matte --version\n ```\n A failed checksum stops here: never build a file that does not match. No `swiftc`: Apple's command line tools are missing, which only the person can install (`xcode-select --install`); say so on the same slot and go to 2b. Built and printing its version: `completeOutput: \"ask-build-synap-matte\"`.\n - Present but failing to run: a repair, rebuilt the same way without asking. Printing another version: an upgrade, asked as for a first build.\n - Run it: `~/.synap/bin/synap-matte media/take-cfr.mp4 media/alpha.mov accurate` (about 10 frames a second on Apple silicon; it prints one JSON line `{\"frames\", \"missing\", ...}`). `<matte>` = `media/alpha.mov`, `<fg>` = `media/take-cfr.mp4`. More than a few `missing` frames: say how many in the pass's question.\n2b. **Robust Video Matting on this computer**, when the person installed it in `~/.synap/tools/rvm` (its own venv, its checkpoint `rvm_mobilenetv3.pth`): `~/.synap/tools/rvm/.venv/bin/python ~/.synap/tools/rvm/inference.py --variant mobilenetv3 --checkpoint ~/.synap/tools/rvm/rvm_mobilenetv3.pth --device <cuda when torch.cuda.is_available(), else cpu> --input-source media/take-cfr.mp4 --downsample-ratio 0.25 --output-type video --output-alpha media/alpha.mp4 --output-foreground media/fgr.mp4 --output-video-mbps 20 --seq-chunk 12` (0.25 for 1080p, 0.125 for 4K). `<matte>` = `media/alpha.mp4`, `<fg>` = `media/fgr.mp4`. Not installed: never install it yourself; go to 2c.\n2c. **The person's `edit_video` tool.** `synap_list_capabilities({ intent: \"edit_video\" })` names the verb (Replicate RVM: `replicate_rvm_matte`); none: go to step 7. Run it with the take's file id in the verb's pod-file parameter and nothing else: RVM takes `{ \"videoFile\": <file entity id> }`. The pod signs the file when the run starts (a verb parameter declared `file:url`), so an approval that lands hours later still sends a live link; never fetch a signed URL yourself for it. A tool that declares no pod-file parameter takes `{ \"video\": <https URL> }`: get one with `GET /api/hub/files/<fileId>/url` just before the run (it lives one hour). A run that sends a pod file always comes back proposed (it sends the person's file out and bills their account): hand them the review link and wait for it. The result:\n - `status: \"succeeded\"`: `alphaUrl` (the matte, white = the person) and `foregroundUrl` (the model's own colour for the person, edges included);\n - `status: \"processing\"`: run its `next` (the read verb and parameters it names) every 30-60 s, for up to 15 min. Never run the matte verb again for the same take: it would bill again.\n Download every URL at once (`media/alpha.mp4`, `media/fgr.mp4`): providers delete them (Replicate after about an hour). `<matte>` = `media/alpha.mp4`, `<fg>` = `media/fgr.mp4`.\n3. **Gate the matte** before anything uses it. It passes only if all hold:\n - frames: `ffprobe -v error -count_frames -select_streams v:0 -show_entries stream=nb_read_frames,width,height,duration -of json` on the take, `<matte>` and `<fg>`: same size, frame counts within 1, durations within one frame;\n - no flicker: `ffmpeg -i <matte> -vf \"format=gray,tblend=all_mode=difference,signalstats,metadata=print:key=lavfi.signalstats.YAVG:file=media/alpha-diff.txt\" -f null -`; the mean change between two frames stays under 4 (of 255) on at least 99% of frames;\n - the person never vanishes: `signalstats` YAVG of the matte itself never drops under half its median;\n - look at the 3 frames with the largest change (hair, a hand, a held object) and say what you saw.\n These thresholds are a starting point (7 Nov, 4 s of a real take: `synap-matte` 100% of frames under 4, largest change 2.7; the local low-quality matte 97.5%, largest 7.4): say when a take fails one by a hair. A failed gate goes to the next matte in the list and says which check failed.\n4. **Defocus the background once, and give it the camera's look.** An image look: one still, scaled to cover the frame, then matched to the take and blurred, never per frame. A photo is sharper and more saturated than a webcam and lit by other light, which is what reads as fake. Measure the take and the covered photo (`ffmpeg -v error -i <file> -vf \"fps=2,signalstats,metadata=print:file=-\" -f null -`, the mean of YAVG and SATAVG over the frames):\n ```\n ffmpeg -i <background> -vf \"scale=<W>:<H>:force_original_aspect_ratio=increase,crop=<W>:<H>\" -frames:v 1 media/bg-cover.png\n ffmpeg -i media/bg-cover.png -vf \"eq=saturation=<S>:brightness=<B>,gblur=sigma=12\" -frames:v 1 media/bg.png\n ```\n `<S>` = SATAVG(take) / SATAVG(photo), kept between 0.6 and 1; `<B>` = half of (YAVG(take) − YAVG(photo)) / 255, kept between −0.08 and 0.08. The camera's grain goes on in step 6. `mode: blur`: the real room WITHOUT the person, lightly blurred, like a phone's depth of field: the room's shapes still read. Blurring the take itself smears the person into the room as a dark halo, and a heavy blur reads as an effect, not a lens (founder, 2 Nov 2026: \"even the blur is ugly\"). Weight the take by where the person is not, blur it, and divide by the blurred weight:\n ```\n ffmpeg -i media/take-cfr.mp4 -i <matte> -filter_complex \"\n [1:v]format=gray,negate,split[inv][invw];[inv]format=gbrp[inv3];[0:v]format=gbrp[t];\n [t][inv3]blend=all_mode=multiply,gblur=sigma=9[num];[invw]gblur=sigma=9,format=gbrp[den];\n [num][den]blend=all_expr='if(gt(B\\,3)\\,min(255\\,A*255/B)\\,A)',format=yuv420p[room]\" \\\n -map \"[room]\" -an -c:v libx264 -crf 16 media/room-blur.mp4\n ```\n sigma 9 is for a take 1080 pixels wide: scale it with the width. Where the person hides the room entirely the result is black; it is always under the person.\n5. **Composite the person with ffmpeg**, the edge fixes that stop the fake look: the matte smoothed over three frames (no shimmer on hair), choked one pixel and feathered; the edge's colour cleaned (where the matte is soft, the old room's colour is replaced by the person's own colour from just inside the edge); and a light wrap (the new background's light bleeding over the person's edge). The person stays its own transparent layer, so HyperFrames can treat the background and the person apart:\n ```\n ffmpeg -i <fg> -i <matte> -loop 1 -framerate <fps> -i media/bg.png -filter_complex \"\n [1:v]format=gray,tmix=frames=3:weights='1 2 1',erosion,gblur=sigma=1.2,split=4[m][mw][mc][md];\n [mc]lut=c0='clip((val-96)*1.6,0,255)',format=gbrp[core];[md]format=gbrp,split[mm][mn];\n [0:v]format=gbrp,split[f][f2];[f2][mm]blend=all_mode=multiply,gblur=sigma=6[num];[mn]gblur=sigma=6[den];\n [num][den]blend=all_expr='if(gt(B\\,3)\\,min(255\\,A*255/B)\\,A)'[ext];[ext][f][core]maskedmerge,format=yuva420p[clean];\n [mw]negate,gblur=sigma=8[out];[m]split[m1][m2];[m2][out]blend=all_mode=multiply,lut=c0=val*0.35[band];\n [2:v]gblur=sigma=20,format=yuva420p[glow];[glow][band]alphamerge[wrap];\n [clean][m1]alphamerge[p];[p][wrap]overlay=shortest=1[person]\" \\\n -map \"[person]\" -c:v libvpx-vp9 -pix_fmt yuva420p -b:v 0 -crf 24 -an media/person.webm\n ```\n For `mode: blur` the third input is `media/room-blur.mp4` without `-loop 1 -framerate <fps>`, and the light wrap is left out (`[p]` is the person, no `[wrap]`): the room's light is already the person's. The chain is a starting point, tried on one real take (7 Nov): check the edge on sample frames; a halo: raise the choke (a second `erosion`) or lower the wrap strength (`0.35`); hair smeared at the edge: lower the colour clean's reach (`gblur=sigma=6` to 3).\n6. **Lay it in HyperFrames.** Under the person, on the same ranges as the cut: the background (`media/bg.png`, `object-fit: cover`, for the whole video) or the blurred room (cut on the same ranges). The person video (`media/person.webm`) takes the raw take's place in the cut (same source in and out points; the sound still comes from the take); captions and graphics stay above it. Then:\n - grain on the background, so it matches the camera's noise: `npx hyperframes media-treatment --project . --file index.html --selector '<background>' --grading '{\"details\":{\"grain\":0.1}}' --apply --json`;\n - light match on the person: measure both with `ffmpeg -v error -i <file> -vf \"signalstats,metadata=print:file=-\" -f null -` (YAVG for brightness, UAVG/VAVG for the colour cast), then set the person's `adjust` section (exposure and warmth; `npx hyperframes media-treatment --capabilities --json` names the keys) until the person sits in the background's light. Small moves: the Colour pass grades both after this.\n7. **Fallback, in order.** The video is never broken:\n - no matte from a to c (none on this computer, a refused or failed run, still processing after 15 min, or every one failed its gate): matte locally with `npx hyperframes remove-background media/take-cfr.mp4 -o media/person-rb.webm --json` (hyperframes 0.8.211: a transparent VP9 WebM, its alpha in a side stream; the model, u2net_human_seg ~168 MB, downloads once to `~/.cache/hyperframes/background-removal/models/` and the first run loads it for about a minute with no output: it is not hung; then about 0.23 s per 1080×1440 frame on CoreML). Take its matte out as a video: `ffmpeg -c:v libvpx-vp9 -i media/person-rb.webm -vf alphaextract,format=gray -c:v libx264 -crf 12 -pix_fmt yuv420p media/alpha.mp4` (`-c:v libvpx-vp9` goes BEFORE `-i`: ffmpeg's own VP9 decoder drops the alpha). Run the gate (step 3) on it and put its result in the pass's question; a failed gate does not stop this path, the person judges the preview. Then steps 4 to 6 as for the tool's matte, with `<matte>` = `media/alpha.mp4` and `<fg>` = `media/take-cfr.mp4`, over the background the look chose: the photo, or the blurred room for `mode: blur`. Its edge can carry the old room's colour and keep an object beside the person (a lamp, a mic stand, a shelf): say what you saw on sample frames (step 8). The pass's question opens with \"Low-quality matte: hair may flicker and keep the old room's colour.\" and names the better matte this computer could have (on a Mac: answer Rework \"build the Mac matte\", which removes `~/.synap/bin/synap-matte.declined` and asks 2a again). The person can answer Rework (for example \"use the blurred room\") or Remove this pass;\n - that failing too: the raw take stays in the cut, and the pass's preview and question say \"background not applied\" and why.\n Say in the pass's question which path made the preview.\n8. Sample frames before the preview: a segmenter can drop an object the person holds or keep a large one beside them. Say what you saw.\n9. Remove this pass: put the raw take back in the cut and drop the underlay; keep the matte files.\n\n**Colour pass.**\n1. Ingest the `.cube` (never read it into context): `npx hyperframes media-use resolve --type lut --from <downloaded>.cube --project .` validates it and freezes a copy as `.media/luts/lut_NNN.cube` (the `path` it prints).\n2. Grade every footage element of the frame (the person, and the background image or the blurred room) with the grading block's `lut`, keys `src` and `intensity` (0 to 1, the look's intensity): `npx hyperframes media-treatment --project . --file index.html --selector '<element>' --grading '{\"lut\":{\"src\":\".media/luts/lut_NNN.cube\",\"intensity\":<intensity>}}' --apply --json`. It writes `data-color-grading` on that element. An element the Background pass already treated (grain, light match) keeps those sections: send them again with `lut` in the same `--grading` object, so the LUT is added, not swapped in.\n3. Remove this pass: `npx hyperframes media-treatment --project . --file index.html --selector '<element>' --clear` on each element it graded.\n\n## 7. QA before showing anyone\n\n1. `npx hyperframes lint` and `npx hyperframes check` clean.\n2. Snapshot every graphic in its complete state (after its last reveal, before its exit) with `npx hyperframes snapshot` and open the images: whole phrases, no orphan words, no overflow, centred, clear of captions and face.\n3. Snapshot both sides of every cut (cut -0.1 s and +0.2 s): no visual jump, a persistent element sits at the same position on both sides.\n4. Measure the audio: `ffmpeg -i out.mp4 -af ebur128=peak=true -f null -`: the integrated loudness is the cut's audio's (the level the person chose in §4), true peak at or below -1 dBTP; look for spikes at seams. Another level means the audio was mixed or re-encoded after the cut: find where.\n5. `ffprobe` the output: duration matches the plan, audio and video present, stream starts within max(0.1 s, 2/fps).\n6. Confirm the brand font actually loaded (fallbacks fail silently).\nCap self-fix at 3 passes, then report what remains.\n\n## 8. Review, render, save\n\n1. One human review of a preview. Then render with `npx hyperframes render`. Its audio is a fresh AAC encode, and `render` has no flag to keep the source's.\n2. **Put the cut's audio back, then check it**, after every render the person hears, a pass's preview included: `ffmpeg -i <render>.mp4 -i media/cut-audio.m4a -map 0:v:0 -map 1:a:0 -c copy -movflags +faststart <name>-final.mp4`. The decoded audio of the two must hash the same: `ffmpeg -v error -i <name>-final.mp4 -map 0:a:0 -f md5 -`, and the same on `media/cut-audio.m4a`. Different hashes: do not upload; find the step that re-encoded. One exception: a project that adds sound of its own (music, a sound effect, b-roll with sound) ships the render's mix as rendered, said in the pass's question, and that mix is never re-encoded again either.\n3. **Too big to upload?** Re-encode the video only: `ffmpeg -i <name>-final.mp4 -map 0 -c:v libx264 -crf <a higher CRF> -preset slow -c:a copy -movflags +faststart <name>-upload.mp4`, then the hash check again. Never an audio encoder (a `-c:a` other than `copy`) on a render or a delivery.\n4. Upload the final MP4 to the pod (`synap_store_file`) and record it: the composition as `content-composition`, the render as `render-job` / `content-asset`, linked to the content piece. Save reusable pieces (lower-third, caption style) as assets.\n\n## Revisions\n\nStart from the delivered version. Change only what was flagged; keep every settled decision (wording, splits, placement, size, fps). A late cut is one frame-aligned range applied to video, audio, captions and graphics together. Fix a repeated defect across every graphic that shares it, then re-snapshot only those.\n\n## Sources\n\nAdapted from video-use SKILL.md (MIT, © browser-use) and cut-motion docs (Apache-2.0, cut-motion by Endless1936, © 2026 prototech).\n"
22
22
  },
23
23
  "dependencies": []
24
24
  }
@@ -0,0 +1,125 @@
1
+ // synap-matte: a person matte for a video, made on this Mac by Apple Vision.
2
+ // White = the person. One matte frame per video frame, same times, same size,
3
+ // same rotation flag as the input, so ffmpeg lines it up with the take.
4
+ //
5
+ // Build (macOS 12 or later): swiftc -O synap-matte.swift -o synap-matte
6
+ // Use: synap-matte <input video> <output matte .mov> [accurate|balanced|fast]
7
+ // synap-matte --version
8
+ //
9
+ // The request runs through one VNSequenceRequestHandler for the whole video,
10
+ // so Vision sees the frames in order. The matte is written as ProRes 422 HQ.
11
+ import AVFoundation
12
+ import CoreImage
13
+ import Foundation
14
+ import Vision
15
+
16
+ let version = "synap-matte 1.0.0"
17
+ let args = CommandLine.arguments
18
+
19
+ func fail(_ message: String, _ code: Int32 = 1) -> Never {
20
+ FileHandle.standardError.write((message + "\n").data(using: .utf8)!)
21
+ exit(code)
22
+ }
23
+
24
+ if args.count == 2 && args[1] == "--version" {
25
+ print(version)
26
+ exit(0)
27
+ }
28
+ guard args.count == 3 || args.count == 4 else {
29
+ fail("usage: synap-matte <input video> <output matte .mov> [accurate|balanced|fast]", 2)
30
+ }
31
+ let quality = args.count == 4 ? args[3] : "accurate"
32
+ guard ["accurate", "balanced", "fast"].contains(quality) else {
33
+ fail("quality must be accurate, balanced or fast", 2)
34
+ }
35
+
36
+ let inURL = URL(fileURLWithPath: args[1])
37
+ let outURL = URL(fileURLWithPath: args[2])
38
+ try? FileManager.default.removeItem(at: outURL)
39
+
40
+ let asset = AVURLAsset(url: inURL)
41
+ guard let track = asset.tracks(withMediaType: .video).first else { fail("no video track in \(args[1])") }
42
+ let size = track.naturalSize
43
+ let width = Int(size.width), height = Int(size.height)
44
+
45
+ let reader: AVAssetReader
46
+ do { reader = try AVAssetReader(asset: asset) } catch { fail("cannot read \(args[1]): \(error)") }
47
+ let readerOutput = AVAssetReaderTrackOutput(
48
+ track: track,
49
+ outputSettings: [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA])
50
+ readerOutput.alwaysCopiesSampleData = false
51
+ reader.add(readerOutput)
52
+
53
+ let writer: AVAssetWriter
54
+ do { writer = try AVAssetWriter(outputURL: outURL, fileType: .mov) } catch { fail("cannot write \(args[2]): \(error)") }
55
+ let writerInput = AVAssetWriterInput(
56
+ mediaType: .video,
57
+ outputSettings: [
58
+ AVVideoCodecKey: AVVideoCodecType.proRes422HQ,
59
+ AVVideoWidthKey: width,
60
+ AVVideoHeightKey: height,
61
+ ])
62
+ writerInput.expectsMediaDataInRealTime = false
63
+ writerInput.transform = track.preferredTransform
64
+ let adaptor = AVAssetWriterInputPixelBufferAdaptor(
65
+ assetWriterInput: writerInput,
66
+ sourcePixelBufferAttributes: [
67
+ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
68
+ kCVPixelBufferWidthKey as String: width,
69
+ kCVPixelBufferHeightKey as String: height,
70
+ ])
71
+ writer.add(writerInput)
72
+
73
+ let request = VNGeneratePersonSegmentationRequest()
74
+ request.qualityLevel = quality == "fast" ? .fast : quality == "balanced" ? .balanced : .accurate
75
+ request.outputPixelFormat = kCVPixelFormatType_OneComponent8
76
+ let sequence = VNSequenceRequestHandler()
77
+ // No colour management: the mask's values go to the file as they are.
78
+ let context = CIContext(options: [.workingColorSpace: NSNull(), .outputColorSpace: NSNull()])
79
+
80
+ guard reader.startReading() else { fail("cannot read \(args[1]): \(String(describing: reader.error))") }
81
+ guard writer.startWriting() else { fail("cannot write \(args[2]): \(String(describing: writer.error))") }
82
+
83
+ let started = Date()
84
+ var frames = 0, missing = 0
85
+ var lastMask: CIImage?
86
+
87
+ while let sample = readerOutput.copyNextSampleBuffer() {
88
+ guard let frame = CMSampleBufferGetImageBuffer(sample) else { continue }
89
+ let time = CMSampleBufferGetPresentationTimeStamp(sample)
90
+ if frames == 0 { writer.startSession(atSourceTime: time) } // the matte starts where the video starts
91
+ var mask: CIImage? = nil
92
+ if (try? sequence.perform([request], on: frame)) != nil, let buffer = request.results?.first?.pixelBuffer {
93
+ let m = CIImage(cvPixelBuffer: buffer)
94
+ let sy = CGFloat(height) / m.extent.height, sx = CGFloat(width) / m.extent.width
95
+ mask = m.applyingFilter("CILanczosScaleTransform", parameters: [
96
+ kCIInputScaleKey: sy, kCIInputAspectRatioKey: sx / sy,
97
+ ]).cropped(to: CGRect(x: 0, y: 0, width: width, height: height))
98
+ }
99
+ // A frame Vision could not read keeps the previous matte (black before the first one),
100
+ // so the matte always has exactly the input's frames.
101
+ if mask == nil { missing += 1 }
102
+ let out = mask ?? lastMask ?? CIImage(color: .black).cropped(to: CGRect(x: 0, y: 0, width: width, height: height))
103
+ lastMask = out
104
+ guard let pool = adaptor.pixelBufferPool else { fail("the writer has no buffer pool: \(String(describing: writer.error))") }
105
+ var pixel: CVPixelBuffer?
106
+ CVPixelBufferPoolCreatePixelBuffer(nil, pool, &pixel)
107
+ guard let target = pixel else { fail("cannot allocate a frame") }
108
+ context.render(out, to: target)
109
+ while !writerInput.isReadyForMoreMediaData { usleep(1000) }
110
+ if !adaptor.append(target, withPresentationTime: time) {
111
+ fail("cannot write frame \(frames): \(String(describing: writer.error))")
112
+ }
113
+ frames += 1
114
+ }
115
+ if reader.status == .failed { fail("reading stopped at frame \(frames): \(String(describing: reader.error))") }
116
+
117
+ writerInput.markAsFinished()
118
+ let done = DispatchSemaphore(value: 0)
119
+ writer.finishWriting { done.signal() }
120
+ done.wait()
121
+ if writer.status != .completed { fail("cannot finish \(args[2]): \(String(describing: writer.error))") }
122
+
123
+ let seconds = Date().timeIntervalSince(started)
124
+ print("{\"frames\":\(frames),\"missing\":\(missing),\"width\":\(width),\"height\":\(height),\"quality\":\"\(quality)\",\"seconds\":\(String(format: "%.1f", seconds)),\"output\":\"\(outURL.path)\"}")
125
+ if frames == 0 { fail("no frame was matted") }
@@ -158,13 +158,16 @@ describe("Produce Content starts the Background and Colour passes from the booth
158
158
  });
159
159
 
160
160
  /**
161
- * The Background pass cuts the person out with THEIR matting tool (intent
162
- * `edit_video`, founder decisions be0deab7 and ac1ba2b6, 2026-10-28), and the
163
- * local HyperFrames matte is only the fallback: it squashes each frame to
164
- * 320x320 with no memory between frames (research 2e187277). The chain never
165
- * breaks the video: tool → local matte over the real room → raw take.
161
+ * The Background pass cuts the person out with the best matte the computer has
162
+ * (lane W0, 7 Nov 2026, decisions 168f34db: a Mac's synap-matte, local RVM, then
163
+ * THEIR edit_video tool only when they installed it — decisions be0deab7 and
164
+ * ac1ba2b6, 2026-10-28), and the local HyperFrames matte is only the last
165
+ * resort: it squashes each frame to 320x320 with no memory between frames
166
+ * (research 2e187277). The chain never breaks the video: best matte → local
167
+ * matte over the chosen background → raw take. The order itself is pinned in
168
+ * facecam-edit.matte.test.ts.
166
169
  */
167
- describe("Produce Content's Background pass uses the person's edit_video tool first", () => {
170
+ describe("Produce Content's Background pass uses the best matte the computer has", () => {
168
171
  const produce = playbook("Produce Content");
169
172
  const goal = (produce?.stages?.find((s) => s.key === "draft")?.goal ?? "").replace(/\s+/g, " ");
170
173
  const look = goal.slice(goal.indexOf("A LOOK given"));
@@ -180,20 +183,25 @@ describe("Produce Content's Background pass uses the person's edit_video tool fi
180
183
  });
181
184
 
182
185
  it("the Draft goal gates the matte and finishes the composite, in order", () => {
183
- const steps = ["gate the matte", "frame count and duration match the take", "no flicker", "defocus the background once", "choke, feather, light wrap", "grain on the background and a light match on the person"];
186
+ const steps = ["gate the matte", "frame count and duration match the take", "no flicker", "defocus the background once", "choke, feather, clean edge, light wrap", "grain on the background and a light match on the person"];
184
187
  const at = steps.map((s) => look.indexOf(s));
185
188
  expect(at.every((i) => i >= 0)).toBe(true);
186
189
  expect([...at].sort((a, b) => a - b)).toEqual(at);
187
190
  });
188
191
 
189
- it("the Draft goal falls back tool → local matte over the chosen background → raw take marked background not applied", () => {
192
+ it("the Draft goal goes synap-matte → local RVM → edit_video tool → low-quality local matte over the chosen background → raw take marked background not applied", () => {
193
+ const order = ["Mac synap-matte", "local RVM", "the person's edit_video tool"].map((s) => look.indexOf(s));
194
+ expect(order.every((i) => i >= 0)).toBe(true);
195
+ expect([...order].sort((a, b) => a - b)).toEqual(order);
196
+ expect(look).toContain("intent edit_video) if installed");
190
197
  const tool = look.indexOf("edit_video tool");
191
198
  const local = look.indexOf("npx hyperframes remove-background instead and composite the same way over the same background");
192
199
  const raw = look.indexOf('says "background not applied"');
193
200
  expect(tool).toBeGreaterThanOrEqual(0);
194
201
  expect(local).toBeGreaterThan(tool);
195
202
  expect(raw).toBeGreaterThan(local);
196
- expect(look.indexOf("No edit_video tool, a failed run or a failed gate")).toBeLessThan(local);
203
+ expect(look.indexOf("No matte, a failed run or gate")).toBeLessThan(local);
204
+ expect(look.indexOf("over the same background, saying: low quality")).toBeGreaterThan(local);
197
205
  });
198
206
 
199
207
  it("the skill runs the tool with the video alone, waits through next, and never re-runs the billed verb", () => {
@@ -212,8 +220,8 @@ describe("Produce Content's Background pass uses the person's edit_video tool fi
212
220
  for (const part of ["nb_read_frames", "tblend=all_mode=difference", "never drops under half its median", "gblur=sigma=12", "alphamerge", '"details":{"grain"', "`adjust` section"]) {
213
221
  expect(pass).toContain(part);
214
222
  }
215
- expect(pass.indexOf("**Gate the matte**")).toBeLessThan(pass.indexOf("**Defocus the background once.**"));
216
- expect(pass.indexOf("**Defocus the background once.**")).toBeLessThan(pass.indexOf("**Composite the person with ffmpeg**"));
223
+ expect(pass.indexOf("**Gate the matte**")).toBeLessThan(pass.indexOf("**Defocus the background once"));
224
+ expect(pass.indexOf("**Defocus the background once")).toBeLessThan(pass.indexOf("**Composite the person with ffmpeg**"));
217
225
  });
218
226
 
219
227
  it("the skill's fallback order is local matte over the chosen background, then the raw take, and Remove this pass keeps working", () => {
@@ -2045,17 +2045,18 @@ playbooks:
2045
2045
  null, "colour": {"assetId","fileId","intensity"} or null}, picked in
2046
2046
  the booth before recording) means the Background pass and the Colour
2047
2047
  pass START from it, as the facecam-edit skill spells out. Background:
2048
- matte the person from the raw take with the person's edit_video tool
2049
- (synap_list_capabilities with intent edit_video), handing it the
2050
- take's pod file id ({"videoFile": id}; the pod signs it at run
2051
- time); gate the matte (its frame
2052
- count and duration match the take, no flicker); defocus the
2053
- background once; composite with ffmpeg (choke, feather, light wrap);
2054
- then grain on the background and a light match on the person. The
2055
- background is the background file (downloaded from the pod by its
2056
- fileId), or, for mode blur, a blurred copy of the raw take. No edit_video tool, a failed run or a failed gate: matte with
2057
- npx hyperframes remove-background instead and composite the same way
2058
- over the same background; that failing
2048
+ matte the raw take with the best matte here, in the skill's order
2049
+ (Mac synap-matte, local RVM, the person's edit_video tool
2050
+ (synap_list_capabilities with intent edit_video) if installed,
2051
+ handing it the take's pod file id ({"videoFile": id}; the pod signs
2052
+ it at run time)); gate the matte (its frame count and duration match
2053
+ the take, no flicker); defocus the background once; composite with
2054
+ ffmpeg (choke, feather, clean edge, light wrap); then grain on the
2055
+ background and a light match on the person. The background is the
2056
+ background file (from the pod by its fileId), or, for mode blur, a
2057
+ blurred copy of the raw take. No matte, a failed run or gate: matte
2058
+ with npx hyperframes remove-background instead and composite the
2059
+ same way over the same background, saying: low quality; that failing
2059
2060
  too: the raw take, and the pass's question says "background not
2060
2061
  applied". Colour: download the
2061
2062
  .cube by its fileId and apply it as the grading block grading.lut
@@ -0,0 +1,190 @@
1
+ /**
2
+ * facecam-edit's background matte (lane W0 of the computer-run capabilities
3
+ * program, session df45bc94, plan b647769e; founder decisions 168f34db, 7 Nov
4
+ * 2026: install asks once per tool per computer, repairs run without asking,
5
+ * GPL tools are installed and never bundled, Replicate only when opted in).
6
+ *
7
+ * The "fake background" came from the last-resort matte (u2net, 320x320 per
8
+ * frame, no memory between frames) and a composite that left the old room's
9
+ * colour on the edge. So:
10
+ * - the matte order is: a Mac's `synap-matte` (Apple Vision, built from the
11
+ * Swift source shipped next to this skill), local Robust Video Matting, the
12
+ * person's edit_video tool only when they installed it, and u2net last,
13
+ * saying plainly that it is low quality;
14
+ * - the composite smooths the matte over time, chokes and feathers it,
15
+ * cleans the edge's colour and wraps the new background's light, and the
16
+ * background gets the camera's look.
17
+ *
18
+ * The Swift source and the ffmpeg chain are RUN here when the computer can
19
+ * (swiftc on a Mac; ffmpeg anywhere): the text the agent follows is the text
20
+ * that is tested.
21
+ */
22
+ import { execFileSync, spawnSync } from "child_process";
23
+ import { createHash } from "crypto";
24
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "fs";
25
+ import { tmpdir } from "os";
26
+ import { dirname, join } from "path";
27
+ import { fileURLToPath } from "url";
28
+ import { afterAll, describe, expect, it } from "vitest";
29
+
30
+ const here = dirname(fileURLToPath(import.meta.url));
31
+ const skillDir = join(here, "../skills/facecam-edit");
32
+ const body = (
33
+ JSON.parse(readFileSync(join(skillDir, "facecam-edit.skill.json"), "utf8")) as {
34
+ spec: { body: string };
35
+ }
36
+ ).spec.body;
37
+ const swiftPath = join(skillDir, "synap-matte.swift");
38
+ const swift = readFileSync(swiftPath, "utf8");
39
+ const pkgVersion = (
40
+ JSON.parse(readFileSync(join(here, "../package.json"), "utf8")) as { version: string }
41
+ ).version;
42
+
43
+ const pass = body.slice(body.indexOf("**Background pass.**"), body.indexOf("**Colour pass.**"));
44
+ const between = (from: string, to: string) => {
45
+ const start = pass.indexOf(from);
46
+ const end = pass.indexOf(to, start + 1);
47
+ expect(start).toBeGreaterThanOrEqual(0);
48
+ expect(end).toBeGreaterThan(start);
49
+ return pass.slice(start, end);
50
+ };
51
+ /** The first fenced block after `marker`, without its fences. */
52
+ const fenceAfter = (marker: string) => {
53
+ const at = pass.indexOf(marker);
54
+ expect(at).toBeGreaterThanOrEqual(0);
55
+ const open = pass.indexOf("```\n", at);
56
+ const close = pass.indexOf("```", open + 4);
57
+ return pass.slice(open + 4, close);
58
+ };
59
+
60
+ const has = (cmd: string, args: string[]) => spawnSync(cmd, args, { stdio: "ignore" }).status === 0;
61
+ const canSwift = process.platform === "darwin" && has("swiftc", ["--version"]);
62
+ const canFfmpeg = has("ffmpeg", ["-version"]) && has("ffprobe", ["-version"]);
63
+ const work = mkdtempSync(join(tmpdir(), "facecam-matte-"));
64
+ afterAll(() => rmSync(work, { recursive: true, force: true }));
65
+ const frames = (file: string) =>
66
+ Number(
67
+ execFileSync("ffprobe", ["-v", "error", "-count_frames", "-select_streams", "v:0", "-show_entries", "stream=nb_read_frames", "-of", "csv=p=0", file], {
68
+ encoding: "utf8",
69
+ }).trim(),
70
+ );
71
+
72
+ describe("facecam-edit picks the best matte this computer has", () => {
73
+ const order = between("**Background pass.**", "1. **Prepare the take once.**");
74
+
75
+ it("lists the four mattes in order: synap-matte, local RVM, the person's edit_video tool, the low-quality matte", () => {
76
+ const at = ["- **a. Mac: `synap-matte`**", "- **b. Elsewhere: Robust Video Matting", "- **c. The person's `edit_video` tool**", "- **d. Last resort: the local low-quality matte**"].map((s) =>
77
+ order.indexOf(s),
78
+ );
79
+ expect(at.every((i) => i >= 0)).toBe(true);
80
+ expect([...at].sort((x, y) => x - y)).toEqual(at);
81
+ // The steps run in the same order, the low-quality matte last.
82
+ const steps = ["2a. **`synap-matte` on a Mac.**", "2b. **Robust Video Matting on this computer**", "2c. **The person's `edit_video` tool.**", "7. **Fallback, in order.**"].map((s) => pass.indexOf(s));
83
+ expect(steps.every((i) => i >= 0)).toBe(true);
84
+ expect([...steps].sort((x, y) => x - y)).toEqual(steps);
85
+ });
86
+
87
+ it("uses Replicate only when the person installed it, and says it bills and sends the take out", () => {
88
+ expect(order).toContain("only when they installed it");
89
+ expect(order).toContain("each run sends the take to Replicate and bills their account");
90
+ expect(pass).toContain('synap_list_capabilities({ intent: "edit_video" })` names the verb (Replicate RVM: `replicate_rvm_matte`); none: go to step 7');
91
+ });
92
+
93
+ it("never installs or ships RVM (GPL-3.0): the person installs it", () => {
94
+ expect(order).toContain("GPL-3.0: the person installs it, Synap never ships it");
95
+ expect(between("2b. **Robust", "2c. **")).toContain("Not installed: never install it yourself");
96
+ });
97
+
98
+ it("says plainly when the low-quality matte made the preview", () => {
99
+ const plain = '"Low-quality matte: hair may flicker and keep the old room\'s colour."';
100
+ expect(order).toContain(plain);
101
+ expect(between("7. **Fallback, in order.**", "8. Sample frames")).toContain(`The pass's question opens with ${plain}`);
102
+ });
103
+ });
104
+
105
+ describe("synap-matte is built on the Mac from the source shipped beside the skill", () => {
106
+ const install = between("2a. **`synap-matte` on a Mac.**", "2b. **");
107
+ const version = /let version = "(synap-matte [0-9.]+)"/.exec(swift)?.[1];
108
+
109
+ it("downloads exactly this file, at a published version, and checks its sha256 before building", () => {
110
+ const url = /https:\/\/cdn\.jsdelivr\.net\/npm\/@synap-core\/workspace-templates@([0-9.]+)\/(\S+)/.exec(install);
111
+ expect(url?.[2]).toBe("skills/facecam-edit/synap-matte.swift");
112
+ // Never a version this package has not reached (it would 404). When the source
113
+ // changes, its sha changes below: point the URL at the version that commit publishes.
114
+ const [a, b] = [url![1], pkgVersion].map((v) => v.split(".").map(Number));
115
+ expect(a[0] * 1e6 + a[1] * 1e3 + a[2]).toBeLessThanOrEqual(b[0] * 1e6 + b[1] * 1e3 + b[2]);
116
+ const sha = createHash("sha256").update(readFileSync(swiftPath)).digest("hex");
117
+ expect(install).toContain(`echo "${sha} synap-matte.swift" | shasum -a 256 -c -`);
118
+ expect(install).toContain("A failed checksum stops here: never build a file that does not match");
119
+ });
120
+
121
+ it("names the version the source prints", () => {
122
+ expect(version).toBeDefined();
123
+ expect(install).toContain(`prints \`${version}\``);
124
+ });
125
+
126
+ it("asks once per computer to build, repairs without asking, asks again for an upgrade", () => {
127
+ expect(install).toContain("building it is an install, asked once per computer");
128
+ expect(install).toContain('key: "ask-build-synap-matte"');
129
+ expect(install).toContain("~/.synap/bin/synap-matte.declined");
130
+ expect(install).toContain("a repair, rebuilt the same way without asking");
131
+ expect(install).toContain("Printing another version: an upgrade, asked as for a first build");
132
+ });
133
+
134
+ it.skipIf(!canSwift)(
135
+ "compiles with swiftc, prints its version, and mattes every frame of a video",
136
+ () => {
137
+ const bin = join(work, "synap-matte");
138
+ execFileSync("swiftc", ["-O", swiftPath, "-o", bin], { stdio: "pipe" });
139
+ expect(execFileSync(bin, ["--version"], { encoding: "utf8" }).trim()).toBe(version);
140
+ expect(spawnSync(bin, [], { encoding: "utf8" }).status).toBe(2);
141
+ if (!canFfmpeg) return;
142
+ const clip = join(work, "clip.mp4");
143
+ execFileSync("ffmpeg", ["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=s=320x240:d=1:r=10", "-pix_fmt", "yuv420p", "-c:v", "libx264", clip]);
144
+ const out = join(work, "alpha.mov");
145
+ const report = JSON.parse(execFileSync(bin, [clip, out, "fast"], { encoding: "utf8" }).trim()) as { frames: number };
146
+ expect(report.frames).toBe(10);
147
+ expect(frames(out)).toBe(10);
148
+ },
149
+ 180_000,
150
+ );
151
+ });
152
+
153
+ describe("the composite hides the edge, and the background gets the camera's look", () => {
154
+ it("measures the take and gives the photo its saturation and brightness before the blur", () => {
155
+ const bg = between("4. **Defocus the background once, and give it the camera's look.**", "`mode: blur`");
156
+ expect(bg).toContain("the mean of YAVG and SATAVG over the frames");
157
+ expect(bg).toContain('eq=saturation=<S>:brightness=<B>,gblur=sigma=12');
158
+ expect(bg).toContain("`<S>` = SATAVG(take) / SATAVG(photo), kept between 0.6 and 1");
159
+ });
160
+
161
+ const chain = fenceAfter("5. **Composite the person with ffmpeg**");
162
+
163
+ it("smooths the matte over time, chokes and feathers it, cleans the edge's colour, then wraps the light", () => {
164
+ const steps = ["tmix=frames=3:weights='1 2 1'", "erosion", "gblur=sigma=1.2", "maskedmerge", "[glow][band]alphamerge[wrap]", "[p][wrap]overlay"].map((s) => chain.indexOf(s));
165
+ expect(steps.every((i) => i >= 0)).toBe(true);
166
+ expect([...steps].sort((x, y) => x - y)).toEqual(steps);
167
+ });
168
+
169
+ it.skipIf(!canFfmpeg)(
170
+ "runs as written: one transparent person frame per take frame",
171
+ () => {
172
+ const dir = join(work, "recipe");
173
+ mkdirSync(join(dir, "media"), { recursive: true });
174
+ const run = (args: string[]) => execFileSync("ffmpeg", ["-v", "error", "-y", ...args], { cwd: dir });
175
+ run(["-f", "lavfi", "-i", "testsrc2=s=320x240:d=1:r=10", "-pix_fmt", "yuv420p", "-c:v", "libx264", "media/take.mp4"]);
176
+ run(["-f", "lavfi", "-i", "color=black:s=320x240:d=1:r=10,format=gray,geq=lum='if(lt(hypot(X-160\\,Y-120)\\,70)\\,255\\,0)'", "-pix_fmt", "yuv420p", "-c:v", "libx264", "media/alpha.mp4"]);
177
+ run(["-f", "lavfi", "-i", "color=0x406080:s=320x240", "-frames:v", "1", "media/bg.png"]);
178
+ const cmd = chain.replaceAll("<fg>", "media/take.mp4").replaceAll("<matte>", "media/alpha.mp4").replaceAll("<fps>", "10");
179
+ const r = spawnSync("bash", ["-c", cmd], { cwd: dir, encoding: "utf8" });
180
+ expect(r.stderr).not.toMatch(/Error|Invalid/);
181
+ expect(r.status).toBe(0);
182
+ const person = join(dir, "media/person.webm");
183
+ expect(existsSync(person)).toBe(true);
184
+ expect(frames(person)).toBe(10);
185
+ // The person keeps an alpha channel (VP9 stores it in a side stream).
186
+ expect(execFileSync("ffprobe", ["-v", "error", "-show_entries", "stream_tags=alpha_mode", "-of", "csv=p=0", person], { encoding: "utf8" })).toContain("1");
187
+ },
188
+ 60_000,
189
+ );
190
+ });