frameyard 0.6.0 → 0.8.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.
Files changed (161) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/GLOSSARY.md +40 -16
  3. package/README.md +64 -55
  4. package/dist/assets/process.js.map +1 -1
  5. package/dist/assets/provenance.js.map +1 -1
  6. package/dist/cli.js +255 -59
  7. package/dist/cli.js.map +1 -1
  8. package/dist/composition/attributes.d.ts.map +1 -1
  9. package/dist/composition/descriptor.d.ts +17 -3
  10. package/dist/composition/descriptor.d.ts.map +1 -1
  11. package/dist/composition/descriptor.js +51 -28
  12. package/dist/composition/descriptor.js.map +1 -1
  13. package/dist/composition/diagnostics.js.map +1 -1
  14. package/dist/composition/format.d.ts +12 -0
  15. package/dist/composition/format.d.ts.map +1 -0
  16. package/dist/composition/format.js +28 -0
  17. package/dist/composition/format.js.map +1 -0
  18. package/dist/composition/index.d.ts +2 -1
  19. package/dist/composition/index.d.ts.map +1 -1
  20. package/dist/composition/index.js +1 -0
  21. package/dist/composition/index.js.map +1 -1
  22. package/dist/contracts.d.ts +55 -6
  23. package/dist/contracts.d.ts.map +1 -1
  24. package/dist/contracts.js +3 -0
  25. package/dist/contracts.js.map +1 -1
  26. package/dist/doctor.d.ts.map +1 -1
  27. package/dist/doctor.js +79 -0
  28. package/dist/doctor.js.map +1 -1
  29. package/dist/errors.d.ts +1 -1
  30. package/dist/errors.d.ts.map +1 -1
  31. package/dist/errors.js.map +1 -1
  32. package/dist/html/document-adapter.d.ts +3 -3
  33. package/dist/html/document-adapter.d.ts.map +1 -1
  34. package/dist/html/document-adapter.js +1 -1
  35. package/dist/html/document-adapter.js.map +1 -1
  36. package/dist/html/index.d.ts +1 -1
  37. package/dist/html/index.d.ts.map +1 -1
  38. package/dist/html/parse.d.ts +10 -2
  39. package/dist/html/parse.d.ts.map +1 -1
  40. package/dist/html/parse.js +14 -5
  41. package/dist/html/parse.js.map +1 -1
  42. package/dist/html/validate.d.ts +12 -1
  43. package/dist/html/validate.d.ts.map +1 -1
  44. package/dist/html/validate.js +233 -61
  45. package/dist/html/validate.js.map +1 -1
  46. package/dist/index.d.ts +4 -4
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +2 -2
  49. package/dist/index.js.map +1 -1
  50. package/dist/preview/main.d.ts +2 -2
  51. package/dist/preview/main.d.ts.map +1 -1
  52. package/dist/preview/main.js +47 -19
  53. package/dist/preview/main.js.map +1 -1
  54. package/dist/preview/timeline-data.d.ts +5 -1
  55. package/dist/preview/timeline-data.d.ts.map +1 -1
  56. package/dist/preview/timeline-host.d.ts +2 -0
  57. package/dist/preview/timeline-host.d.ts.map +1 -1
  58. package/dist/preview/timeline-host.js +31 -10
  59. package/dist/preview/timeline-host.js.map +1 -1
  60. package/dist/preview/timeline-main.js +12 -7
  61. package/dist/preview/timeline-main.js.map +1 -1
  62. package/dist/project.d.ts +3 -5
  63. package/dist/project.d.ts.map +1 -1
  64. package/dist/project.js.map +1 -1
  65. package/dist/renderer/browser.d.ts +2 -2
  66. package/dist/renderer/browser.d.ts.map +1 -1
  67. package/dist/renderer/browser.js +10 -4
  68. package/dist/renderer/browser.js.map +1 -1
  69. package/dist/renderer/ffmpeg.d.ts +69 -2
  70. package/dist/renderer/ffmpeg.d.ts.map +1 -1
  71. package/dist/renderer/ffmpeg.js +381 -69
  72. package/dist/renderer/ffmpeg.js.map +1 -1
  73. package/dist/renderer/index.d.ts +66 -5
  74. package/dist/renderer/index.d.ts.map +1 -1
  75. package/dist/renderer/index.js +226 -51
  76. package/dist/renderer/index.js.map +1 -1
  77. package/dist/renderer/timeline.d.ts +41 -4
  78. package/dist/renderer/timeline.d.ts.map +1 -1
  79. package/dist/renderer/timeline.js +242 -39
  80. package/dist/renderer/timeline.js.map +1 -1
  81. package/dist/runtime/host.d.ts.map +1 -1
  82. package/dist/runtime/host.js +155 -31
  83. package/dist/runtime/host.js.map +1 -1
  84. package/dist/runtime/media.d.ts.map +1 -1
  85. package/dist/runtime/media.js +3 -6
  86. package/dist/runtime/media.js.map +1 -1
  87. package/dist/runtime/video.d.ts +13 -0
  88. package/dist/runtime/video.d.ts.map +1 -0
  89. package/dist/runtime/video.js +41 -0
  90. package/dist/runtime/video.js.map +1 -0
  91. package/dist/scaffold.d.ts.map +1 -1
  92. package/dist/scaffold.js +107 -103
  93. package/dist/scaffold.js.map +1 -1
  94. package/dist/server/composition-html.d.ts +13 -7
  95. package/dist/server/composition-html.d.ts.map +1 -1
  96. package/dist/server/composition-html.js +65 -16
  97. package/dist/server/composition-html.js.map +1 -1
  98. package/dist/server/preview-html.js +1 -1
  99. package/dist/server/server.d.ts.map +1 -1
  100. package/dist/server/server.js +26 -6
  101. package/dist/server/server.js.map +1 -1
  102. package/dist/server/timeline-preview-data.d.ts.map +1 -1
  103. package/dist/server/timeline-preview-data.js +11 -0
  104. package/dist/server/timeline-preview-data.js.map +1 -1
  105. package/dist/server/timeline-preview-html.d.ts.map +1 -1
  106. package/dist/server/timeline-preview-html.js +2 -1
  107. package/dist/server/timeline-preview-html.js.map +1 -1
  108. package/dist/timeline/plan.js.map +1 -1
  109. package/dist/timeline/resolve.d.ts +4 -3
  110. package/dist/timeline/resolve.d.ts.map +1 -1
  111. package/dist/timeline/resolve.js +14 -4
  112. package/dist/timeline/resolve.js.map +1 -1
  113. package/dist/timeline/schema.d.ts +7 -0
  114. package/dist/timeline/schema.d.ts.map +1 -1
  115. package/dist/timeline/schema.js +50 -0
  116. package/dist/timeline/schema.js.map +1 -1
  117. package/dist/transcript.js.map +1 -1
  118. package/dist/version.d.ts +1 -1
  119. package/dist/version.js +1 -1
  120. package/docs/adr/0007-make-html-documents-the-composition-unit.md +1 -1
  121. package/docs/adr/0008-share-the-composition-document-grammar.md +1 -1
  122. package/docs/adr/0011-compose-timeline-frames-in-a-live-browser-host.md +1 -1
  123. package/docs/adr/0012-render-timelines-with-segmented-lossless-clip-intermediates.md +1 -1
  124. package/docs/adr/0013-capture-bounded-multi-frame-stills-for-inspection.md +33 -0
  125. package/docs/adr/0014-compose-timeline-clips-with-css-blend-modes.md +37 -0
  126. package/docs/adr/0015-inherit-composition-format-from-the-project.md +28 -0
  127. package/docs/adr/0016-author-compositions-as-html-fragments.md +32 -0
  128. package/docs/adr/0017-render-timeless-compositions-as-stills.md +99 -0
  129. package/docs/migration-0.4.md +3 -1
  130. package/docs/migration-0.8.md +163 -0
  131. package/docs/preview-testing.md +60 -0
  132. package/docs/v0.1-design.md +52 -42
  133. package/examples/blend/README.md +106 -0
  134. package/examples/blend/assets/ink.mp4 +0 -0
  135. package/examples/blend/compositions/aurora.html +55 -0
  136. package/examples/blend/compositions/backdrop.html +74 -0
  137. package/examples/blend/compositions/labels.html +149 -0
  138. package/examples/blend/scripts/make-ink.sh +26 -0
  139. package/examples/blend/timelines/blend.timeline.json +155 -0
  140. package/examples/blend/video.config.js +11 -0
  141. package/examples/demo/compositions/demo.html +243 -251
  142. package/examples/static/README.md +17 -0
  143. package/examples/static/compositions/still.html +37 -0
  144. package/examples/static/timelines/still.timeline.json +13 -0
  145. package/examples/static/video.config.js +9 -0
  146. package/examples/video/compositions/video-mp4.html +349 -357
  147. package/examples/video/compositions/video-overlay.html +337 -345
  148. package/examples/video/compositions/video.html +349 -357
  149. package/examples/webgl/compositions/webgl.html +205 -213
  150. package/package.json +6 -3
  151. package/skills/frameyard/README.md +7 -5
  152. package/skills/frameyard/SKILL.md +17 -13
  153. package/skills/frameyard/references/common-mistakes.md +30 -6
  154. package/skills/frameyard/references/controllers.md +5 -4
  155. package/skills/frameyard/references/deterministic-authoring.md +42 -36
  156. package/skills/frameyard/references/diagnostics.md +11 -3
  157. package/skills/frameyard/references/failure-recovery.md +28 -7
  158. package/skills/frameyard/references/json-results.md +58 -7
  159. package/skills/frameyard/references/subtitles-karaoke.md +2 -2
  160. package/skills/frameyard/references/timelines.md +21 -15
  161. package/skills/frameyard/scripts/frameyard.mjs +20 -3
@@ -36,17 +36,18 @@ export default {
36
36
  };
37
37
  ```
38
38
 
39
- Exactly one controller is allowed. Inline source is served through a virtual module URL, so relative imports and `import.meta.url` work like an external module. A literal `</script>` inside inline source must be escaped.
39
+ Exactly one controller is allowed, and it must be a top-level sibling of `<fy-composition>` in the fragment. Inline source is served through a virtual module URL, so relative imports and `import.meta.url` work like an external module. A literal `</script>` inside inline source must be escaped.
40
40
 
41
41
  ## Lifecycle
42
42
 
43
43
  - `mount({ root, props })` runs once per load and may be asynchronous. Use it to query the root, create canvases, and start loading local data.
44
44
  - `update({ frame, props, root })` runs on every seek. It must be synchronous and seek-safe: derive the frame from `frame` and `props` only.
45
- - `dispose()` releases resources and resets module-scope state. The host calls it before remounting or disposing the document.
45
+ - `dispose()` releases resources and resets module-scope state. The host calls it before remounting or disposing the composition host.
46
+ - A static composition runs `mount()` and `validateProps()` only. `update()` is not called and frame-state variables are not set; the host reports `STATIC_UPDATE_IGNORED`. Do not create media in a static controller: the host rejects it with `STATIC_MEDIA_UNSUPPORTED`. Draw canvas/WebGL content once from `mount()`.
46
47
  - `validateProps(props)` is optional and synchronous. Return an array of human-readable errors, or `void`.
47
48
  - A validation-only controller may export just `validateProps`.
48
49
 
49
- Keep mutable state in module scope, never on `this`. The controller module is imported once per document; `load` remounts it without re-importing, so `dispose` must reset module state.
50
+ Keep mutable state in module scope, never on `this`. The controller module is imported once per page; `load` remounts it without re-importing, so `dispose` must reset module state.
50
51
 
51
52
  ## Props validation
52
53
 
@@ -105,7 +106,7 @@ Never use `Math.random()`, `Date.now()`, `performance.now()`, timers, independen
105
106
 
106
107
  Prefer declarative `<video>`/`<audio>` elements with `data-fy-*` attributes. The host pauses and seeks them; do not call `play()`, `pause()`, or set `currentTime` in `update()`. Media created by the controller is allowed in `mount`; media added or changed in `update` cannot be prepared without network access.
107
108
 
108
- Media and transcript `src` values resolve from the project root. The controller `src` resolves relative to the composition document.
109
+ Media and transcript `src` values resolve from the project root. The controller `src` resolves relative to the composition URL.
109
110
 
110
111
  ## Karaoke
111
112
 
@@ -1,6 +1,6 @@
1
1
  # Deterministic HTML authoring
2
2
 
3
- Frameyard compositions are full HTML documents. The preview server injects the framework import map and composition host; do not add a bundling step. Optional controllers are native JavaScript modules that import bare `frameyard/runtime`.
3
+ Frameyard compositions are partial HTML fragments. The preview server wraps them in the document shell and injects the framework import map and composition host; do not add a bundling step. Optional controllers are native JavaScript modules that import bare `frameyard/runtime`.
4
4
 
5
5
  ## Project configuration
6
6
 
@@ -14,42 +14,47 @@ export default {
14
14
  };
15
15
  ```
16
16
 
17
- There is no directory scanning. A new composition document is invisible until it is listed. Entries must be `.html` files; JavaScript composition modules are rejected.
17
+ There is no directory scanning. A new composition fragment is invisible until it is listed. Entries must be `.html` files; JavaScript composition modules are rejected.
18
18
 
19
19
  ## Composition shape
20
20
 
21
21
  ```html
22
- <!doctype html>
23
- <html>
24
- <head>
25
- <meta charset="utf-8">
26
- <script type="application/json" data-fy="props">
27
- { "title": "Hello", "accent": "#b8f36b" }
28
- </script>
29
- <style>
30
- @keyframes fade-up {
31
- from { opacity: 0; transform: translateY(24px); }
32
- to { opacity: 1; transform: translateY(0); }
33
- }
34
- #title { animation: fade-up 1s ease-out both; }
35
- </style>
36
- </head>
37
- <body>
38
- <fy-composition width="1920" height="1080" fps="30" duration-in-frames="900">
39
- <h1 id="title"
40
- data-fy-text="title"
41
- data-fy-anim-start="0"
42
- data-fy-anim-duration="30"></h1>
43
- <div style="width: calc(var(--fy-progress) * 100%)"></div>
44
- </fy-composition>
45
- </body>
46
- </html>
22
+ <script type="application/json" data-fy="props">
23
+ { "title": "Hello", "accent": "#b8f36b" }
24
+ </script>
25
+ <style>
26
+ @keyframes fade-up {
27
+ from { opacity: 0; transform: translateY(24px); }
28
+ to { opacity: 1; transform: translateY(0); }
29
+ }
30
+ #title { animation: fade-up 1s ease-out both; }
31
+ </style>
32
+ <fy-composition width="1920" height="1080" fps="30" duration-in-frames="900">
33
+ <h1 id="title"
34
+ data-fy-text="title"
35
+ data-fy-anim-start="0"
36
+ data-fy-anim-duration="30"></h1>
37
+ <div style="width: calc(var(--fy-progress) * 100%)"></div>
38
+ </fy-composition>
47
39
  ```
48
40
 
49
- - `width`, `height`, `fps`, and `duration-in-frames` are fixed on the root.
50
- - The `data-fy="props"` block is a JSON object. External props merge shallowly over it and are deep-frozen.
51
- - There is exactly one `<fy-composition>` root, inside `<body>`.
52
- - A doctype is required. Unsupported document features such as `<base>`, refresh/CSP meta tags, `<iframe>`, `<object>`, and `<embed>` are rejected.
41
+ - `duration-in-frames` is required for an animated composition. `width`, `height`, and `fps` are optional: a missing value inherits the project `format`, and a declared value wins. Declare `width` and `height` together or not at all. A composition that declares neither `duration-in-frames` nor `fps` is static.
42
+ - The `data-fy="props"` block is a top-level JSON object. External props merge shallowly over it and are deep-frozen.
43
+ - There is exactly one `<fy-composition>` root, and it must be a top-level element in the fragment.
44
+ - Do not add `<!doctype>`, `<html>`, `<head>`, `<body>`, `<title>`, or `<meta>`; Frameyard owns the shell. Props, the controller, and import maps are top-level siblings of the root. Top-level `<style>` and `<link rel="stylesheet">` elements are hoisted into the generated head; styles inside the root stay in the body.
45
+ - An optional top-level `<script type="importmap">` adds bare specifiers for controllers. `frameyard` and `frameyard/` specifiers are reserved.
46
+ - The reserved `data-fy-host` attribute and `data-fy="format"` block are framework-only. Unsupported features such as `<base>`, refresh/CSP meta tags, `<iframe>`, `<object>`, and `<embed>` are rejected.
47
+
48
+ ## Static compositions
49
+
50
+ A static composition declares neither `duration-in-frames` nor `fps`. It has no frame clock: Frameyard loads it, applies bindings, runs `mount()`, pauses CSS animations at time 0, and captures one PNG. `update()` is not called and `--fy-frame`, `--fy-time`, `--fy-progress`, `--fy-fps`, `--fy-duration`, and `--fy-duration-frames` are not set.
51
+
52
+ - Use `<img>`, CSS backgrounds, SVG, and canvas drawn once from `mount()`.
53
+ - Do not use `data-fy-anim-*`, `<video>`, `<audio>`, or `<fy-transcript>`; these are validation errors. Put media and captions on a timeline.
54
+ - Do not rely on `update()` or frame-state variables. A controller that exports `update()` loads with a `STATIC_UPDATE_IGNORED` warning.
55
+ - CSS animations and transitions are paused at 0 with a `STATIC_ANIMATION_PAUSED` warning; remove them for a predictable still.
56
+ - In a timeline, a static composition clip needs an explicit `duration` and `sourceIn: 0`.
57
+ - `render` writes a PNG and rejects video flags. Use a timeline image clip when the deliverable must be video.
53
58
 
54
59
  ## Bindings
55
60
 
@@ -126,7 +131,7 @@ External for a reusable or complex controller:
126
131
  - A validation-only controller may export just `validateProps(props)`.
127
132
  - Inline source is served through a virtual module URL, so relative imports and `import.meta.url` work like an external module. A literal `</script>` inside inline source must be escaped.
128
133
  - The controller is the only executable code. Other executable `<script>` elements and `on*` handlers are rejected.
129
- - Controller `src` resolves relative to the composition document. Media and transcript `src` values resolve from the project root.
134
+ - Controller `src` resolves relative to the composition URL. Media and transcript `src` values resolve from the project root.
130
135
  - Keep mutable state in module scope, never on `this`. Reset it in `dispose()`.
131
136
 
132
137
  ## Forbidden animation inputs
@@ -167,7 +172,7 @@ Do not preserve and accumulate WebGL drawing across frames unless that accumulat
167
172
 
168
173
  ## Embedded video and audio
169
174
 
170
- Declare local media in the composition document:
175
+ Declare local media in the composition fragment:
171
176
 
172
177
  ```html
173
178
  <video
@@ -210,17 +215,18 @@ Declare canonical transcript requirements with:
210
215
 
211
216
  Remote data, images, fonts, and other assets must resolve during the loading phase before frame production. The production browser blocks network requests after loading begins. A fetch from `update()` is a bug.
212
217
 
213
- Images declared in the document are awaited by the host. Static fonts should be loaded with `document.fonts.ready` or an explicit `FontFace` before frame production.
218
+ Images declared in the fragment are awaited by the host. Static fonts should be loaded with `document.fonts.ready` or an explicit `FontFace` before frame production.
214
219
 
215
220
  ## Validation and inspection
216
221
 
217
222
  ```sh
218
223
  npx frameyard check hello --json
219
224
  npx frameyard still hello --frame 42 --out hello-42.png --json
225
+ npx frameyard still hello --frame 0,118-126 --out out/stills --json
220
226
  ```
221
227
 
222
- `check` runs static validation before Chromium, then evaluates every frame and validates audio and declared transcripts without capturing or encoding. Read the structured `diagnostics` array. Do not replace `check` with a single still.
228
+ `check` runs static validation before Chromium, then evaluates every animated frame and validates audio and declared transcripts without capturing or encoding. A static composition is inspected once. Read the structured `diagnostics` array. Do not replace `check` with a single still. Inspect several representative frames in one `still` call with a range or list; the batch seeks sequentially in one session and is limited to 32 unique frames. A static composition exposes only frame 0.
223
229
 
224
230
  ## Metadata is fixed
225
231
 
226
- `width`, `height`, `fps`, and `duration-in-frames` cannot be derived from props. Choose a reusable resolution, frame rate, and duration; put variation in props. Frameyard does not change duration to fit audio.
232
+ `width`, `height`, `fps`, and `duration-in-frames` cannot be derived from props. An animated composition always declares `duration-in-frames`; a static composition declares neither time attribute. `width`, `height`, and `fps` are either declared or inherited from the project `format`, and the resolved values are fixed for the load. A composition that declares no dimensions in a project without `format` fails with `COMPOSITION_FORMAT_UNRESOLVED`. The resolved format is part of the immutable frame environment, so viewport units and percentage layout are valid but depend on it. Put variation in props. Frameyard does not change duration to fit audio.
@@ -8,12 +8,13 @@ Frameyard reports composition problems as structured diagnostics before and duri
8
8
 
9
9
  Static validation covers:
10
10
 
11
- - doctype, root count, metadata, and default-props JSON
11
+ - fragment structure, root count and placement, metadata (including `width`/`height` pairing), default-props JSON, and import maps
12
12
  - controller declaration and file existence
13
13
  - executable scripts, `on*` handlers, `javascript:` URLs, and unsupported visual elements
14
14
  - unknown `data-fy-*` attributes, binding paths, `if`/`repeat` placement, and animation attributes
15
15
  - audio/video/transcript references and ordinary image/stylesheet assets
16
16
  - visual content outside `<fy-composition>`
17
+ - document-level `<title>`/`<meta>` elements, the reserved `data-fy-host` attribute, and non-top-level props, controllers, or import maps
17
18
 
18
19
  ## Diagnostic shape
19
20
 
@@ -40,7 +41,10 @@ Static validation covers:
40
41
 
41
42
  | Code | Severity | Meaning |
42
43
  | --- | --- | --- |
43
- | `COMPOSITION_INVALID` | error | Missing/invalid doctype, root, metadata, or document structure |
44
+ | `COMPOSITION_INVALID` | error | Missing/invalid root, metadata, fragment structure, or reserved attributes |
45
+ | `COMPOSITION_DOCUMENT_SHELL` | error | A full document shell (`<!doctype>`, `<html>`, `<head>`, or `<body>`) is present; compositions are fragments |
46
+ | `IMPORT_MAP_INVALID` | error | Invalid import map placement, JSON, attributes, or reserved `frameyard` specifiers |
47
+ | `COMPOSITION_FORMAT_UNRESOLVED` | error | The composition omits `width`/`height` and the project has no `format`; this is a command error, not a document diagnostic |
44
48
  | `PROPS_INVALID` | error | Invalid default props JSON, invalid overrides, or `validateProps` failure |
45
49
  | `CONTROLLER_INVALID` | error | Missing/duplicate/both-inline-and-external controller, missing file, or invalid exports |
46
50
  | `BINDING_INVALID` | error | Unknown `data-fy-*` attribute or malformed binding path |
@@ -52,6 +56,10 @@ Static validation covers:
52
56
  | `ANIMATION_INVALID` | error | Invalid `data-fy-anim-*` value |
53
57
  | `ANIMATION_NOT_FOUND` | warning | `data-fy-anim-*` is present but the element has no CSS animation |
54
58
  | `ANIMATION_MULTIPLE` | warning | Multiple animations on one declaratively scheduled element |
59
+ | `STATIC_ANIMATION_UNSUPPORTED` | error | A static composition uses `data-fy-anim-*`; remove it or declare `duration-in-frames` and `fps` |
60
+ | `STATIC_MEDIA_UNSUPPORTED` | error | A static composition declares `<video>`, `<audio>`, or `<fy-transcript>`; move media to a timeline |
61
+ | `STATIC_UPDATE_IGNORED` | warning | A static composition controller exports `update()`; move initialization into `mount()` |
62
+ | `STATIC_ANIMATION_PAUSED` | warning | A static composition has CSS animations/transitions; they are paused at time 0 |
55
63
  | `SCRIPT_NOT_ALLOWED` | error | Executable `<script>` other than the controller |
56
64
  | `EVENT_HANDLER_NOT_ALLOWED` | error | Inline `on*` handler or `data-fy-attr-on*` |
57
65
  | `URL_NOT_ALLOWED` | error | `javascript:` URL |
@@ -75,7 +83,7 @@ Static validation covers:
75
83
  - `controller`: `{ kind: "inline" }`, `{ kind: "external", src }`, or `null`
76
84
  - `diagnostics`: all static and runtime diagnostics, deduplicated
77
85
 
78
- `check` also exposes `metadata`, `framesChecked`, `audio.details`, and `transcripts.details`. `still` exposes `frame`, `path`, `width`, and `height`. `render` exposes the output path, dimensions, frame count, concurrency, bytes, and audio/transcript clip counts.
86
+ `check` and `render` are discriminated unions. Animated/video results report `kind: "animated"` or `kind: "video"`; static composition results report `kind: "static"` or `kind: "image"`. A static check exposes `metadata: { width, height, static: true }` and `framesChecked: 1` with no audio/transcript fields. An image render exposes the PNG path, dimensions, bytes, diagnostics, and elapsed time with no fps, duration, frame count, codec, container, encoder, audio, or transcript fields. `still` exposes `frames`, `width`, and `height`, plus `frame` and `path` when exactly one frame was captured; a static composition only accepts frame 0.
79
87
 
80
88
  Failures use the same envelope with `error.code`, `error.message`, optional `error.details`, and top-level `diagnostics`.
81
89
 
@@ -10,12 +10,15 @@ Do not claim success after a nonzero exit. Fix the cause, rerun `check`, and onl
10
10
  | `CONFIG_NOT_FOUND` | No `video.config.js` found | Run from the project root or pass `--project`. |
11
11
  | `CONFIG_INVALID` | Invalid config or composition entry | Fix `video.config.js`; entries must be explicit, local, `.html`, and exist. |
12
12
  | `COMPOSITION_NOT_FOUND` | ID is not listed | Add the entry to `video.config.js`. |
13
- | `COMPOSITION_INVALID` | Invalid doctype, root, metadata, or document structure | Fix the composition document; check `details.line` and `details.attribute`. |
13
+ | `COMPOSITION_INVALID` | Invalid root, metadata, fragment structure, or reserved attribute | Fix the composition fragment; check `details.line` and `details.attribute`. |
14
+ | `COMPOSITION_DOCUMENT_SHELL` | A full document shell is present | Remove `<!doctype>`, `<html>`, `<head>`, and `<body>`; keep the fragment body and top-level metadata. See the 0.8 migration guide. |
15
+ | `IMPORT_MAP_INVALID` | Invalid import map placement, JSON, attributes, or reserved specifier | Keep one or more top-level import maps; remove `frameyard`/`frameyard/` keys and forbidden attributes. |
16
+ | `COMPOSITION_FORMAT_UNRESOLVED` | The composition omits `width`/`height` and the project has no `format` | Declare `width`/`height` on `<fy-composition>`, add `format { width, height, fps }` to `video.config.js`, or render through a timeline. |
14
17
  | `COMPOSITION_ERROR` | Mount, update, or browser page error | Read the frame location; remove forbidden clocks/randomness or fix the controller. |
15
18
  | `COMPOSITION_UPDATE_ASYNC` | `update()` returned a promise | Move async work to `mount()`. |
16
19
  | `COMPOSITION_NOT_READY` | `seek`/`settle` before a successful load | Await `load`/`ready`. |
17
20
  | `COMPOSITION_DISPOSED` | Operation after host disposal | Reload the composition. |
18
- | `CONTROLLER_INVALID` | Missing, duplicate, or invalid controller declaration/file/exports | Keep exactly one inline or external controller; verify its exports and path. |
21
+ | `CONTROLLER_INVALID` | Missing, duplicate, non-top-level, or invalid controller declaration/file/exports | Keep exactly one inline or external top-level controller; verify its exports and path. |
19
22
  | `BINDING_INVALID` | Unknown `data-fy-*` attribute or malformed path | Fix the attribute name or path. |
20
23
  | `BINDING_PATH_MISSING` | Binding path absent from resolved props | Fix the prop or path; use `details.suggestion`. |
21
24
  | `BINDING_VALUE_INVALID` | Object/array used where a primitive is required | Use a string/number/boolean or move the logic to a controller. |
@@ -25,14 +28,18 @@ Do not claim success after a nonzero exit. Fix the cause, rerun `check`, and onl
25
28
  | `ANIMATION_INVALID` | Invalid `data-fy-anim-*` value | Use non-negative integer frames and a positive duration. |
26
29
  | `ANIMATION_NOT_FOUND` | `data-fy-anim-*` without a CSS animation | Add an `animation` shorthand or remove the attributes. |
27
30
  | `ANIMATION_MULTIPLE` | Multiple animations on one scheduled element | Use one animation or move scheduling to CSS/the controller. |
31
+ | `STATIC_ANIMATION_UNSUPPORTED` | A static composition uses `data-fy-anim-*` | Remove the attributes or declare `duration-in-frames` and `fps` to make the composition animated. |
32
+ | `STATIC_MEDIA_UNSUPPORTED` | A static composition declares video, audio, or a transcript | Move media and captions to a timeline, or declare `duration-in-frames` and `fps`. |
33
+ | `STATIC_UPDATE_IGNORED` | A static composition exports `update()` | Move initialization into `mount()`; static compositions do not run `update()`. |
34
+ | `STATIC_ANIMATION_PAUSED` | A static composition has CSS animations/transitions | Remove them; the capture uses time 0. |
28
35
  | `SCRIPT_NOT_ALLOWED` | Executable script other than the controller | Move code into the controller. |
29
36
  | `EVENT_HANDLER_NOT_ALLOWED` | Inline `on*` handler or `data-fy-attr-on*` | Remove the handler; use a controller. |
30
37
  | `URL_NOT_ALLOWED` | `javascript:` URL | Use a safe URL. |
31
- | `ELEMENT_NOT_SUPPORTED` | `iframe`, `object`, `embed`, `base`, refresh/CSP meta, `source`, `track`, or declarative shadow DOM | Use supported DOM/SVG/canvas/video sources. |
38
+ | `ELEMENT_NOT_SUPPORTED` | `iframe`, `object`, `embed`, `base`, document-level `<title>`/`<meta>`, refresh/CSP meta, `source`, `track`, or declarative shadow DOM | Use supported DOM/SVG/canvas/video sources. |
32
39
  | `ELEMENT_UNKNOWN` | Unknown `fy-*` element | Use `fy-composition`/`fy-transcript` or a normal element. |
33
40
  | `CONTENT_OUTSIDE_ROOT` | Visual content outside `<fy-composition>` | Move it inside the root. |
34
- | `PROPS_INVALID` | Invalid props JSON or `validateProps()` failure | Fix the props file or validator. |
35
- | `FRAME_OUT_OF_RANGE` | Frame outside `0..durationInFrames - 1` | Choose a valid frame. |
41
+ | `PROPS_INVALID` | Invalid/non-top-level props JSON or `validateProps()` failure | Put the props block at the top level of the fragment; fix the props file or validator. |
42
+ | `FRAME_OUT_OF_RANGE` | Frame outside `0..durationInFrames - 1` | Choose valid frames; a batch error lists every invalid frame and the valid range. |
36
43
  | `AUDIO_INVALID` | Bad clip or unreadable/unsupported audio | Use a local project-root-relative file with a decodable audio stream. |
37
44
  | `AUDIO_WARNING` | Audio timeline overrun or silent embedded media | Adjust timing/trim or remove the silent source. |
38
45
  | `MEDIA_INVALID` | Invalid media source or attributes | Use local, seekable files and valid `data-fy-*` values. |
@@ -57,11 +64,12 @@ Do not claim success after a nonzero exit. Fix the cause, rerun `check`, and onl
57
64
 
58
65
  ## Behavior to rely on
59
66
 
60
- - A failed render writes to a temporary file and does not publish a partial MP4.
67
+ - A failed render writes to a temporary file and does not publish a partial output.
68
+ - A multi-frame still captures every frame to a temporary file first; a failed capture publishes no stills. `OUTPUT_EXISTS` lists every conflicting output path before capture starts.
61
69
  - Failed TTS/transcription operations leave no artifact and no manifest entry.
62
70
  - Existing outputs are replaced only with `--overwrite` (renders/stills) or `--force` (generated assets).
63
71
  - Cancellation terminates the child inference process and cleans temporary work.
64
- - `check` validates every frame and declared transcripts without encoding.
72
+ - `check` validates every animated frame, or a static composition's single state, and declared transcripts without encoding.
65
73
  - Static validation failures exit before Chromium starts.
66
74
  - `check`, `still`, and `render` seek embedded videos and wait for decode before evaluating or capturing a frame.
67
75
  - `doctor` inspects but never repairs, downloads, or modifies the environment.
@@ -81,3 +89,16 @@ Report all of:
81
89
  - still paths and whether they were viewed;
82
90
  - final video path, frame count, and audio/transcript summary;
83
91
  - any skipped step or unresolved diagnostic.
92
+
93
+ ## 0.8 fragment migration
94
+
95
+ 0.8 changed compositions from full documents to partial HTML fragments. If `check` reports `COMPOSITION_DOCUMENT_SHELL`:
96
+
97
+ 1. Remove `<!doctype html>`, `<html>`, `<head>`, `<body>`, `<title>`, and `<meta>`.
98
+ 2. Keep one top-level `<fy-composition>` root.
99
+ 3. Move the props block, the controller, and import maps to the top level.
100
+ 4. Keep top-level `<style>` and `<link rel="stylesheet">` as siblings; Frameyard hoists them into the generated head. Styles inside the root stay in the body.
101
+ 5. Remove the reserved `data-fy-host` attribute and `data-fy="format"` block.
102
+ 6. Run `npx frameyard check <id> --json` again.
103
+
104
+ See `docs/migration-0.8.md` in the Frameyard repository for before/after examples.
@@ -11,7 +11,7 @@ Every non-interactive command supports `--json`. Stdout contains one JSON docume
11
11
  }
12
12
  ```
13
13
 
14
- Additional fields are command-specific and stable within the 0.4 contract.
14
+ Additional fields are command-specific and stable within the 0.8 contract. `check`, `still`, and `render` report `elapsedMs`, the wall time of the operation from static validation and preflight through the final frame or publication; CLI argument parsing is excluded.
15
15
 
16
16
  ## Error envelope
17
17
 
@@ -55,10 +55,13 @@ Static validation diagnostics include source locations. Runtime diagnostics may
55
55
 
56
56
  ## check
57
57
 
58
+ Animated composition results report `kind: "animated"`. `metadata` reports the resolved format, combining declared values with the project `format`.
59
+
58
60
  ```json
59
61
  {
60
62
  "ok": true,
61
63
  "command": "check",
64
+ "kind": "animated",
62
65
  "composition": "hello",
63
66
  "entry": "compositions/hello.html",
64
67
  "controller": { "kind": "inline" },
@@ -89,7 +92,24 @@ Static validation diagnostics include source locations. Runtime diagnostics may
89
92
  }
90
93
  ```
91
94
 
92
- `entry` is the project-relative HTML path. `controller` is `{ kind: "inline" }`, `{ kind: "external", src }`, or `null`.
95
+ `entry` is the project-relative composition fragment path. `controller` is `{ kind: "inline" }`, `{ kind: "external", src }`, or `null`.
96
+
97
+ A static composition result reports `kind: "static"`, dimensions only, `framesChecked: 1`, and no audio/transcript fields:
98
+
99
+ ```json
100
+ {
101
+ "ok": true,
102
+ "command": "check",
103
+ "kind": "static",
104
+ "composition": "still",
105
+ "entry": "compositions/still.html",
106
+ "controller": null,
107
+ "metadata": { "width": 1920, "height": 1080, "static": true },
108
+ "framesChecked": 1,
109
+ "diagnostics": [],
110
+ "elapsedMs": 600
111
+ }
112
+ ```
93
113
 
94
114
  ## still
95
115
 
@@ -100,21 +120,28 @@ Static validation diagnostics include source locations. Runtime diagnostics may
100
120
  "composition": "hello",
101
121
  "entry": "compositions/hello.html",
102
122
  "controller": { "kind": "external", "src": "compositions/hello.controller.js" },
103
- "frame": 42,
104
- "path": "/absolute/path/hello-42.png",
123
+ "frames": [
124
+ { "frame": 0, "path": "/absolute/path/out/stills/still-hello-0.png" },
125
+ { "frame": 42, "path": "/absolute/path/out/stills/still-hello-42.png" }
126
+ ],
105
127
  "width": 1920,
106
128
  "height": 1080,
107
129
  "diagnostics": [],
108
- "elapsedMs": 700
130
+ "elapsedMs": 1400
109
131
  }
110
132
  ```
111
133
 
134
+ `frames` is always present and follows the expanded `--frame` order. `frame` and `path` are also present only when exactly one frame was captured. `--frame` accepts a number, an inclusive range (`2-12`), or a comma-separated list; at most 32 unique frames are captured per invocation. With multiple frames, `--out` is a directory and the files are named `still-<id>-<frame>.png`. A static composition exposes only frame `0`; other frames fail with `FRAME_OUT_OF_RANGE`.
135
+
112
136
  ## render
113
137
 
138
+ Animated composition and timeline results report `kind: "video"`:
139
+
114
140
  ```json
115
141
  {
116
142
  "ok": true,
117
143
  "command": "render",
144
+ "kind": "video",
118
145
  "composition": "hello",
119
146
  "entry": "compositions/hello.html",
120
147
  "controller": { "kind": "inline" },
@@ -126,6 +153,9 @@ Static validation diagnostics include source locations. Runtime diagnostics may
126
153
  "durationSeconds": 30,
127
154
  "framesRendered": 900,
128
155
  "concurrency": 1,
156
+ "codec": "h264",
157
+ "container": "mp4",
158
+ "encoder": "libx264",
129
159
  "bytes": 1234567,
130
160
  "audio": { "clips": 1 },
131
161
  "transcripts": { "clips": 1 },
@@ -134,7 +164,26 @@ Static validation diagnostics include source locations. Runtime diagnostics may
134
164
  }
135
165
  ```
136
166
 
137
- A successful render means the final path exists and was published atomically. A nonzero exit means no new partial video was published. Without `--out`, the final path is `out/<composition-id>.mp4` inside the project root. `concurrency` is the number of browser sessions actually used; it is capped by `durationInFrames` and by the CLI maximum of 16.
167
+ A static composition result reports `kind: "image"` and has no fps, duration, frame count, codec, container, encoder, audio, or transcript fields:
168
+
169
+ ```json
170
+ {
171
+ "ok": true,
172
+ "command": "render",
173
+ "kind": "image",
174
+ "composition": "still",
175
+ "entry": "compositions/still.html",
176
+ "controller": null,
177
+ "path": "/absolute/path/out/still.png",
178
+ "width": 1920,
179
+ "height": 1080,
180
+ "bytes": 48210,
181
+ "diagnostics": [],
182
+ "elapsedMs": 700
183
+ }
184
+ ```
185
+
186
+ A successful render means the final path exists and was published atomically. A nonzero exit means no new partial output was published. Without `--out`, the final path is `out/<composition-id>.<ext>` for video and `out/<composition-id>.png` for a static composition inside the project root. `concurrency` is the number of browser sessions actually used; it is capped by `durationInFrames` and by the CLI maximum of 16, and a static composition rejects it. `container` is `mp4`, `mov`, `mkv`, or `webm`; `codec` is `h264`, `hevc`, or `vp9`; and `encoder` is the resolved encoder (`libx264`, `libx265`, `libvpx-vp9`, `h264_nvenc`, `hevc_nvenc`, `h264_qsv`, or `hevc_qsv`). Timeline results also report the effective `workers` count after the encoder session cap, with an `ENCODER_WARNING` diagnostic when it was reduced.
138
187
 
139
188
  ## tts
140
189
 
@@ -190,7 +239,7 @@ A successful render means the final path exists and was published atomically. A
190
239
 
191
240
  ## doctor
192
241
 
193
- Doctor reports findings rather than throwing for environment problems. `ok: true` means the inspection completed; `healthy` says whether required checks, manifest integrity, explicitly supplied models, and integration compatibility passed.
242
+ Doctor reports findings rather than throwing for environment problems. `ok: true` means the inspection completed; `healthy` says whether required checks, manifest integrity, explicitly supplied models, and integration compatibility passed. `ffmpeg-opus-encoder` and `ffmpeg-video-encoders` are optional: the video check probes each supported encoder with a one-frame encode and lists any that FFmpeg advertises but cannot open (an old driver, for example), and neither check affects `healthy`.
194
243
 
195
244
  ```json
196
245
  {
@@ -201,6 +250,8 @@ Doctor reports findings rather than throwing for environment problems. `ok: true
201
250
  { "name": "node", "required": true, "status": "ok", "detail": "v22.12.0" },
202
251
  { "name": "ffmpeg", "required": true, "status": "ok", "detail": "ffmpeg version ..." },
203
252
  { "name": "ffmpeg-encoders", "required": true, "status": "ok", "detail": "libx264 and aac are available." },
253
+ { "name": "ffmpeg-opus-encoder", "required": false, "status": "ok", "detail": "libopus is available for VP9/WebM audio." },
254
+ { "name": "ffmpeg-video-encoders", "required": false, "status": "error", "detail": "Unavailable: h264_nvenc (Driver does not support the required nvenc API version); hevc_nvenc (Driver does not support the required nvenc API version). Available: libx264, libx265, libvpx-vp9, h264_qsv, hevc_qsv." },
204
255
  { "name": "ffprobe", "required": true, "status": "ok", "detail": "ffprobe version ..." },
205
256
  { "name": "chromium", "required": true, "status": "ok", "detail": "/path/to/chrome" },
206
257
  { "name": "manifest", "required": false, "status": "ok", "detail": "2 artifacts verified." }
@@ -72,10 +72,10 @@ Apply that value to text styling inside the controller's `update({ frame })`. Do
72
72
 
73
73
  ```sh
74
74
  npx frameyard check karaoke --json
75
- npx frameyard still karaoke --frame 120 --out karaoke-120.png --json
75
+ npx frameyard still karaoke --frame 118-126 --out karaoke-frames --json
76
76
  ```
77
77
 
78
- Inspect several frames across a word boundary. A single still cannot prove that highlighting is synchronized.
78
+ Inspect several frames across a word boundary in one batch. A single still cannot prove that highlighting is synchronized.
79
79
 
80
80
  ## Derived exports
81
81
 
@@ -16,9 +16,9 @@ export default {
16
16
  };
17
17
  ```
18
18
 
19
- - `format` fixes width, height, and fps for every timeline. It is required when the project lists timelines.
19
+ - `format` fixes width, height, and fps for every timeline and fills those values for compositions that omit them; a declared value always wins. It is required when the project lists timelines.
20
20
  - Composition and timeline IDs share one namespace and must be unique.
21
- - A composition used in a timeline must match the project fps exactly. A different size is fitted by the clip.
21
+ - An animated composition that declares an `fps` must match the project fps exactly; an undeclared `fps` inherits it. A different size is fitted by the clip. A static composition has no fps and skips the frame-rate comparison.
22
22
  - Composition-only projects remain valid without `format` or `timelines`.
23
23
 
24
24
  ## Timeline document
@@ -41,6 +41,7 @@ export default {
41
41
  "duration": 150,
42
42
  "sourceIn": 0,
43
43
  "props": { "title": "Hello" },
44
+ "blend": "screen",
44
45
  "transform": { "fit": "contain", "x": 0, "y": 0, "scale": 1, "rotation": 0, "opacity": 1 },
45
46
  "audio": { "muted": false, "gain": 1 }
46
47
  },
@@ -68,13 +69,13 @@ export default {
68
69
  - `tracks` and `clips` are required. Track order is bottom-to-top for video and irrelevant for audio.
69
70
  - `background` is an optional color; the final video composites over black.
70
71
  - `durationInFrames` is optional. The duration is derived from the latest clip end unless declared; a declared duration may extend but not cut clips.
71
- - Unknown fields are errors. No transitions, effects, keyframes, or track solo/visibility in 0.6.
72
+ - Unknown fields are errors. Blend modes are static per clip; no transitions, keyframes, other effects, or track solo/visibility.
72
73
 
73
74
  ## Sources
74
75
 
75
76
  | `source.kind` | Fields | Notes |
76
77
  | --- | --- | --- |
77
- | `composition` | `id` | Project composition ID. The clip may set `props`. |
78
+ | `composition` | `id` | Project composition ID. The clip may set `props`. A static composition behaves as an image source. |
78
79
  | `timeline` | `id` | Nested timeline ID. Rendered as a group. |
79
80
  | `video` | `src` | Local MP4/WebM, project-relative. |
80
81
  | `image` | `src` | Local PNG/JPEG/WebP. `duration` is required. |
@@ -85,9 +86,10 @@ Media paths resolve from the project root. No remote URLs.
85
86
  ## Time and trims
86
87
 
87
88
  - `start` is the timeline frame where the clip begins.
88
- - `sourceIn` is the first source frame. It defaults to `0` and must be `0` for images.
89
- - `duration` is the number of timeline frames. It is optional for compositions, nested timelines, video, and audio; it defaults to the rest of the source. Images require it.
90
- - A clip that exceeds its source is an error.
89
+ - `sourceIn` is the first source frame. It defaults to `0` and must be `0` for images and static compositions.
90
+ - `duration` is the number of timeline frames. It is optional for animated compositions, nested timelines, video, and audio; it defaults to the rest of the source. Images and static compositions require it.
91
+ - A clip that exceeds its source is an error. Static compositions have no intrinsic duration, so the clip duration is the only bound.
92
+ - A static composition clip is captured once per props variant as a transparent PNG and reused across segments. A clip whose source is static and whose `audio` settings are non-default receives an `AUDIO_WARNING` because there is no source audio.
91
93
  - Clips on one track cannot overlap. Touching endpoints are allowed. Use separate tracks for overlays.
92
94
 
93
95
  ## Presentation
@@ -100,35 +102,38 @@ Visual clips accept a static transform:
100
102
  - `scale`: multiplier after fitting to the project frame; an alternative to `width`/`height`.
101
103
  - `rotation`: degrees around the center.
102
104
  - `opacity`: 0–1.
105
+ - `blend`: CSS `mix-blend-mode`, defaulting to `normal`. Supported values are `normal`, `multiply`, `screen`, `overlay`, `darken`, `lighten`, `color-dodge`, `color-burn`, `hard-light`, `soft-light`, `difference`, and `exclusion`. `hue`, `saturation`, `color`, and `luminosity` are rejected.
103
106
 
104
- No keyframes or animated transforms in 0.6.
107
+ A blend mode composites the clip against everything below it in the stage, including the timeline background, and only where the clip's source alpha is non-zero. A composition or nested timeline that paints an opaque background blocks the lower layers; source alpha is what lets a blend reach them. A top-level timeline with no declared background renders over black; a nested or embedded timeline stays transparent. Blend modes are static; there are no keyframes or animated transforms.
105
108
 
106
109
  ## Audio
107
110
 
108
- - Composition, video, and nested-timeline clips contribute audio automatically unless `audio.muted` is true.
111
+ - Animated composition, video, and nested-timeline clips contribute audio automatically unless `audio.muted` is true. Static compositions contribute no audio; put audio on a timeline track.
109
112
  - Timeline audio clips live on audio tracks. Audio tracks support `gain` and `muted`.
110
113
  - `audio.gain` on a clip is 0–1 and multiplies the source audio.
111
114
  - There are no linked A/V pairs. For a J/L cut, mute the visual clip and add an audio clip with a different `sourceIn`.
112
- - Audio is mixed once for the whole timeline and muxed into the final MP4.
115
+ - Audio is mixed once for the whole timeline and muxed into the final output.
113
116
 
114
117
  ## Nested timelines
115
118
 
116
- A nested timeline clip behaves as one layer: its internal tracks are composited first, then the parent clip's opacity and transform apply to the whole group. Nested audio is included. Cycles are rejected statically. Timeline clips do not override props of compositions inside a nested timeline in 0.6.
119
+ A nested timeline clip behaves as one layer: its internal tracks are composited first, then the parent clip's opacity, transform, and blend mode apply to the whole group. A blend mode inside the nested timeline sees only that timeline's own backdrop; it never sees the parent's layers. A declared nested background is part of the group, so a transparent nested timeline has no backdrop to blend against beyond its own layers. Nested audio is included. Cycles are rejected statically. Timeline clips do not override props of compositions inside a nested timeline.
117
120
 
118
121
  ## Commands
119
122
 
120
123
  ```sh
121
124
  npx frameyard check main --json
122
125
  npx frameyard still main --frame 42 --out main-42.png --json
126
+ npx frameyard still main --frame 0,118-126 --out out/stills --json
123
127
  npx frameyard render main --props props/main.json --workers 8 --scratch /path/to/scratch --json
124
128
  ```
125
129
 
126
130
  - `--props` on a timeline is a JSON object keyed by top-level composition clip ID; values merge over the clip's `props`.
127
- - `--workers` is 1–16. The default is 8.
131
+ - `--workers` is 1–16. The default is 8. With `--encoder nvenc` it is capped at 8 concurrent encode sessions and a larger value reports an `ENCODER_WARNING`.
128
132
  - `--scratch` selects the FFV1 intermediate directory. Use a disk with enough space; intermediates are cleaned after the render.
133
+ - `--container <mp4|mov|mkv|webm>` selects the delivery container (default `mp4`); `--codec <h264|hevc|vp9>` selects the codec, and `--encoder <auto|software|nvenc|qsv>` selects how it is encoded. MP4 and MOV hold H.264/HEVC with AAC; MKV holds all three; WebM holds VP9 with Opus and defaults to VP9. `auto` is the default and probes NVENC, then Quick Sync, then software; `software` forces `libx264`, `libx265`, or `libvpx-vp9`. FFV1 intermediates stay lossless and software-encoded, HEVC keeps the `hvc1` tag in MP4/MOV, and the output extension must match the container.
129
134
  - `--concurrency` is composition-only.
130
135
  - `render` defaults to `out/<timeline-id>.mp4`.
131
- - `check` evaluates every timeline frame and composition frame. `still` captures one frame through the same clip capture and composite path.
136
+ - `check` evaluates every timeline frame and every animated composition frame; a static composition layer is inspected once and not seeked per frame. `still` captures one or more requested frames through the same clip capture and composite path; a multi-frame still resolves the timeline, starts one server, and reuses one scratch directory. The `--frame` spec and 32-frame cap are the same as for compositions.
132
137
  - `npx frameyard dev` lists timelines in the preview; the timeline view has a program monitor, read-only tracks and clips, playhead, playback, audio controls, and diagnostics.
133
138
 
134
139
  ## Determinism and performance
@@ -136,6 +141,7 @@ npx frameyard render main --props props/main.json --workers 8 --scratch /path/to
136
141
  - A timeline frame derives only from its active clips, their composition frames, props, assets, and the render plan.
137
142
  - Composition frames are captured with Chromium; transparent compositions preserve alpha.
138
143
  - Timeline production captures active composition and nested-timeline clips to lossless FFV1, composites segments with FFmpeg, concatenates the ordered segments, and mixes audio once.
139
- - Preview composites in Chromium; production composites with FFmpeg. Small scaling, alpha, and video color differences are expected. Parity is guaranteed at clip capture, not at final encoded pixels.
140
- - Final MP4 bytes are not a reproducibility guarantee.
144
+ - Preview composites in Chromium with CSS `mix-blend-mode`; production composites with FFmpeg's `blend` and `maskedmerge`. CSS is the reference; small scaling, alpha, color, and blend-math differences are expected. Parity is guaranteed at clip capture, not at final encoded pixels.
145
+ - Alpha-correct blending costs roughly 1.7× per blended 1080p layer in the production composite. Budget for it when a timeline stacks several blended layers.
146
+ - Final output bytes are not a reproducibility guarantee.
141
147
  - Long timelines are segmented and bounded. The reference target and measured worst cases are documented in `docs/adr/0012-render-timelines-with-segmented-lossless-clip-intermediates.md`.
@@ -1,13 +1,30 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
- import { existsSync } from "node:fs";
3
+ import { existsSync, readFileSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
7
7
  const scriptDir = path.dirname(fileURLToPath(import.meta.url));
8
- const localCli = path.resolve(scriptDir, "../../../dist/cli.js");
8
+ const packageRoot = path.resolve(scriptDir, "../../..");
9
9
  const configured = process.env.FRAMEYARD_CLI;
10
10
 
11
+ // Only prefer a sibling build inside the frameyard package itself. A copied
12
+ // skill sits three levels below its host directory, which may hold an
13
+ // unrelated dist/cli.js.
14
+ function packageCli() {
15
+ const manifestPath = path.join(packageRoot, "package.json");
16
+ const cliPath = path.join(packageRoot, "dist", "cli.js");
17
+ if (!existsSync(manifestPath) || !existsSync(cliPath)) return undefined;
18
+ try {
19
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
20
+ return manifest.name === "frameyard" ? cliPath : undefined;
21
+ } catch {
22
+ return undefined;
23
+ }
24
+ }
25
+
26
+ const localCli = packageCli();
27
+
11
28
  let command;
12
29
  let args;
13
30
  if (configured) {
@@ -22,7 +39,7 @@ if (configured) {
22
39
  command = configured;
23
40
  args = process.argv.slice(2);
24
41
  }
25
- } else if (existsSync(localCli)) {
42
+ } else if (localCli) {
26
43
  command = process.execPath;
27
44
  args = [localCli, ...process.argv.slice(2)];
28
45
  } else {