@officexapp/vidfarm-devcli 0.21.56 → 0.21.57

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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: vidfarm
3
- skill_version: 0.21.56
3
+ skill_version: 0.21.57
4
4
  description: Use Vidfarm as a director. Run a strategy **consultation** (the `brainstorm/*` chain — cold-start interview, awareness stages, persuasive angles, hooks, product placement). Answer "give me content ideas" / "what should I post" / "I need 30 videos this month" from the bundled idea banks (50 content frames x 5 awareness stages x 44 problem angles). Browse/add inspiration videos, browse the free public raws catalog BY CATEGORY (curated shelves like scroll-stoppers/greenscreen/reaction — the cheapest way to source footage for one video, and a ready-made clip pool for bulk scripting N variants), fork a template into a composition, edit it in the Trackpad Editor (timeline-based like Premiere/DaVinci), auto-decompose source video into scenes, render to MP4, approve into a shareable post, and schedule it. Includes login, provider keys, discovery, versioning, uploads/downloads, and billing. Every step is available as raw REST; `vidfarm-devcli` wraps those routes and composes the file-backed scripting flows.
5
5
  ---
6
6
 
@@ -397,6 +397,27 @@ The mechanism is deterministic, not luck: rendering is seek-safe, so frame 0 sho
397
397
 
398
398
  Full mechanics and editor verbs: `references/editor-workflows.md` (“The opening frame is the post's thumbnail”); poster-state authoring craft: `hyperframes-creative/references/beat-direction.md`.
399
399
 
400
+ ## Reserved globals — the engine owns them, and assigning one ships a still image
401
+
402
+ **Never assign `window.__player`, `window.__hf`, `window.__playerReady`, `window.__renderReady`, or `window.__hyperframes` from composition code.** The render engine installs all of them. A composition that overwrites one destroys the frame-capture bridge, and the failure is invisible: the renderer stalls 45 seconds, downgrades the error to a warning, exits **0**, and writes an MP4 with the **correct duration, correct frame count and correct audio, in which nothing moves.** Four paid deliverables shipped that way in one night before anyone looked at the pixels.
403
+
404
+ **Motion has exactly one mechanism — you register a paused timeline and the engine SEEKS it:**
405
+
406
+ ```html
407
+ <script>
408
+ window.__timelines = window.__timelines || {}; // this line is the ONLY __ global you write
409
+ var tl = gsap.timeline({ paused: true }); // paused, built synchronously
410
+ tl.to('#box', { x: 880, duration: 4, ease: 'none' }, 0);
411
+ window.__timelines['main'] = tl; // key === the root's data-composition-id, exactly
412
+ </script>
413
+ ```
414
+
415
+ - **Need imperative per-frame drawing** (canvas, a custom renderer)? Do *not* invent a seek API. Register a paused **driver** timeline and hang your draw call off its `onUpdate`: `gsap.timeline({ paused: true, onUpdate: function () { draw(driver.time()); } })`, then give it a dummy tween of the full duration. The engine seeks the driver; `onUpdate` fires at the seeked time.
416
+ - **No timeline at all** is the same failure with a different cause. A composition that builds a GSAP/anime timeline and never registers it renders static too.
417
+ - **`window.__timelines` is the one exception** — writing it is required. Nothing else in that namespace is yours.
418
+
419
+ **Three tools enforce this, so a mistake costs seconds instead of a night:** `vidfarm lint <dir>` and `vidfarm qa <dir>` both report `engine_owned_global_assigned` as an **error**, `vidfarm render --target local` **refuses to start**, and every local render ends with an automatic **`vidfarm motion-check`** that fails the render when the finished MP4 never moves (`--allow-static` for a title card that holds still on purpose). Run `vidfarm motion-check <file.mp4>` by hand on anything those did not produce — a cloud render, a watermarked master, an ffmpeg re-encode.
420
+
400
421
  ## Judge the WHOLE video, not the parts you built — and never by one frame
401
422
 
402
423
  **Assume your own finished video has a defect you can't see.** That's the observed base rate, not modesty: across a 32-video batch, *every* first-pass video had a real defect that the agent who built it had already reported as "verified, looks good" — dead space under the content, a placeholder that reads as a failed render, contradictory numbers in one frame, a CTA still animating at the last frame.
@@ -405,7 +426,7 @@ Full mechanics and editor verbs: `references/editor-workflows.md` (“The openin
405
426
 
406
427
  The mechanical form is a contact sheet, because one image read shows drift that twelve separate checks can't — and it's one command: **`vidfarm stills ./work --sheet`** renders the frames and tiles them into `stills/contact-sheet.png`. **Read the sheet as an image.**
407
428
 
408
- **And never verify a video by a single frame.** Frame 0 is the thumbnail so you judge it alone — but a whole class of render bug (an overlay pass missing `-loop 1`, assets outside the composition root so the timeline never runs) freezes *every* frame while duration, frame count and audio hash all still pass, and frame 0 looks perfect. **Always compare two frames from different scenes; they must differ a lot.** Likewise, verify audio by measurement, never "it sounds good" — you can't hear it.
429
+ **And never verify a video by a single frame.** Frame 0 is the thumbnail so you judge it alone — but a whole class of render bug (an assigned engine-owned global, an overlay pass missing `-loop 1`, assets outside the composition root so the timeline never runs) freezes *every* frame while duration, frame count and audio hash all still pass, and frame 0 looks perfect. **Settle it with `vidfarm motion-check <file.mp4>`, then compare two frames from different scenes by eye; they must differ a lot.** Likewise, verify audio by measurement, never "it sounds good" — you can't hear it.
409
430
 
410
431
  Full method — the holistic checklist, the six defects in observed frequency order, the "does it rest there" test, frozen-render verification, audio measurement, and how to brief a revision pass: **`references/reviewing-renders.md`**.
411
432
 
@@ -510,6 +531,7 @@ Why they exist: the prompts you find on the `/discover` pages are tuned to **one
510
531
  | `https://vidfarm.cc/experimental/ugc-reaction-greenscreen.md` | Sell an app with three streams cut against each other: `ugc-reaction` raws, a Display Greenscreen device whose screen carries the customer's real demo, and the demo itself. One actor across every beat via `actor_<uuid>`, a per-frame tracked screen insert, captions inside the platform-chrome-safe core, and two exports from one render (voiceover-only to publish, voiceover+music to review) |
511
532
  | `https://vidfarm.cc/experimental/sticker-slideshow-tips.md` | A tips **carousel** — the deliverable is N still slides, and the 3.0s-per-slide MP4 is only the playable preview. Die-cut cutouts on a paper page or a photo background, a literal "Tips for…" cover, the specificity ladder, one slide shilled from the middle and written so it survives deleting the brand name, four background modes, and a measured WCAG contrast gate on the exported PNGs |
512
533
  | `https://vidfarm.cc/experimental/wall-text-pov-ugc.md` | One unbroken ambient take + one static block of unplated type. No cuts, no voiceover, no subtitles, nothing animated. The retention engine is arithmetic — `duration = words / 8`, so one play lands the viewer at the **halfway mark**, committed and one pass from done — floored at 8s so a trending sound gets a real phrase of a track. The four speaker frames, MIRROR vs TURN, a density pass that treats padding as the fatal failure, casting the scene dark so the type needs no plate (measured), ping-ponging the plate for a seamless loop, and three gates. Runs at $0 |
534
+ | `https://vidfarm.cc/experimental/engaging-chat-convo.md` | A chat-thread **carousel** — N still screenshots of ONE text conversation at 3.0s each; the slides are the deliverable and the MP4 is only the preview. Pure type on pure black with **no phone chrome at all** (no status bar, avatar, header, timestamps or keyboard), because a crop containing only bubbles cannot be *wrong* about a detail — and one wrong detail makes a reader re-read the whole thread as fake. The engine is two voices that stay separable with the **colour removed**; `>>>` skip cards buy time jumps for free; the story opens mid-mistake, reveals something in the middle that recasts what came before, breaks one character's register in the last act, and ends mid-action with no resolution, because the bait is the missing ending. The offer is named **once**, lowercase, mid-bubble, as a plot fact a character mentions about their own life while the other reacts sceptically. Ships a voice-drift detector that names the slide where the two voices merge. Runs at $0 |
513
535
  | `https://vidfarm.cc/experimental/animated-sticker-story.md` | A narrated **paper puppet theater** — one full-bleed parchment stage that never cuts, a cast of die-cut stickers, and every element moved by ONE paused GSAP timeline (MotionPathPlugin for walks; paths in absolute canvas coordinates, never `align:"self"`). The three-node rig, the seven moves (ENTER / WALK / CROWD / BEAT / STAMP / DRAW / CAMERA), buying SHEETS rather than stickers so one art class survives, generating art without shadows and adding one CSS drop-shadow, kinetic captions that animate **colour only** on whisper word timings, a two-pass build that MEASURES where the drawing is quietest before placing any type, a feathered paper wash that is not a plate, and the offer named once as a wordmark on the last beat. Desktop-only. ~$0.25 in `hybrid`, $0 in `minimize` |
514
536
 
515
537
  Fetch one as plain markdown and follow it end to end; do not skim it into a summary.
@@ -562,6 +584,8 @@ The File Index above says what each file *is*; this says which one a given ask m
562
584
  - Treat `forkId` as an unguessable bearer token for read access.
563
585
  - Submission routes are generally not idempotent. Especially for renders and expensive primitives, check status before retrying.
564
586
  - In the web editor, use CSS/declarative motion only. Script-bearing HTML is stripped or rejected there.
587
+ - **Never assign `window.__player` / `window.__hf` / `window.__playerReady` / `window.__renderReady` / `window.__hyperframes`.** The engine owns them; assigning one writes a correct-length MP4 of a still image and exits 0. Register `window.__timelines["<data-composition-id>"] = gsap.timeline({ paused: true })` and let the engine seek it. See “Reserved globals”.
588
+ - **Never call a render verified until `vidfarm motion-check <file.mp4>` has passed.** A frozen render matches on duration, frame count, file size and audio hash, and frame 0 looks perfect.
565
589
  - **Never render or approve without judging frame 0 as a standalone still.** It is the thumbnail everywhere the post appears; an empty/black opening frame ships a dead post. See “The FIRST FRAME is the thumbnail”.
566
590
  - **Never judge the VIDEO by one frame, and never report a render as reviewed without the holistic pass.** Compare frames from at least two different scenes (a frozen render passes every other check), read a contact sheet for balance/spacing/style/pacing drift, and state separately what you measured vs. what you judged. See “Judge the WHOLE video”.
567
591
 
@@ -125,6 +125,7 @@ you follow. Read one **before** you build that format, and stack it on a base.
125
125
  | `wall-text-pov-ugc` | One unbroken ambient take + one static block of unplated type. `duration = words / 8` |
126
126
  | `ugc-reaction-greenscreen` | Reaction cutaways + a keyed device carrying the customer's real app demo |
127
127
  | `sticker-slideshow-tips` | A tips carousel — N still slides at 3.0s; the slides are the deliverable |
128
+ | `engaging-chat-convo` | A text thread as a carousel — N chat screenshots at 3.0s, two voices, no ending |
128
129
  | `animated-sticker-story` | A narrated paper puppet theater on one parchment stage, one GSAP timeline |
129
130
  | `google-news-to-video` | Timely newsjack — news-search finds the STORY, video-search the VISUALS |
130
131
  | `unique-product-explainers` | N customer URLs → N videos that do not converge. Differentiation as an input |
@@ -140,6 +140,8 @@ vidfarm render "$FORK_ID" --dir ./work --target cloud --tracer "batch-2026-07-09
140
140
 
141
141
  `--dir` may also point directly at `composition.html`. Local renders don't push `./work` to the cloud — `vidfarm publish` (or the cloud-target render) does that. Render prep automatically downloads a long source once and cuts the timeline's segments from it, so referencing the same long raw N times is fine.
142
142
 
143
+ **A local render is gated at both ends, because a broken composition still produces a plausible MP4.** Before it starts, it refuses any composition that assigns an engine-owned global (`window.__player`, `window.__hf`, …) — that mistake writes a correct-length video of a still image and exits 0. After it finishes, it runs `vidfarm motion-check` on the output and **fails with exit 1** if not one sampled frame pair differs. Pass `--allow-static` when the video is meant to hold still. A cloud render has no such gate: run `vidfarm motion-check <file.mp4>` on the downloaded result yourself.
144
+
143
145
  Best practices:
144
146
 
145
147
  - Prefer one canonical base fork per automation run, then branch from that fork if you need variants.
@@ -268,7 +270,8 @@ The licensed harness also carries the **generative build workflow** guidance (ch
268
270
  | `vidfarm remove-video-captions <forkId>` (alias: `ghostcut`) | `GET .../compositions/:forkId/remove-video-captions` | subtitle-removal status |
269
271
  | `vidfarm snapshot <forkId>` | `POST .../compositions/:forkId/versions` | save composition fork version |
270
272
  | `vidfarm versions <forkId>` | `GET .../compositions/:forkId/versions` | list versions |
271
- | `vidfarm render <forkId> [--dir <dir\|composition.html>] [--out <path>] [--target local\|cloud] [--wait]` | local (default): renders ON YOUR MACHINE via bundled hyperframes, free; `--target cloud`: `PUT/PATCH working files, then POST .../compositions/:forkId/render` (billed) | script-friendly render to MP4; no `--dir` uses the fork's current working state |
273
+ | `vidfarm render <forkId> [--dir <dir\|composition.html>] [--out <path>] [--target local\|cloud] [--wait]` | local (default): renders ON YOUR MACHINE via bundled hyperframes, free; `--target cloud`: `PUT/PATCH working files, then POST .../compositions/:forkId/render` (billed) | script-friendly render to MP4; no `--dir` uses the fork's current working state. A **local** render refuses to start on a composition that assigns an engine-owned global, and **fails (exit 1)** when the finished MP4 never moves — `--allow-static` for a card that holds still on purpose |
274
+ | `vidfarm motion-check <video.mp4> [--json] [--fps N]` | (local ffmpeg, no route) | **does the finished video actually MOVE?** Exits 1 when every sampled frame pair is identical. A frozen render matches on duration, frame count, file size and audio hash and has a perfect frame 0, so nothing else catches it. Runs automatically at the end of `render --target local` |
272
275
  | `vidfarm render-status <forkId> <renderId>` | `GET .../compositions/:forkId/renders/:renderId` | poll a render |
273
276
  | `vidfarm visibility <forkId> <private\|public>` | `PATCH .../compositions/:forkId/visibility` | set visibility |
274
277
  | `vidfarm clone <forkId>` | `POST .../compositions/:forkId/clone` | clone a fork |
@@ -363,6 +366,8 @@ What it flags:
363
366
  | `layout-template` | error | The frame composed like a **page**: 3+ stacked text blocks in one container ending in a call to action — headline + subheading + CTA, the web hero/modal body. Fires with or without the box around it, because the STACK is the tell. Three stacked lines with no CTA are a legitimate title card and pass |
364
367
  | `modal-scrim` | error | A full-frame backdrop that is **blurred AND dimmed** — the website-modal staging that pushes the picture back so a floating block pops. Blur alone passes (the blurred fill behind a 16:9 clip in a 9:16 frame is a real technique); it needs the dimming too, and a bed faded under `opacity:0.25` is an ambient texture wash, not staging, so it passes as well |
365
368
  | `gradient-text` | error | `background-clip:text` gradient headline fills |
369
+ | `engine-owned-global` | error | A `<script>` assigns `window.__player` / `__hf` / `__playerReady` / `__renderReady` / `__hyperframes`. **The only rule here that is about correctness, not taste** — assigning one destroys frame capture, and the render still exits 0 with a correct-length MP4 in which nothing moves. Register `window.__timelines["<data-composition-id>"] = gsap.timeline({ paused: true })` instead. Same check runs in `vidfarm lint` (`engine_owned_global_assigned`) and blocks `vidfarm render --target local` |
370
+ | `missing-timeline-registry` | warn | A GSAP/anime timeline is built and never registered on `window.__timelines` — the engine has nothing to seek, so the render is a still image |
366
371
  | `clickable-element` | error/warn | `<button>`, `<form>`, `<input>`; `<a href>` warns |
367
372
  | `web-framework-classes` | error/warn | Bootstrap/Tailwind class tokens (`btn`, `badge`, `card`, `hero`, `col-*`, `rounded-full`, `shadow-lg`, `backdrop-blur`, `bg-gradient-to-*`) or a linked CSS framework. A `<script>` CDN for GSAP/anime.js is fine |
368
373
  | `page-structure` / `bullet-list` | error/warn | `<nav>`/`<header>`/`<footer>`/`<table>`; a `<ul>` with visible bullet markers |
@@ -76,12 +76,21 @@ vidfarm stills ./work --at 6.0,6.2,6.4,6.6,6.8,7.0 # is it a wipe, or a hole?
76
76
 
77
77
  **This is the failure mode that survives every check you'd think to run.** Whole classes of render bug produce a video where *every frame is identical* — the timeline never ran — while duration, frame count, file size and audio hash all come out exactly right. Frame 0 looks perfect, so a single-frame check passes and you ship a frozen video.
78
78
 
79
- Two real causes, both silent:
79
+ **Settle it mechanically, first, every time — one command:**
80
80
 
81
+ ```bash
82
+ vidfarm motion-check ./final.mp4 # exits 1 and says why when nothing in the video ever changes
83
+ ```
84
+
85
+ It samples the video and compares consecutive frames. `vidfarm render --target local` now runs it automatically and **fails the render** on a frozen output (`--allow-static` for a title card that is meant to hold still). Run it by hand on anything that did not come out of that command — a cloud render, a watermarked master, an ffmpeg re-encode.
86
+
87
+ Three real causes, all silent:
88
+
89
+ - **The composition assigned an engine-owned global.** `window.__player` and `window.__hf` belong to the engine; assigning either destroys the frame-capture bridge, and the renderer then stalls 45 s, downgrades the failure to a warning, and writes a correct-length MP4 of a still image with exit code 0. **Four paid deliverables shipped this way in one night.** `vidfarm lint` and `vidfarm qa` both call this an error now, and a local render refuses to start. Never write those globals — see SKILL.md § "Reserved globals".
81
90
  - **A watermark/overlay pass without `-loop 1` on a single-frame PNG input.** The frame-sync collapses the whole video onto one frame. Five videos shipped this way before it was caught.
82
91
  - **Assets outside the composition root.** Only `<style>`/`<script>` *inside* the `data-composition-id` root execute, and sibling relative files may not resolve — fonts, images, even the animation library itself. The timeline never starts; frame 0 still renders fine because frame 0 is the static DOM.
83
92
 
84
- **The rule that catches both: always compare two frames from different scenes.** They must differ a lot. And when you've applied any pass over an existing video (watermark, overlay, dedupe, re-encode), also compare each output frame against **its own** input frame at the same timestamp — that difference should be tiny. Two checks, opposite directions:
93
+ **Then look, too: compare two frames from different scenes.** They must differ a lot. And when you've applied any pass over an existing video (watermark, overlay, dedupe, re-encode), also compare each output frame against **its own** input frame at the same timestamp — that difference should be tiny. Two checks, opposite directions:
85
94
 
86
95
  ```bash
87
96
  # consecutive/distant frames must DIFFER (motion preserved)
package/SKILL.director.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: vidfarm
3
- skill_version: 0.21.56
3
+ skill_version: 0.21.57
4
4
  description: Use Vidfarm as a director. Run a strategy **consultation** (the `brainstorm/*` chain — cold-start interview, awareness stages, persuasive angles, hooks, product placement). Answer "give me content ideas" / "what should I post" / "I need 30 videos this month" from the bundled idea banks (50 content frames x 5 awareness stages x 44 problem angles). Browse/add inspiration videos, browse the free public raws catalog BY CATEGORY (curated shelves like scroll-stoppers/greenscreen/reaction — the cheapest way to source footage for one video, and a ready-made clip pool for bulk scripting N variants), fork a template into a composition, edit it in the Trackpad Editor (timeline-based like Premiere/DaVinci), auto-decompose source video into scenes, render to MP4, approve into a shareable post, and schedule it. Includes login, provider keys, discovery, versioning, uploads/downloads, and billing. Every step is available as raw REST; `vidfarm-devcli` wraps those routes and composes the file-backed scripting flows.
5
5
  ---
6
6
 
@@ -397,6 +397,27 @@ The mechanism is deterministic, not luck: rendering is seek-safe, so frame 0 sho
397
397
 
398
398
  Full mechanics and editor verbs: `references/editor-workflows.md` (“The opening frame is the post's thumbnail”); poster-state authoring craft: `hyperframes-creative/references/beat-direction.md`.
399
399
 
400
+ ## Reserved globals — the engine owns them, and assigning one ships a still image
401
+
402
+ **Never assign `window.__player`, `window.__hf`, `window.__playerReady`, `window.__renderReady`, or `window.__hyperframes` from composition code.** The render engine installs all of them. A composition that overwrites one destroys the frame-capture bridge, and the failure is invisible: the renderer stalls 45 seconds, downgrades the error to a warning, exits **0**, and writes an MP4 with the **correct duration, correct frame count and correct audio, in which nothing moves.** Four paid deliverables shipped that way in one night before anyone looked at the pixels.
403
+
404
+ **Motion has exactly one mechanism — you register a paused timeline and the engine SEEKS it:**
405
+
406
+ ```html
407
+ <script>
408
+ window.__timelines = window.__timelines || {}; // this line is the ONLY __ global you write
409
+ var tl = gsap.timeline({ paused: true }); // paused, built synchronously
410
+ tl.to('#box', { x: 880, duration: 4, ease: 'none' }, 0);
411
+ window.__timelines['main'] = tl; // key === the root's data-composition-id, exactly
412
+ </script>
413
+ ```
414
+
415
+ - **Need imperative per-frame drawing** (canvas, a custom renderer)? Do *not* invent a seek API. Register a paused **driver** timeline and hang your draw call off its `onUpdate`: `gsap.timeline({ paused: true, onUpdate: function () { draw(driver.time()); } })`, then give it a dummy tween of the full duration. The engine seeks the driver; `onUpdate` fires at the seeked time.
416
+ - **No timeline at all** is the same failure with a different cause. A composition that builds a GSAP/anime timeline and never registers it renders static too.
417
+ - **`window.__timelines` is the one exception** — writing it is required. Nothing else in that namespace is yours.
418
+
419
+ **Three tools enforce this, so a mistake costs seconds instead of a night:** `vidfarm lint <dir>` and `vidfarm qa <dir>` both report `engine_owned_global_assigned` as an **error**, `vidfarm render --target local` **refuses to start**, and every local render ends with an automatic **`vidfarm motion-check`** that fails the render when the finished MP4 never moves (`--allow-static` for a title card that holds still on purpose). Run `vidfarm motion-check <file.mp4>` by hand on anything those did not produce — a cloud render, a watermarked master, an ffmpeg re-encode.
420
+
400
421
  ## Judge the WHOLE video, not the parts you built — and never by one frame
401
422
 
402
423
  **Assume your own finished video has a defect you can't see.** That's the observed base rate, not modesty: across a 32-video batch, *every* first-pass video had a real defect that the agent who built it had already reported as "verified, looks good" — dead space under the content, a placeholder that reads as a failed render, contradictory numbers in one frame, a CTA still animating at the last frame.
@@ -405,7 +426,7 @@ Full mechanics and editor verbs: `references/editor-workflows.md` (“The openin
405
426
 
406
427
  The mechanical form is a contact sheet, because one image read shows drift that twelve separate checks can't — and it's one command: **`vidfarm stills ./work --sheet`** renders the frames and tiles them into `stills/contact-sheet.png`. **Read the sheet as an image.**
407
428
 
408
- **And never verify a video by a single frame.** Frame 0 is the thumbnail so you judge it alone — but a whole class of render bug (an overlay pass missing `-loop 1`, assets outside the composition root so the timeline never runs) freezes *every* frame while duration, frame count and audio hash all still pass, and frame 0 looks perfect. **Always compare two frames from different scenes; they must differ a lot.** Likewise, verify audio by measurement, never "it sounds good" — you can't hear it.
429
+ **And never verify a video by a single frame.** Frame 0 is the thumbnail so you judge it alone — but a whole class of render bug (an assigned engine-owned global, an overlay pass missing `-loop 1`, assets outside the composition root so the timeline never runs) freezes *every* frame while duration, frame count and audio hash all still pass, and frame 0 looks perfect. **Settle it with `vidfarm motion-check <file.mp4>`, then compare two frames from different scenes by eye; they must differ a lot.** Likewise, verify audio by measurement, never "it sounds good" — you can't hear it.
409
430
 
410
431
  Full method — the holistic checklist, the six defects in observed frequency order, the "does it rest there" test, frozen-render verification, audio measurement, and how to brief a revision pass: **`references/reviewing-renders.md`**.
411
432
 
@@ -510,6 +531,7 @@ Why they exist: the prompts you find on the `/discover` pages are tuned to **one
510
531
  | `https://vidfarm.cc/experimental/ugc-reaction-greenscreen.md` | Sell an app with three streams cut against each other: `ugc-reaction` raws, a Display Greenscreen device whose screen carries the customer's real demo, and the demo itself. One actor across every beat via `actor_<uuid>`, a per-frame tracked screen insert, captions inside the platform-chrome-safe core, and two exports from one render (voiceover-only to publish, voiceover+music to review) |
511
532
  | `https://vidfarm.cc/experimental/sticker-slideshow-tips.md` | A tips **carousel** — the deliverable is N still slides, and the 3.0s-per-slide MP4 is only the playable preview. Die-cut cutouts on a paper page or a photo background, a literal "Tips for…" cover, the specificity ladder, one slide shilled from the middle and written so it survives deleting the brand name, four background modes, and a measured WCAG contrast gate on the exported PNGs |
512
533
  | `https://vidfarm.cc/experimental/wall-text-pov-ugc.md` | One unbroken ambient take + one static block of unplated type. No cuts, no voiceover, no subtitles, nothing animated. The retention engine is arithmetic — `duration = words / 8`, so one play lands the viewer at the **halfway mark**, committed and one pass from done — floored at 8s so a trending sound gets a real phrase of a track. The four speaker frames, MIRROR vs TURN, a density pass that treats padding as the fatal failure, casting the scene dark so the type needs no plate (measured), ping-ponging the plate for a seamless loop, and three gates. Runs at $0 |
534
+ | `https://vidfarm.cc/experimental/engaging-chat-convo.md` | A chat-thread **carousel** — N still screenshots of ONE text conversation at 3.0s each; the slides are the deliverable and the MP4 is only the preview. Pure type on pure black with **no phone chrome at all** (no status bar, avatar, header, timestamps or keyboard), because a crop containing only bubbles cannot be *wrong* about a detail — and one wrong detail makes a reader re-read the whole thread as fake. The engine is two voices that stay separable with the **colour removed**; `>>>` skip cards buy time jumps for free; the story opens mid-mistake, reveals something in the middle that recasts what came before, breaks one character's register in the last act, and ends mid-action with no resolution, because the bait is the missing ending. The offer is named **once**, lowercase, mid-bubble, as a plot fact a character mentions about their own life while the other reacts sceptically. Ships a voice-drift detector that names the slide where the two voices merge. Runs at $0 |
513
535
  | `https://vidfarm.cc/experimental/animated-sticker-story.md` | A narrated **paper puppet theater** — one full-bleed parchment stage that never cuts, a cast of die-cut stickers, and every element moved by ONE paused GSAP timeline (MotionPathPlugin for walks; paths in absolute canvas coordinates, never `align:"self"`). The three-node rig, the seven moves (ENTER / WALK / CROWD / BEAT / STAMP / DRAW / CAMERA), buying SHEETS rather than stickers so one art class survives, generating art without shadows and adding one CSS drop-shadow, kinetic captions that animate **colour only** on whisper word timings, a two-pass build that MEASURES where the drawing is quietest before placing any type, a feathered paper wash that is not a plate, and the offer named once as a wordmark on the last beat. Desktop-only. ~$0.25 in `hybrid`, $0 in `minimize` |
514
536
 
515
537
  Fetch one as plain markdown and follow it end to end; do not skim it into a summary.
@@ -562,6 +584,8 @@ The File Index above says what each file *is*; this says which one a given ask m
562
584
  - Treat `forkId` as an unguessable bearer token for read access.
563
585
  - Submission routes are generally not idempotent. Especially for renders and expensive primitives, check status before retrying.
564
586
  - In the web editor, use CSS/declarative motion only. Script-bearing HTML is stripped or rejected there.
587
+ - **Never assign `window.__player` / `window.__hf` / `window.__playerReady` / `window.__renderReady` / `window.__hyperframes`.** The engine owns them; assigning one writes a correct-length MP4 of a still image and exits 0. Register `window.__timelines["<data-composition-id>"] = gsap.timeline({ paused: true })` and let the engine seek it. See “Reserved globals”.
588
+ - **Never call a render verified until `vidfarm motion-check <file.mp4>` has passed.** A frozen render matches on duration, frame count, file size and audio hash, and frame 0 looks perfect.
565
589
  - **Never render or approve without judging frame 0 as a standalone still.** It is the thumbnail everywhere the post appears; an empty/black opening frame ships a dead post. See “The FIRST FRAME is the thumbnail”.
566
590
  - **Never judge the VIDEO by one frame, and never report a render as reviewed without the holistic pass.** Compare frames from at least two different scenes (a frozen render passes every other check), read a contact sheet for balance/spacing/style/pacing drift, and state separately what you measured vs. what you judged. See “Judge the WHOLE video”.
567
591
 
@@ -2241,12 +2265,21 @@ vidfarm stills ./work --at 6.0,6.2,6.4,6.6,6.8,7.0 # is it a wipe, or a hole?
2241
2265
 
2242
2266
  **This is the failure mode that survives every check you'd think to run.** Whole classes of render bug produce a video where *every frame is identical* — the timeline never ran — while duration, frame count, file size and audio hash all come out exactly right. Frame 0 looks perfect, so a single-frame check passes and you ship a frozen video.
2243
2267
 
2244
- Two real causes, both silent:
2268
+ **Settle it mechanically, first, every time — one command:**
2269
+
2270
+ ```bash
2271
+ vidfarm motion-check ./final.mp4 # exits 1 and says why when nothing in the video ever changes
2272
+ ```
2245
2273
 
2274
+ It samples the video and compares consecutive frames. `vidfarm render --target local` now runs it automatically and **fails the render** on a frozen output (`--allow-static` for a title card that is meant to hold still). Run it by hand on anything that did not come out of that command — a cloud render, a watermarked master, an ffmpeg re-encode.
2275
+
2276
+ Three real causes, all silent:
2277
+
2278
+ - **The composition assigned an engine-owned global.** `window.__player` and `window.__hf` belong to the engine; assigning either destroys the frame-capture bridge, and the renderer then stalls 45 s, downgrades the failure to a warning, and writes a correct-length MP4 of a still image with exit code 0. **Four paid deliverables shipped this way in one night.** `vidfarm lint` and `vidfarm qa` both call this an error now, and a local render refuses to start. Never write those globals — see SKILL.md § "Reserved globals".
2246
2279
  - **A watermark/overlay pass without `-loop 1` on a single-frame PNG input.** The frame-sync collapses the whole video onto one frame. Five videos shipped this way before it was caught.
2247
2280
  - **Assets outside the composition root.** Only `<style>`/`<script>` *inside* the `data-composition-id` root execute, and sibling relative files may not resolve — fonts, images, even the animation library itself. The timeline never starts; frame 0 still renders fine because frame 0 is the static DOM.
2248
2281
 
2249
- **The rule that catches both: always compare two frames from different scenes.** They must differ a lot. And when you've applied any pass over an existing video (watermark, overlay, dedupe, re-encode), also compare each output frame against **its own** input frame at the same timestamp — that difference should be tiny. Two checks, opposite directions:
2282
+ **Then look, too: compare two frames from different scenes.** They must differ a lot. And when you've applied any pass over an existing video (watermark, overlay, dedupe, re-encode), also compare each output frame against **its own** input frame at the same timestamp — that difference should be tiny. Two checks, opposite directions:
2250
2283
 
2251
2284
  ```bash
2252
2285
  # consecutive/distant frames must DIFFER (motion preserved)
@@ -3050,6 +3083,8 @@ vidfarm render "$FORK_ID" --dir ./work --target cloud --tracer "batch-2026-07-09
3050
3083
 
3051
3084
  `--dir` may also point directly at `composition.html`. Local renders don't push `./work` to the cloud — `vidfarm publish` (or the cloud-target render) does that. Render prep automatically downloads a long source once and cuts the timeline's segments from it, so referencing the same long raw N times is fine.
3052
3085
 
3086
+ **A local render is gated at both ends, because a broken composition still produces a plausible MP4.** Before it starts, it refuses any composition that assigns an engine-owned global (`window.__player`, `window.__hf`, …) — that mistake writes a correct-length video of a still image and exits 0. After it finishes, it runs `vidfarm motion-check` on the output and **fails with exit 1** if not one sampled frame pair differs. Pass `--allow-static` when the video is meant to hold still. A cloud render has no such gate: run `vidfarm motion-check <file.mp4>` on the downloaded result yourself.
3087
+
3053
3088
  Best practices:
3054
3089
 
3055
3090
  - Prefer one canonical base fork per automation run, then branch from that fork if you need variants.
@@ -3178,7 +3213,8 @@ The licensed harness also carries the **generative build workflow** guidance (ch
3178
3213
  | `vidfarm remove-video-captions <forkId>` (alias: `ghostcut`) | `GET .../compositions/:forkId/remove-video-captions` | subtitle-removal status |
3179
3214
  | `vidfarm snapshot <forkId>` | `POST .../compositions/:forkId/versions` | save composition fork version |
3180
3215
  | `vidfarm versions <forkId>` | `GET .../compositions/:forkId/versions` | list versions |
3181
- | `vidfarm render <forkId> [--dir <dir\|composition.html>] [--out <path>] [--target local\|cloud] [--wait]` | local (default): renders ON YOUR MACHINE via bundled hyperframes, free; `--target cloud`: `PUT/PATCH working files, then POST .../compositions/:forkId/render` (billed) | script-friendly render to MP4; no `--dir` uses the fork's current working state |
3216
+ | `vidfarm render <forkId> [--dir <dir\|composition.html>] [--out <path>] [--target local\|cloud] [--wait]` | local (default): renders ON YOUR MACHINE via bundled hyperframes, free; `--target cloud`: `PUT/PATCH working files, then POST .../compositions/:forkId/render` (billed) | script-friendly render to MP4; no `--dir` uses the fork's current working state. A **local** render refuses to start on a composition that assigns an engine-owned global, and **fails (exit 1)** when the finished MP4 never moves — `--allow-static` for a card that holds still on purpose |
3217
+ | `vidfarm motion-check <video.mp4> [--json] [--fps N]` | (local ffmpeg, no route) | **does the finished video actually MOVE?** Exits 1 when every sampled frame pair is identical. A frozen render matches on duration, frame count, file size and audio hash and has a perfect frame 0, so nothing else catches it. Runs automatically at the end of `render --target local` |
3182
3218
  | `vidfarm render-status <forkId> <renderId>` | `GET .../compositions/:forkId/renders/:renderId` | poll a render |
3183
3219
  | `vidfarm visibility <forkId> <private\|public>` | `PATCH .../compositions/:forkId/visibility` | set visibility |
3184
3220
  | `vidfarm clone <forkId>` | `POST .../compositions/:forkId/clone` | clone a fork |
@@ -3273,6 +3309,8 @@ What it flags:
3273
3309
  | `layout-template` | error | The frame composed like a **page**: 3+ stacked text blocks in one container ending in a call to action — headline + subheading + CTA, the web hero/modal body. Fires with or without the box around it, because the STACK is the tell. Three stacked lines with no CTA are a legitimate title card and pass |
3274
3310
  | `modal-scrim` | error | A full-frame backdrop that is **blurred AND dimmed** — the website-modal staging that pushes the picture back so a floating block pops. Blur alone passes (the blurred fill behind a 16:9 clip in a 9:16 frame is a real technique); it needs the dimming too, and a bed faded under `opacity:0.25` is an ambient texture wash, not staging, so it passes as well |
3275
3311
  | `gradient-text` | error | `background-clip:text` gradient headline fills |
3312
+ | `engine-owned-global` | error | A `<script>` assigns `window.__player` / `__hf` / `__playerReady` / `__renderReady` / `__hyperframes`. **The only rule here that is about correctness, not taste** — assigning one destroys frame capture, and the render still exits 0 with a correct-length MP4 in which nothing moves. Register `window.__timelines["<data-composition-id>"] = gsap.timeline({ paused: true })` instead. Same check runs in `vidfarm lint` (`engine_owned_global_assigned`) and blocks `vidfarm render --target local` |
3313
+ | `missing-timeline-registry` | warn | A GSAP/anime timeline is built and never registered on `window.__timelines` — the engine has nothing to seek, so the render is a still image |
3276
3314
  | `clickable-element` | error/warn | `<button>`, `<form>`, `<input>`; `<a href>` warns |
3277
3315
  | `web-framework-classes` | error/warn | Bootstrap/Tailwind class tokens (`btn`, `badge`, `card`, `hero`, `col-*`, `rounded-full`, `shadow-lg`, `backdrop-blur`, `bg-gradient-to-*`) or a linked CSS framework. A `<script>` CDN for GSAP/anime.js is fine |
3278
3316
  | `page-structure` / `bullet-list` | error/warn | `<nav>`/`<header>`/`<footer>`/`<table>`; a `<ul>` with visible bullet markers |
package/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: vidfarm
3
- skill_version: 0.21.56
3
+ skill_version: 0.21.57
4
4
  description: Entry point for making, editing, and rendering short-form video with Vidfarm. Use when the task is to create a video, ad, explainer, or social clip; to edit or re-theme an existing composition; to source footage or audio; or to run a batch of video variants. Routes to the full director skill and the `vidfarm` CLI.
5
5
  ---
6
6
 
@@ -162,6 +162,7 @@ vidfarm publish <forkId> # push edits back to th
162
162
  | "A UGC ad for my app" / "someone reacting, then the app" / a client's app demo you have to make watchable | three streams cut against each other: `ugc-reaction` raws (ONE actor, via `actor_<uuid>`), a Display Greenscreen device carrying their real demo on its screen, and the demo. Ships a voiceover-only cut to publish + a voiceover+music cut to review | `vidfarm.cc/experimental/ugc-reaction-greenscreen.md` |
163
163
  | "A tips slideshow / carousel" / "5 tips for X" / a listicle post | N still slides at exactly 3.0s, die-cut cutouts on a page or a photo, a literal "Tips for…" cover, ONE slide shilled from the middle. **The slides are the deliverable; the MP4 is the preview.** $0–$0.15 | `vidfarm.cc/experimental/sticker-slideshow-tips.md` |
164
164
  | "A confession / truth bomb over a POV or ambient scene" / "just text on a video" / a text-story post | ONE unbroken take + ONE static block of unplated type. Nothing animates, nothing is cut. `duration = words / 8` (min 8s) so one play reaches the HALFWAY mark and they loop to finish. Cast the scene DARK so the type needs no plate. $0 | `vidfarm.cc/experimental/wall-text-pov-ugc.md` |
165
+ | "A fake text conversation" / "a chat story" / "a screenshot thread" / funny-or-dramatic DMs | N still chat screenshots at exactly 3.0s, pure type on pure black, **no phone chrome at all**. Two voices that stay separable with the colour removed, `>>>` cards for time jumps, the offer named ONCE mid-thread as a plot fact, and NO ending — it stops mid-action. **The slides are the deliverable; the MP4 is the preview.** $0 | `vidfarm.cc/experimental/engaging-chat-convo.md` |
165
166
  | "An animated explainer / a little story that animates" / "like those Vox map animations" / "cutout paper animation" | ONE full-bleed parchment stage, no cuts, a cast of die-cut paper stickers moved by ONE paused GSAP timeline. Narrated, subtitles stay small and kinetic in colour only, the offer named once as a wordmark on the LAST beat. Buy SHEETS, not stickers. Desktop-only (the web editor strips scripts). ~$0.25 hybrid, $0 minimize | `vidfarm.cc/experimental/animated-sticker-story.md` |
166
167
  | "Make N videos for N customers / clients" / a batch that must not read as N runs of one template | differentiation is an INPUT, not a hope: assign each variant its own frame before you build any of them, then review frame by frame. $0 | `vidfarm.cc/experimental/unique-product-explainers.md` |
167
168
  | "Download this video from `<url>`" | `vidfarm download-video <url>` (paid). Free plan gets a 402 — `vidfarm browse page "<url>"` and save it from their Chrome yourself, else have them download it, then `vidfarm put-file`. Never answer "I can't." | `references/browser-harness.md` |
@@ -221,6 +222,7 @@ The standards themselves are not optional. `vidfarm qa <dir>` is — it is a fre
221
222
  - **The first frame is the thumbnail.** A real visual and the hook words at `start:0`. No fade-up, no entrance transition on the first clip. Check with `vidfarm stills <dir> --at 0`.
222
223
  - **Cut ruthlessly.** First assembly is 30–50% too long. Delete every beat that the video survives without, then ripple the hole closed. Length is an output, not a target.
223
224
  - **Review the whole video, never one frame.** Tile ~12 stills with `vidfarm stills <dir> --sheet` and read it as an image — one type scale, one palette, deliberate pacing, clean joins. Verify audio by measurement (~12–15 dB speech-over-bed, peak <0 dBFS). Report what you measured separately from what you judged. Your own "looks good" is the least reliable signal here.
225
+ - **Prove the render MOVES before you call it done: `vidfarm motion-check <file.mp4>`.** A frozen render has the right duration, frame count, file size and audio, and a perfect frame 0 — every other check you would run passes. `vidfarm render --target local` runs this itself and fails on a still output (`--allow-static` when that is the point).
224
226
  - **Use the real trending song.** The render is a review artifact; the user re-attaches the same track from the platform's in-app music library at post time, where it is licensed. Pull it with `vidfarm download-audio <post-url>`, keep it on its own `<audio>` layer at its own `data-volume`, and name the track in your report. Exception: a paid ad placement is not covered — say so once and offer `vidfarm music "<same vibe, same BPM>"`.
225
227
  - **Ask about deduplication before the render, not after.** "Is this going out more than once?" Answering early keeps it at render once → dedupe N.
226
228
 
@@ -231,6 +233,7 @@ The standards themselves are not optional. `vidfarm qa <dir>` is — it is a fre
231
233
  - Render only through `POST /api/v1/compositions/:forkId/render`. Never call the renderer directly.
232
234
  - Submissions are **not** idempotent. Every render and primitive POST charges again — check status before retrying.
233
235
  - In the web editor, CSS/declarative motion only; JS animation adapters are stripped on save. Locally via `vidfarm serve`, the full adapters work.
236
+ - **`window.__player`, `window.__hf`, `window.__playerReady`, `window.__renderReady` and `window.__hyperframes` belong to the engine — never assign them.** Assigning one destroys frame capture: the render exits 0 and writes a correct-length MP4 in which nothing moves. Motion has one mechanism — register a paused timeline and let the engine seek it: `window.__timelines = window.__timelines || {}; window.__timelines["<data-composition-id>"] = gsap.timeline({ paused: true });` (that key must equal the root's `data-composition-id` exactly). For per-frame drawing, hang your draw call off a paused driver timeline's `onUpdate` instead of inventing a seek API. `vidfarm lint` and `vidfarm qa` both error on this, and a local render refuses to start.
234
237
  - Treat `forkId` as an unguessable bearer token. Never put provider secrets in composition HTML or JSON.
235
238
 
236
239
  ## 8. Deeper knowledge — fetch on demand
@@ -262,7 +265,7 @@ Otherwise fetch `https://vidfarm.cc/skill-pack/vidfarm/files/<path>`. Load one f
262
265
  | `recipes/*.md` | `find-and-fork-template` · `retheme-template` · `local-edit-render-approve` · `onboard-a-new-director` · `bulk-scripting-with-a-harness` · `cutout-graphics-for-explainers` |
263
266
  | `harnesses/README.md` | anything harness-shaped — start here. Bases: `short-form` · `hooks` · `ugc-testimonial` · `explainer` · `product-demo` · `product-explainer` |
264
267
  | `vidfarm.cc/flash-harness.md` | **you are a weak model (§0a), or you are unsure.** The process harness that sits above every format harness: match a decomposed `/discover` template → decompose if nobody has → derive the viral + visual DNA → procure every raw in one pass → transplant. Also at `vidfarm harness show flash-harness`, offline |
265
- | `vidfarm harness list` | both shelves: the bases above, **plus every format harness from `vidfarm.cc/experimental`** — `flash-harness` (the weak-model process, not a format) · `meme-recaption` · `wall-text-pov-ugc` · `ugc-reaction-greenscreen` · `sticker-slideshow-tips` · `animated-sticker-story` · `google-news-to-video` · `unique-product-explainers`. They ship in the package, so `vidfarm harness show <name>` reads one in full with no fetch, and `vidfarm qa ./work --harness <name>` grades against it. The name is the URL slug |
268
+ | `vidfarm harness list` | both shelves: the bases above, **plus every format harness from `vidfarm.cc/experimental`** — `flash-harness` (the weak-model process, not a format) · `meme-recaption` · `wall-text-pov-ugc` · `ugc-reaction-greenscreen` · `sticker-slideshow-tips` · `engaging-chat-convo` · `animated-sticker-story` · `google-news-to-video` · `unique-product-explainers`. They ship in the package, so `vidfarm harness show <name>` reads one in full with no fetch, and `vidfarm qa ./work --harness <name>` grades against it. The name is the URL slug |
266
269
 
267
270
  Also served at `vidfarm.cc`: `/flash-harness.md` (**the weak-model process harness — read it first if §0a called you weak or unsure**), `/experiments.md` (ad testing), `/marketplace.md` (the marketplace manual — routes you to one of the two side harnesses below), `/marketplace-buyer.md` (**buyer side**: commission videos from the crowd), `/agentic-clipper.md` (**worker side**: "Agentic Clipper" mode — one orchestrator on a long-horizon earning mission, one subagent per task), `/update.md` (upgrade runbook), `/experimental` (format harnesses under live testing — the index a clipper routes tasks against), `/skill/vidfarm-platform` (architecture), `/skill/hyperframes` (composition-authoring craft — route broad "make me a video" asks here first).
268
271
 
package/dist/src/cli.js CHANGED
@@ -868,6 +868,10 @@ Fine timeline control (trackpad-level verbs on a pulled/served composition — l
868
868
  --target cloud: billed Lambda render (~$0.01-$0.10)
869
869
  → POST .../compositions/:forkId/render (--wait to
870
870
  poll; --dir pushes composition.html + .json first)
871
+ A local render REFUSES compositions that assign the
872
+ engine-owned globals (window.__player / window.__hf)
873
+ and FAILS if the finished MP4 never moves — pass
874
+ --allow-static for a deliberately still card
871
875
  render-status <forkId> <renderId> Poll one render job → GET .../compositions/:forkId/renders/:renderId
872
876
  template run <template_id> <operation_name>
873
877
  Run one template operation via REST → POST /api/v1/templates/:templateId/operations/:operationName
@@ -934,6 +938,7 @@ Local media engines & toolchain (all local, free, no account — no cloud key ne
934
938
  meme-recaption, wall-text-pov-ugc,
935
939
  ugc-reaction-greenscreen,
936
940
  sticker-slideshow-tips,
941
+ engaging-chat-convo,
937
942
  animated-sticker-story,
938
943
  google-news-to-video,
939
944
  unique-product-explainers
@@ -961,6 +966,13 @@ Local media engines & toolchain (all local, free, no account — no cloud key ne
961
966
  sheet shows it
962
967
  --sheet-out <file> Sheet path (default <out>/contact-sheet.png)
963
968
  --sheet-width <px> Per-tile width in the sheet (default 320)
969
+ motion-check <video.mp4> DOES IT MOVE? Proves a finished video is not (local ffmpeg)
970
+ a still image. A frozen render has the right
971
+ duration, frame count, audio and frame 0, so
972
+ every other check you would run passes. Exits 1
973
+ when nothing in the video ever changes. Runs
974
+ automatically after vidfarm render --target local
975
+ [--json] [--fps <n>]
964
976
  doctor Health-check the local toolchain: node, (local)
965
977
  ffmpeg, hyperframes engines, Chrome, API key
966
978
  (whoami), provider keys, agent CLI, poisoned
@@ -1817,6 +1829,13 @@ async function main() {
1817
1829
  case "stills":
1818
1830
  await runStillsCommand(rest);
1819
1831
  return;
1832
+ // "does this finished video actually move?" — the check that catches a
1833
+ // frozen render, which passes every other verification.
1834
+ case "motion-check":
1835
+ case "motioncheck":
1836
+ case "frozen-check":
1837
+ await runMotionCheckCommand(rest);
1838
+ return;
1820
1839
  case "doctor":
1821
1840
  await runDoctorCommand(rest);
1822
1841
  return;
@@ -4188,6 +4207,10 @@ async function runRenderCommand(argv) {
4188
4207
  out: { type: "string" },
4189
4208
  quality: { type: "string" },
4190
4209
  "keep-project": { type: "boolean", default: false },
4210
+ // A deliberately static render (a title card, a still loop) is a real
4211
+ // output — this is how you say so. Everything else that never moves is a
4212
+ // bug, so the default is to fail loudly.
4213
+ "allow-static": { type: "boolean", default: false },
4191
4214
  wait: { type: "boolean", default: false }
4192
4215
  }
4193
4216
  });
@@ -4246,8 +4269,20 @@ async function runRenderCommand(argv) {
4246
4269
  keepProjectDir: Boolean(parsed.values["keep-project"])
4247
4270
  });
4248
4271
  const prepCuts = result.prepNotes.filter((note) => note.action !== "skipped");
4272
+ // A frozen render is a FAILED render wearing a successful render's exit
4273
+ // code. Treat it as a failure unless the director said the video is static
4274
+ // on purpose.
4275
+ const frozen = Boolean(result.motion?.frozen) && !parsed.values["allow-static"];
4249
4276
  if (ctx.json) {
4250
- printJson({ ok: true, render_target: "local", output_path: result.outputPath, duration_ms: result.durationMs, media_prep: result.prepNotes });
4277
+ printJson({
4278
+ ok: !frozen,
4279
+ render_target: "local",
4280
+ output_path: result.outputPath,
4281
+ duration_ms: result.durationMs,
4282
+ media_prep: result.prepNotes,
4283
+ motion: result.motion ?? null,
4284
+ ...(frozen ? { error: "frozen_render", message: result.motion?.reason } : {})
4285
+ });
4251
4286
  }
4252
4287
  else {
4253
4288
  if (prepCuts.length > 0) {
@@ -4255,10 +4290,22 @@ async function runRenderCommand(argv) {
4255
4290
  }
4256
4291
  console.log(`${GREEN}Rendered locally in ${(result.durationMs / 1000).toFixed(1)}s → ${result.outputPath}${RESET}`);
4257
4292
  console.log(`${DIM}$0.00 — local Vidfarm render, no cloud charge.${RESET}`);
4293
+ if (frozen && result.motion) {
4294
+ const { formatFrozenRenderFailure } = await import("./lib/frozen-render.js");
4295
+ console.log(formatFrozenRenderFailure(result.motion, { red: RED, bold: BOLD, dim: DIM, reset: RESET }));
4296
+ }
4297
+ else if (result.motion?.skipped) {
4298
+ console.log(`${DIM}Motion not verified (${result.motion.reason}) — check it by eye before publishing.${RESET}`);
4299
+ }
4300
+ else if (result.motion) {
4301
+ console.log(`${DIM}Motion verified: ${result.motion.moving_pairs}/${result.motion.compared_pairs} sampled frame pairs differ — the video moves.${RESET}`);
4302
+ }
4258
4303
  // Nudge, not a gate: QA never runs automatically, but every production
4259
4304
  // should get a look before it's published.
4260
4305
  console.log(`${DIM}Recommended: ${RESET}vidfarm qa ${sourceDir ?? "<dir>"}${DIM} — social-native QA (HTML slop + font regime). Feedback only, free, instant.${RESET}`);
4261
4306
  }
4307
+ if (frozen)
4308
+ process.exitCode = 1;
4262
4309
  return;
4263
4310
  }
4264
4311
  if (sourceDir) {
@@ -12784,6 +12831,54 @@ async function runStillsCommand(argv) {
12784
12831
  console.log(`${DIM}READ the contact sheet as ONE image — that is how you catch what per-scene checks miss: uneven margins, a wandering type scale or accent colour, N identically-long beats, a jarring join, dead space under top-anchored content. Fix drift by defining the system, not by patching the one odd scene.${RESET}`);
12785
12832
  }
12786
12833
  }
12834
+ // `vidfarm motion-check <video.mp4>` — prove a finished video actually MOVES.
12835
+ //
12836
+ // The one check that catches a whole class of silent render failure: a frozen
12837
+ // render has the right duration, the right frame count, clean audio, and a
12838
+ // perfect frame 0, so every other verification an agent would think to run
12839
+ // passes. This one samples the video and compares consecutive frames. Runs on
12840
+ // any MP4, not just a vidfarm render — including the output of a watermark or
12841
+ // overlay pass, which is where the other known cause lives.
12842
+ async function runMotionCheckCommand(argv) {
12843
+ const parsed = parseArgs({
12844
+ args: argv,
12845
+ allowPositionals: true,
12846
+ options: {
12847
+ json: { type: "boolean", default: false },
12848
+ fps: { type: "string" }
12849
+ }
12850
+ });
12851
+ const target = parsed.positionals[0];
12852
+ if (!target)
12853
+ throw new Error("motion-check requires a video path: `vidfarm motion-check <video.mp4> [--json]`.");
12854
+ const videoPath = path.resolve(process.cwd(), target);
12855
+ if (!existsSync(videoPath))
12856
+ throw new Error(`No such file: ${videoPath}`);
12857
+ const fpsRaw = parsed.values.fps;
12858
+ const fpsGrid = fpsRaw !== undefined ? Number(fpsRaw) : undefined;
12859
+ if (fpsRaw !== undefined && (!Number.isFinite(fpsGrid) || fpsGrid <= 0)) {
12860
+ throw new Error(`--fps must be a positive number, got "${fpsRaw}".`);
12861
+ }
12862
+ const { checkRenderMotion, formatFrozenRenderFailure } = await import("./lib/frozen-render.js");
12863
+ const result = await checkRenderMotion(videoPath, fpsGrid ? { fpsGrid } : {});
12864
+ if (parsed.values.json) {
12865
+ printJson(result);
12866
+ if (result.frozen)
12867
+ process.exitCode = 1;
12868
+ return;
12869
+ }
12870
+ if (result.frozen) {
12871
+ console.log(formatFrozenRenderFailure(result, { red: RED, bold: BOLD, dim: DIM, reset: RESET }));
12872
+ process.exitCode = 1;
12873
+ return;
12874
+ }
12875
+ if (result.skipped) {
12876
+ console.log(`${YELLOW}? Motion not verified${RESET} ${DIM}— ${result.reason}${RESET}`);
12877
+ return;
12878
+ }
12879
+ console.log(`${GREEN}✓ The video moves${RESET} ${DIM}— ${result.moving_pairs}/${result.compared_pairs} sampled frame pairs differ (max change ${result.motion_score.toFixed(1)}%).${RESET}`);
12880
+ console.log(`${DIM}This proves motion exists, not that the video is good. Still watch it: vidfarm stills <dir> --sheet.${RESET}`);
12881
+ }
12787
12882
  // ── Agent skill ───────────────────────────────────────────────────────────────
12788
12883
  // Install the latest director skill onto disk as a Claude Code / agent skill so
12789
12884
  // the user's AI agent can read SKILL.director.md and act. We pull the freshest
@@ -560,7 +560,7 @@ async function startLocalRender(input) {
560
560
  void (async () => {
561
561
  try {
562
562
  const { renderCompositionLocally } = await import("./local-render.js");
563
- await renderCompositionLocally({
563
+ const outcome = await renderCompositionLocally({
564
564
  compositionHtml: compositionHtml,
565
565
  outputPath,
566
566
  stdio: "capture"
@@ -569,6 +569,11 @@ async function startLocalRender(input) {
569
569
  record.outputPath = outputPath;
570
570
  record.endedAt = Date.now();
571
571
  console.log(`[vidfarm] local render ${renderId} finished (${((record.endedAt - record.startedAt) / 1000).toFixed(1)}s) → ${outputPath}`);
572
+ // The editor keeps the file (a still card is a legal output here), but a
573
+ // frozen render is almost always a bug and must not pass silently.
574
+ if (outcome.motion?.frozen) {
575
+ console.warn(`[vidfarm] ⚠ render ${renderId} NEVER MOVES — ${outcome.motion.reason} Check for window.__player/__hf assignments and a registered window.__timelines entry (vidfarm lint).`);
576
+ }
572
577
  }
573
578
  catch (error) {
574
579
  record.status = "FAILED";
@@ -638,7 +643,7 @@ async function startStudioRender(input) {
638
643
  void (async () => {
639
644
  try {
640
645
  const { renderCompositionLocally } = await import("./local-render.js");
641
- await renderCompositionLocally({
646
+ const outcome = await renderCompositionLocally({
642
647
  compositionHtml,
643
648
  outputPath,
644
649
  fps,
@@ -650,6 +655,9 @@ async function startStudioRender(input) {
650
655
  job.stage = "rendering";
651
656
  }
652
657
  });
658
+ if (outcome.motion?.frozen) {
659
+ console.warn(`[vidfarm] ⚠ studio render ${job.filename} NEVER MOVES — ${outcome.motion.reason} Check for window.__player/__hf assignments and a registered window.__timelines entry (vidfarm lint).`);
660
+ }
653
661
  // A cancel marks the job terminal for the UI but cannot abort the child
654
662
  // process — don't resurrect a cancelled job when it eventually finishes.
655
663
  if (job.status === "rendering") {
@@ -20,6 +20,8 @@ import os from "node:os";
20
20
  import path from "node:path";
21
21
  import { prepareProjectMediaForRender } from "../lib/render-media-prep.js";
22
22
  import { resolveBundledFfprobe } from "../lib/ffprobe-path.js";
23
+ import { describeEngineGlobalHits, findEngineOwnedGlobalAssignments } from "../lib/engine-globals.js";
24
+ import { checkRenderMotion } from "../lib/frozen-render.js";
23
25
  import { runHyperframesCommand } from "./hyperframes-cli.js";
24
26
  import { normalizeTikTokCaptionLayout } from "./composition-edit.js";
25
27
  // Same invariant as the backend's forceEvenCompositionDimensions: libx264
@@ -70,6 +72,16 @@ export async function renderCompositionLocally(input) {
70
72
  if (!input.compositionHtml.includes("data-composition-id=")) {
71
73
  throw new Error("Local render requires composition HTML with data-composition-id.");
72
74
  }
75
+ // ── Preflight: engine-owned globals ─────────────────────────────────────────
76
+ // Refuse BEFORE spending 45 seconds producing a still image. This is the one
77
+ // authoring mistake whose render "succeeds" in every observable way, so the
78
+ // only place it can be stopped is here.
79
+ if (!input.allowEngineGlobals) {
80
+ const hits = findEngineOwnedGlobalAssignments(input.compositionHtml);
81
+ if (hits.length > 0) {
82
+ throw new Error(`Refusing to render: ${describeEngineGlobalHits(hits)}`);
83
+ }
84
+ }
73
85
  const startedAt = Date.now();
74
86
  const projectDir = await mkdtemp(path.join(os.tmpdir(), "vidfarm-local-render-"));
75
87
  let prepNotes = [];
@@ -121,13 +133,27 @@ export async function renderCompositionLocally(input) {
121
133
  if (!existsSync(input.outputPath)) {
122
134
  throw new Error(`hyperframes render reported success but wrote no file at ${input.outputPath}`);
123
135
  }
136
+ // ── Post-render: does it actually move? ──────────────────────────────────
137
+ // hyperframes downgrades `sub_timeline_readiness_timeout` to a warning and
138
+ // writes the file anyway, so a correct-looking exit 0 is not evidence of a
139
+ // video. One cheap ffmpeg pass settles it. Never throws — a missing ffmpeg
140
+ // comes back `skipped` rather than failing a render that is probably fine.
141
+ let motion;
142
+ if (!input.skipMotionCheck) {
143
+ motion = await checkRenderMotion(input.outputPath);
144
+ if (motion.frozen)
145
+ log("local_render.frozen", { output: input.outputPath, ...motion });
146
+ else if (motion.skipped)
147
+ log("local_render.motion_unverified", { reason: motion.reason });
148
+ }
124
149
  const durationMs = Date.now() - startedAt;
125
- log("local_render.succeeded", { output: input.outputPath, duration_ms: durationMs });
150
+ log("local_render.succeeded", { output: input.outputPath, duration_ms: durationMs, frozen: motion?.frozen ?? null });
126
151
  return {
127
152
  outputPath: input.outputPath,
128
153
  projectDir: input.keepProjectDir ? projectDir : null,
129
154
  durationMs,
130
- prepNotes
155
+ prepNotes,
156
+ ...(motion ? { motion } : {})
131
157
  };
132
158
  }
133
159
  finally {