frameyard 0.9.0 → 0.10.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 (62) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +1 -1
  3. package/dist/preview/fonts/LICENSE-figtree.txt +93 -0
  4. package/dist/preview/fonts/LICENSE-space-mono.txt +93 -0
  5. package/dist/preview/fonts/README.md +23 -0
  6. package/dist/preview/fonts/figtree-latin-wght-normal.woff2 +0 -0
  7. package/dist/preview/fonts/space-mono-latin-400-normal.woff2 +0 -0
  8. package/dist/preview/fonts/space-mono-latin-700-normal.woff2 +0 -0
  9. package/dist/preview/main.js +5 -2
  10. package/dist/preview/main.js.map +1 -1
  11. package/dist/preview/timeline-main.js +41 -23
  12. package/dist/preview/timeline-main.js.map +1 -1
  13. package/dist/renderer/audio.d.ts +6 -0
  14. package/dist/renderer/audio.d.ts.map +1 -1
  15. package/dist/renderer/audio.js +3 -1
  16. package/dist/renderer/audio.js.map +1 -1
  17. package/dist/renderer/browser.d.ts.map +1 -1
  18. package/dist/renderer/browser.js +13 -0
  19. package/dist/renderer/browser.js.map +1 -1
  20. package/dist/renderer/concurrency.d.ts +6 -0
  21. package/dist/renderer/concurrency.d.ts.map +1 -0
  22. package/dist/renderer/concurrency.js +27 -0
  23. package/dist/renderer/concurrency.js.map +1 -0
  24. package/dist/renderer/ffmpeg.d.ts +62 -3
  25. package/dist/renderer/ffmpeg.d.ts.map +1 -1
  26. package/dist/renderer/ffmpeg.js +326 -45
  27. package/dist/renderer/ffmpeg.js.map +1 -1
  28. package/dist/renderer/session-pool.d.ts +28 -0
  29. package/dist/renderer/session-pool.d.ts.map +1 -0
  30. package/dist/renderer/session-pool.js +91 -0
  31. package/dist/renderer/session-pool.js.map +1 -0
  32. package/dist/renderer/timeline.d.ts.map +1 -1
  33. package/dist/renderer/timeline.js +518 -38
  34. package/dist/renderer/timeline.js.map +1 -1
  35. package/dist/renderer/timings.d.ts +13 -0
  36. package/dist/renderer/timings.d.ts.map +1 -0
  37. package/dist/renderer/timings.js +29 -0
  38. package/dist/renderer/timings.js.map +1 -0
  39. package/dist/server/preview-html.d.ts.map +1 -1
  40. package/dist/server/preview-html.js +74 -188
  41. package/dist/server/preview-html.js.map +1 -1
  42. package/dist/server/preview-style.d.ts +11 -0
  43. package/dist/server/preview-style.d.ts.map +1 -0
  44. package/dist/server/preview-style.js +298 -0
  45. package/dist/server/preview-style.js.map +1 -0
  46. package/dist/server/timeline-preview-html.d.ts.map +1 -1
  47. package/dist/server/timeline-preview-html.js +140 -164
  48. package/dist/server/timeline-preview-html.js.map +1 -1
  49. package/dist/version.d.ts +1 -1
  50. package/dist/version.d.ts.map +1 -1
  51. package/dist/version.js +1 -1
  52. package/dist/version.js.map +1 -1
  53. package/docs/adr/0012-render-timelines-with-segmented-lossless-clip-intermediates.md +1 -1
  54. package/docs/adr/0024-stream-composition-captures-and-skip-trivial-composites.md +86 -0
  55. package/docs/adr/0025-capture-timeline-layers-in-parallel-with-a-global-limit.md +67 -0
  56. package/docs/adr/0026-reuse-loaded-composition-sessions-across-segments.md +70 -0
  57. package/docs/adr/0027-encode-two-full-frame-layers-through-dual-pipes.md +76 -0
  58. package/docs/adr/0028-encode-full-frame-video-segments-directly.md +65 -0
  59. package/docs/preview-testing.md +8 -0
  60. package/docs/v0.x-design.md +3 -3
  61. package/package.json +2 -2
  62. package/skills/frameyard/SKILL.md +1 -1
@@ -1,3 +1,4 @@
1
+ import { PREVIEW_CSS } from "./preview-style.js";
1
2
  function escapeHtml(value) {
2
3
  return value.replace(/[&<>"']/g, (character) => {
3
4
  switch (character) {
@@ -20,6 +21,121 @@ const IMPORT_MAP = JSON.stringify({
20
21
  "frameyard/runtime": "/__frameyard/framework/runtime/index.js",
21
22
  },
22
23
  });
24
+ const PAGE_CSS = `
25
+ #app {
26
+ display: grid;
27
+ grid-template-rows: 54px minmax(0, 1fr) 216px 58px;
28
+ height: 100vh;
29
+ }
30
+ #viewport { min-height: 0; }
31
+ #stage-wrap { overflow: hidden; }
32
+ #stage {
33
+ position: relative;
34
+ width: 100%; height: 100%;
35
+ transform-origin: top left;
36
+ overflow: hidden;
37
+ background: #000;
38
+ isolation: isolate;
39
+ }
40
+ .timeline-layer { position: absolute; left: 50%; top: 50%; transform-origin: center; }
41
+ .timeline-layer iframe, .timeline-layer video, .timeline-layer img { display: block; border: 0; }
42
+
43
+ #timeline {
44
+ position: relative;
45
+ min-height: 0;
46
+ border-top: 1px solid var(--line);
47
+ background: var(--panel);
48
+ overflow: auto;
49
+ padding: 12px 18px 16px;
50
+ }
51
+ .ruler {
52
+ display: grid;
53
+ grid-template-columns: 96px minmax(0, 1fr);
54
+ gap: 10px;
55
+ height: 20px;
56
+ }
57
+ .ruler-scale { position: relative; border-bottom: 1px solid var(--line); }
58
+ .ruler-scale::before {
59
+ content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 7px;
60
+ background-image: linear-gradient(90deg, var(--line-strong) 0 1px, transparent 1px);
61
+ background-size: 10% 100%;
62
+ opacity: 0.55;
63
+ pointer-events: none;
64
+ }
65
+ .ruler-tick {
66
+ position: absolute; top: -1px; transform: translateX(-50%);
67
+ font-family: var(--mono); font-size: 9px; color: var(--muted);
68
+ letter-spacing: 0.06em; pointer-events: none;
69
+ }
70
+ .ruler-tick:first-child { transform: none; }
71
+ .ruler-tick:last-child { transform: translateX(-100%); }
72
+ #timeline-inner { position: relative; }
73
+ #tracks { position: relative; display: flex; flex-direction: column; }
74
+ .track { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 10px; min-height: 30px; }
75
+ .track-label {
76
+ display: flex; align-items: center; justify-content: flex-end;
77
+ font-size: 9px; white-space: nowrap;
78
+ }
79
+ .lane {
80
+ position: relative; border: 1px solid var(--line);
81
+ background: var(--ink); overflow: hidden;
82
+ }
83
+ .clip {
84
+ position: absolute; top: 3px; bottom: 3px; border-radius: 1px;
85
+ background: linear-gradient(180deg, rgba(74, 96, 118, 0.5), rgba(38, 52, 66, 0.55));
86
+ border: 1px solid rgba(140, 170, 195, 0.38); color: #dce8f5;
87
+ font: 10px/1 var(--mono); padding: 6px 8px; overflow: visible;
88
+ white-space: nowrap;
89
+ }
90
+ .clip-label { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
91
+ .clip.composition {
92
+ background: linear-gradient(180deg, rgba(184, 243, 107, 0.20), rgba(184, 243, 107, 0.07));
93
+ border-color: rgba(184, 243, 107, 0.45); color: var(--paper);
94
+ }
95
+ .clip.audio {
96
+ background: linear-gradient(180deg, rgba(232, 199, 106, 0.20), rgba(232, 199, 106, 0.06));
97
+ border-color: rgba(232, 199, 106, 0.42); color: #f4e7c4;
98
+ }
99
+ .clip.nested {
100
+ background: linear-gradient(180deg, rgba(168, 130, 200, 0.22), rgba(168, 130, 200, 0.07));
101
+ border-color: rgba(168, 130, 200, 0.45); color: #e8d9f5;
102
+ }
103
+ .keyframe-marker {
104
+ position: absolute; top: 50%; width: 7px; height: 7px;
105
+ background: var(--accent); border: 1px solid var(--ink); border-radius: 1px;
106
+ transform: translate(-50%, -50%) rotate(45deg); cursor: pointer; z-index: 2;
107
+ }
108
+ .keyframe-marker:hover { background: #ffffff; }
109
+ .transition {
110
+ position: absolute; top: 6px; bottom: 6px; border-radius: 1px; z-index: 1;
111
+ background: repeating-linear-gradient(
112
+ 45deg,
113
+ rgba(184, 243, 107, 0.22) 0 4px,
114
+ rgba(184, 243, 107, 0.06) 4px 8px
115
+ );
116
+ border: 1px solid rgba(184, 243, 107, 0.5); pointer-events: none;
117
+ }
118
+ .playhead {
119
+ position: absolute; top: 0; bottom: 0; width: 1px;
120
+ left: calc(106px + (100% - 106px) * var(--playhead-factor, 0));
121
+ background: var(--accent);
122
+ box-shadow: 0 0 10px rgba(184, 243, 107, 0.7);
123
+ pointer-events: none;
124
+ z-index: 3;
125
+ }
126
+ .playhead::before {
127
+ content: ""; position: absolute; top: 0; left: -3px;
128
+ width: 7px; height: 7px; background: var(--accent);
129
+ transform: rotate(45deg);
130
+ }
131
+ #timeline #error, #timeline #diagnostics { margin-top: 10px; }
132
+
133
+ @media (max-width: 760px) {
134
+ #app { grid-template-rows: 54px minmax(0, 1fr) 180px auto; }
135
+ footer { flex-wrap: wrap; padding: 9px 14px; }
136
+ .meta { display: none; }
137
+ }
138
+ `;
23
139
  export function renderTimelinePreviewHtml(timelineId, options = {}) {
24
140
  const id = escapeHtml(timelineId);
25
141
  if (options.embed) {
@@ -50,179 +166,39 @@ export function renderTimelinePreviewHtml(timelineId, options = {}) {
50
166
  <meta name="viewport" content="width=device-width, initial-scale=1">
51
167
  <title>Frameyard timeline ${id}</title>
52
168
  <style>
53
- :root {
54
- color-scheme: dark;
55
- --bg: #0b0d10;
56
- --panel: #11151a;
57
- --panel-2: #171c23;
58
- --line: #252c35;
59
- --text: #e8edf3;
60
- --muted: #8a97a6;
61
- --accent: #b8f36b;
62
- --accent-ink: #101408;
63
- --danger: #ff8a8a;
64
- --mono: "SFMono-Regular", ui-monospace, "Cascadia Code", "Liberation Mono", monospace;
65
- --sans: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
66
- }
67
- * { box-sizing: border-box; }
68
- html, body { height: 100%; }
69
- body {
70
- margin: 0;
71
- background: var(--bg);
72
- color: var(--text);
73
- font-family: var(--sans);
74
- font-size: 13px;
75
- overflow: hidden;
76
- }
77
- #app {
78
- display: grid;
79
- grid-template-rows: 54px minmax(0, 1fr) 210px 58px;
80
- height: 100vh;
81
- }
82
- header {
83
- display: flex;
84
- align-items: center;
85
- gap: 18px;
86
- padding: 0 18px;
87
- border-bottom: 1px solid var(--line);
88
- background: linear-gradient(180deg, #11151a, #0d1014);
89
- }
90
- .brand { display: flex; align-items: center; gap: 9px; font-weight: 650; }
91
- .mark {
92
- width: 17px; height: 17px; border-radius: 4px;
93
- background: linear-gradient(135deg, var(--accent), #5fd6ff);
94
- box-shadow: 0 0 18px rgba(184, 243, 107, 0.3);
95
- }
96
- .tag { color: var(--muted); font-family: var(--mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; }
97
- a { color: var(--accent); text-decoration: none; }
98
- a:hover { text-decoration: underline; }
99
- .meta { margin-left: auto; color: var(--muted); font-family: var(--mono); font-size: 11px; }
100
- #viewport {
101
- min-height: 0;
102
- display: grid;
103
- place-items: center;
104
- padding: 20px;
105
- overflow: hidden;
106
- background:
107
- radial-gradient(circle at 50% 20%, #141b24 0, transparent 55%),
108
- #080a0d;
109
- }
110
- #stage-wrap {
111
- position: relative;
112
- overflow: hidden;
113
- box-shadow: 0 30px 90px rgba(0, 0, 0, 0.55);
114
- background: #000;
115
- }
116
- #stage {
117
- position: relative;
118
- transform-origin: top left;
119
- overflow: hidden;
120
- background: #000;
121
- isolation: isolate;
122
- }
123
- .timeline-layer { position: absolute; left: 50%; top: 50%; transform-origin: center; }
124
- .timeline-layer iframe, .timeline-layer video, .timeline-layer img { display: block; border: 0; }
125
- #timeline {
126
- position: relative;
127
- min-height: 0;
128
- border-top: 1px solid var(--line);
129
- background: var(--panel);
130
- overflow: auto;
131
- padding: 10px 16px 16px;
132
- }
133
- #tracks { position: relative; display: flex; flex-direction: column; gap: 6px; }
134
- .track { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 8px; min-height: 30px; }
135
- .track-label {
136
- display: flex; align-items: center; justify-content: flex-end;
137
- color: var(--muted); font-family: var(--mono); font-size: 10px;
138
- text-transform: uppercase; letter-spacing: 0.06em;
139
- }
140
- .lane {
141
- position: relative; border: 1px solid var(--line); border-radius: 6px;
142
- background: #0d1116; overflow: hidden;
143
- }
144
- .clip {
145
- position: absolute; top: 3px; bottom: 3px; border-radius: 4px;
146
- background: linear-gradient(180deg, #2a4058, #1d2c3d);
147
- border: 1px solid #3b5672; color: #dce8f5;
148
- font: 10px/1 var(--mono); padding: 6px 7px; overflow: visible;
149
- white-space: nowrap;
150
- }
151
- .clip-label { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
152
- .keyframe-marker {
153
- position: absolute; top: 50%; width: 6px; height: 6px;
154
- background: var(--accent); border: 1px solid #0d1116; border-radius: 1px;
155
- transform: translate(-50%, -50%) rotate(45deg); cursor: pointer; z-index: 2;
156
- }
157
- .keyframe-marker:hover { background: #ffffff; }
158
- .clip.composition { background: linear-gradient(180deg, #314a33, #203120); border-color: #4d704d; }
159
- .clip.audio { background: linear-gradient(180deg, #4a3b22, #302516); border-color: #6e5631; }
160
- .clip.nested { background: linear-gradient(180deg, #443152, #2c2036); border-color: #63477a; }
161
- .transition {
162
- position: absolute; top: 8px; bottom: 8px; border-radius: 4px; z-index: 1;
163
- background: repeating-linear-gradient(
164
- 45deg,
165
- rgba(184, 243, 107, 0.24) 0 4px,
166
- rgba(184, 243, 107, 0.08) 4px 8px
167
- );
168
- border: 1px solid rgba(184, 243, 107, 0.55); pointer-events: none;
169
- }
170
- #playhead {
171
- position: absolute; top: 0; bottom: 0; width: 1px; background: var(--accent);
172
- box-shadow: 0 0 12px rgba(184, 243, 107, 0.7); pointer-events: none;
173
- }
174
- #error, #diagnostics {
175
- margin-top: 10px; border: 1px solid #4b3030; border-radius: 6px;
176
- padding: 9px 11px; background: #1b1111; color: var(--danger);
177
- font-family: var(--mono); font-size: 11px; white-space: pre-wrap;
178
- }
179
- #diagnostics { border-color: var(--line); background: var(--panel-2); color: var(--muted); }
180
- footer {
181
- display: flex; align-items: center; gap: 12px; padding: 0 18px;
182
- border-top: 1px solid var(--line); background: var(--panel);
183
- }
184
- button {
185
- border: 1px solid var(--line); background: var(--panel-2); color: var(--text);
186
- border-radius: 6px; padding: 8px 12px; cursor: pointer;
187
- }
188
- button:hover { border-color: #3a4654; }
189
- .step {
190
- width: 32px; height: 32px; padding: 0; border-radius: 6px;
191
- color: var(--muted); font-size: 18px; line-height: 1;
192
- }
193
- .jump {
194
- height: 32px; padding: 0 8px; border-radius: 6px;
195
- color: var(--muted); font: 10px var(--mono);
196
- }
197
- .step:hover:not(:disabled), .jump:hover:not(:disabled) { color: var(--text); }
198
- .play {
199
- width: 38px; height: 38px; border-radius: 50%; border: 0;
200
- background: var(--accent); color: var(--accent-ink);
201
- font-size: 15px; font-weight: 700;
202
- }
203
- .scrub { flex: 1; accent-color: var(--accent); }
204
- .readout { min-width: 150px; text-align: right; color: var(--muted); font-family: var(--mono); font-size: 11px; }
205
- .readout b { color: var(--text); font-weight: 600; }
206
- .audio { display: flex; align-items: center; gap: 8px; color: var(--muted); font-family: var(--mono); font-size: 11px; }
207
- .audio input[type="range"] { width: 80px; accent-color: var(--accent); }
208
- .hint { color: var(--muted); font-family: var(--mono); font-size: 10px; }
169
+ ${PREVIEW_CSS}
170
+ ${PAGE_CSS}
209
171
  </style>
210
172
  <script type="importmap" data-fy-host>${IMPORT_MAP}</script>
211
173
  </head>
212
174
  <body data-timeline-id="${id}" data-embed="0">
213
175
  <div id="app">
214
176
  <header>
215
- <div class="brand"><span class="mark"></span>frameyard <span class="tag">timeline</span></div>
216
- <a href="/__frameyard/">← compositions</a>
217
- <span id="title" class="hint"></span>
177
+ <div class="brand">
178
+ <span class="mark" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
179
+ <span class="wordmark">frameyard</span>
180
+ <span class="slash">/</span>
181
+ <span class="tag">timeline</span>
182
+ </div>
183
+ <span class="topbar-rule"></span>
184
+ <a href="/__frameyard/">&larr; compositions</a>
185
+ <span id="title" class="title"></span>
218
186
  <div id="meta" class="meta"></div>
219
187
  </header>
220
188
  <main id="viewport">
221
- <div id="stage-wrap"><div id="stage"></div></div>
189
+ <div id="stage-wrap">
190
+ <div id="stage"></div>
191
+ </div>
222
192
  </main>
223
193
  <section id="timeline">
224
- <div id="tracks"></div>
225
- <div id="playhead"></div>
194
+ <div id="timeline-inner">
195
+ <div class="ruler">
196
+ <span class="ruler-spacer" aria-hidden="true"></span>
197
+ <div id="ruler-scale" class="ruler-scale"></div>
198
+ </div>
199
+ <div id="tracks"></div>
200
+ <div id="playhead" class="playhead" aria-hidden="true"></div>
201
+ </div>
226
202
  <div id="error" role="alert" hidden></div>
227
203
  <div id="diagnostics" hidden></div>
228
204
  </section>
@@ -232,8 +208,8 @@ export function renderTimelinePreviewHtml(timelineId, options = {}) {
232
208
  <button id="play-button" class="play" type="button" aria-label="Play">&#9654;</button>
233
209
  <button id="step-forward" class="step" type="button" aria-label="Next frame" aria-keyshortcuts="ArrowRight" title="Next frame (→)">&#8250;</button>
234
210
  <button id="jump-forward" class="jump" type="button" aria-label="Forward one second" aria-keyshortcuts="Shift+ArrowRight" title="Forward one second (Shift+→)">+1s</button>
235
- <input id="scrub" class="scrub" type="range" min="0" max="0" value="0" step="1" aria-label="Timeline frame">
236
- <div class="readout"><b id="frame">0</b> / <span id="duration">0</span> f <span id="clock">0.00s</span></div>
211
+ <div class="scrub-wrap"><input id="scrub" class="scrub" type="range" min="0" max="0" value="0" step="1" aria-label="Timeline frame"></div>
212
+ <div class="readout"><span class="readout-label">frame</span><span id="frame">0</span><span class="dim">/</span><span id="duration">0</span><span class="dim">f</span><span class="dim">·</span><span id="clock" class="clock">0.00s</span></div>
237
213
  <label class="audio"><input id="mute" type="checkbox"> mute <input id="volume" type="range" min="0" max="1" step="0.01" value="0.8" aria-label="Audio volume"></label>
238
214
  </footer>
239
215
  </div>
@@ -1 +1 @@
1
- {"version":3,"file":"timeline-preview-html.js","sourceRoot":"","sources":["../../src/server/timeline-preview-html.ts"],"names":[],"mappings":"AAAA,SAAS,UAAU,CAAC,KAAa;IAC/B,OAAO,KAAK,CAAC,OAAO,CAAC,UAAU,EAAE,CAAC,SAAS,EAAE,EAAE;QAC7C,QAAQ,SAAS,EAAE,CAAC;YAClB,KAAK,GAAG;gBACN,OAAO,OAAO,CAAC;YACjB,KAAK,GAAG;gBACN,OAAO,MAAM,CAAC;YAChB,KAAK,GAAG;gBACN,OAAO,MAAM,CAAC;YAChB,KAAK,GAAG;gBACN,OAAO,QAAQ,CAAC;YAClB;gBACE,OAAO,OAAO,CAAC;QACnB,CAAC;IACH,CAAC,CAAC,CAAC;AACL,CAAC;AAED,MAAM,UAAU,GAAG,IAAI,CAAC,SAAS,CAAC;IAChC,OAAO,EAAE;QACP,SAAS,EAAE,iCAAiC;QAC5C,mBAAmB,EAAE,yCAAyC;KAC/D;CACF,CAAC,CAAC;AAEH,MAAM,UAAU,yBAAyB,CAAC,UAAkB,EAAE,OAAO,GAAwB,EAAE;IAC7F,MAAM,EAAE,GAAG,UAAU,CAAC,UAAU,CAAC,CAAC;IAClC,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;QAClB,OAAO;;;;;gCAKqB,EAAE;;;;;;;4CAOU,UAAU;;4BAE1B,EAAE;;;;QAItB,CAAC;IACP,CAAC;IAED,OAAO;;;;;gCAKuB,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;4CA+JU,UAAU;;4BAE1B,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QA8BtB,CAAC;AACT,CAAC"}
1
+ {"version":3,"file":"timeline-preview-html.js","sourceRoot":"","sources":["../../src/server/timeline-preview-html.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEjD,SAAS,UAAU,CAAC,KAAa;IAC/B,OAAO,KAAK,CAAC,OAAO,CAAC,UAAU,EAAE,CAAC,SAAS,EAAE,EAAE;QAC7C,QAAQ,SAAS,EAAE,CAAC;YAClB,KAAK,GAAG;gBACN,OAAO,OAAO,CAAC;YACjB,KAAK,GAAG;gBACN,OAAO,MAAM,CAAC;YAChB,KAAK,GAAG;gBACN,OAAO,MAAM,CAAC;YAChB,KAAK,GAAG;gBACN,OAAO,QAAQ,CAAC;YAClB;gBACE,OAAO,OAAO,CAAC;QACnB,CAAC;IACH,CAAC,CAAC,CAAC;AACL,CAAC;AAED,MAAM,UAAU,GAAG,IAAI,CAAC,SAAS,CAAC;IAChC,OAAO,EAAE;QACP,SAAS,EAAE,iCAAiC;QAC5C,mBAAmB,EAAE,yCAAyC;KAC/D;CACF,CAAC,CAAC;AAEH,MAAM,QAAQ,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkHhB,CAAC;AAEF,MAAM,UAAU,yBAAyB,CAAC,UAAkB,EAAE,OAAO,GAAwB,EAAE;IAC7F,MAAM,EAAE,GAAG,UAAU,CAAC,UAAU,CAAC,CAAC;IAClC,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;QAClB,OAAO;;;;;gCAKqB,EAAE;;;;;;;4CAOU,UAAU;;4BAE1B,EAAE;;;;QAItB,CAAC;IACP,CAAC;IAED,OAAO;;;;;gCAKuB,EAAE;;EAEhC,WAAW;EACX,QAAQ;;4CAEkC,UAAU;;4BAE1B,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QA4CtB,CAAC;AACT,CAAC"}
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.9.0";
1
+ export declare const VERSION = "0.10.0";
2
2
  //# sourceMappingURL=version.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,OAAO,UAAU,CAAC"}
1
+ {"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,OAAO,WAAW,CAAC"}
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = "0.9.0";
1
+ export const VERSION = "0.10.0";
2
2
  //# sourceMappingURL=version.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"version.js","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,OAAO,GAAG,OAAO,CAAC"}
1
+ {"version":3,"file":"version.js","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,OAAO,GAAG,QAAQ,CAAC"}
@@ -1,6 +1,6 @@
1
1
  # Render timelines with segmented lossless clip intermediates
2
2
 
3
- **Status:** Accepted
3
+ **Status:** Accepted. Extended by [ADR-0024](./0024-stream-composition-captures-and-skip-trivial-composites.md): captures stream into FFV1, and single full-frame compositions skip the intermediate and composite.
4
4
  **Date:** 2026-10-31
5
5
 
6
6
  Timeline rendering will be segmented and parallel. For each segment, Chromium renders the active composition and nested-timeline clips to lossless FFV1 intermediates; FFmpeg decodes media, applies the clip's fit and static transform, composites the segment, encodes H.264, and the ordered segments are concatenated without re-encoding. Audio is mixed once for the whole timeline and muxed at the end. Preview remains a live Chromium timeline host with a bounded pool of active clips; parity is guaranteed at clip capture, not at the final composite.
@@ -0,0 +1,86 @@
1
+ # Stream composition captures and skip trivial composites
2
+
3
+ **Status:** Accepted. Extends [ADR-0012](./0012-render-timelines-with-segmented-lossless-clip-intermediates.md). Extended by [ADR-0025](./0025-capture-timeline-layers-in-parallel-with-a-global-limit.md): independent layers are captured concurrently under a global session limit. Extended by [ADR-0026](./0026-reuse-loaded-composition-sessions-across-segments.md): loaded sessions are reused across segments.
4
+ **Date:** 2026-11-06
5
+
6
+ The production timeline renderer will stream captured PNG buffers directly into
7
+ the lossless FFV1 encoder instead of writing a PNG sequence to scratch first. A
8
+ segment whose visual content is a single full-frame composition with no
9
+ transform, opacity, blend, transition, or keyframes will bypass the FFV1
10
+ intermediate and the FFmpeg composite entirely and encode the capture directly
11
+ into the delivery segment over the timeline background. All other segments keep
12
+ the segmented lossless path.
13
+
14
+ ## Context
15
+
16
+ The 60-minute composition-only gate rendered 108,000 frames in 37.28 minutes
17
+ with four workers. Per-stage worker time was 48.9%
18
+ capture, 23.2% FFV1, 24.7% composite, and 2.8% session setup. The run wrote
19
+ 15.52 GB of PNG intermediates, and the FFV1 stage added 8.64 minutes of wall
20
+ time at four workers. Capture itself was the floor: 92.6% of the capture loop
21
+ was `Page.captureScreenshot` plus PNG encoding.
22
+
23
+ The gate is a one-layer composition workload, so the FFV1 intermediate and the
24
+ composite are identity operations apart from painting the timeline background.
25
+ Streaming alone improved the 60-second gate from 43.27 seconds to 38.76
26
+ seconds, but CPU contention between four concurrent captures and four FFV1
27
+ encoders limited the gain. The direct path removes both stages for qualifying
28
+ segments.
29
+
30
+ ## Decision
31
+
32
+ - `captureCompositionLayer` opens an FFV1 encoder before capture and writes
33
+ each PNG buffer to its stdin. The encoder is bounded with
34
+ `FRAMEYARD_FFV1_THREADS` (default 2; `0` uses FFmpeg's default) so concurrent
35
+ captures retain CPU headroom.
36
+ - `processTimelineSegment` detects a single active composition layer whose
37
+ source dimensions equal the stage, whose transform is the identity, and which
38
+ has no keyframes, blend, transition, or opacity. It also requires an opaque
39
+ timeline background. Such a segment uses `startDirectSegmentEncoder`, streams
40
+ the capture into the delivery codec over a solid background, and skips
41
+ `compositeTimelineSegment`.
42
+ - Static compositions, media layers, nested timelines, transformed or animated
43
+ clips, blends, transitions, and transparent nested backgrounds keep the
44
+ segmented lossless path. Nested timelines continue to use FFV1.
45
+
46
+ ## Considered Options
47
+
48
+ - **Keep the file-based PNG sequence.** Rejected: it wrote 15.52 GB to scratch,
49
+ serialized capture and FFV1, and spent 8.64 minutes of wall time on FFV1 at
50
+ four workers.
51
+ - **Stream FFV1 only.** Accepted as the general path, but insufficient alone:
52
+ the 60-second gate improved by only 10.4% because capture and FFV1 contend for
53
+ CPU. It remains necessary for multi-layer and transformed segments.
54
+ - **Always encode directly and skip the composite.** Rejected: multi-layer,
55
+ blended, keyframed, transformed, and nested segments need the FFmpeg
56
+ compositor.
57
+ - **Use a GPU/Skia compositor.** Rejected for this change: the gate is capture
58
+ bound, and GPU rasterization would not remove screenshot/PNG encoding. It
59
+ would also complicate the capture-pixel reproducibility guarantee.
60
+ - **Parallelize layer capture first.** Deferred for this change: it helps
61
+ multi-layer timelines but not the one-layer long-form gate. The measured
62
+ 60-second two-layer run improved only 5.1% from streaming (62.79 to 59.60
63
+ seconds). ADR 0025 then captured independent layers concurrently and reduced
64
+ that run to 43.78 seconds.
65
+
66
+ ## Consequences
67
+
68
+ - A qualifying direct segment emits `timeline.segment.direct` instead of
69
+ `timeline.segment.composite` in the opt-in stage timings.
70
+ - Direct segments are encoded once, without a lossless FFV1 round trip. The
71
+ reproducibility guarantee remains at clip capture. For the 60-minute gate,
72
+ the direct output is byte-identical to the FFV1-and-composite output
73
+ (SHA-256 `02dab384...b6aab`).
74
+ - Scratch no longer contains per-frame PNG sequences. The 60-minute gate wrote
75
+ no PNG files to disk; the 15.52 GB of PNG buffers went through memory and
76
+ pipes. FFV1 files remain for non-direct segments.
77
+ - The 60-minute gate at eight workers completed in 19.83 minutes, 34% inside the
78
+ 30-minute target. ADR 0026 later reduced it to 18.78 minutes by reusing loaded
79
+ sessions. At four workers the ten-minute gate projects about 24 minutes.
80
+ - Multi-layer segments keep the lossless path and still pay for FFV1 and the
81
+ composite. ADR 0025 captures their independent layers concurrently; the
82
+ two-layer 60-second run improved from 62.79 to 59.60 seconds with streaming
83
+ and to 43.78 seconds with parallel layer capture.
84
+ - The direct path is covered by the timeline rendering suite, including a
85
+ transparent full-frame composition over a non-black timeline background, and
86
+ by the full 108,000-frame gate with an FFprobe frame-count check.
@@ -0,0 +1,67 @@
1
+ # Capture timeline layers in parallel with a global limit
2
+
3
+ **Status:** Accepted. Extends [ADR-0024](./0024-stream-composition-captures-and-skip-trivial-composites.md). Extended by [ADR-0026](./0026-reuse-loaded-composition-sessions-across-segments.md): loaded sessions are reused across segments.
4
+ **Date:** 2026-11-06
5
+
6
+ Segments with more than one visual layer now capture their composition and
7
+ static-image layers concurrently. A FIFO semaphore bounds active capture
8
+ sessions across the whole render, independent of the segment worker count. The
9
+ layer results are reordered before compositing so track order and output pixels
10
+ are unchanged.
11
+
12
+ ## Context
13
+
14
+ The segmented renderer processed `segment.layers` sequentially. A two-layer
15
+ 60-second gate with streaming FFV1 took 59.60 s: the segment time was the sum of
16
+ both captures plus the composite. The same one-layer workload took 43.27 s
17
+ before the direct fast path, and 27.64 s after it. The layer captures are
18
+ independent, so the serial loop was leaving the second composition idle while
19
+ the first encoded.
20
+
21
+ An unbounded `Promise.all` is not safe: eight workers with three layers could
22
+ open twenty-four Chromium sessions and run twenty-four concurrent FFV1
23
+ encoders. The segment worker count already bounds the final segment encoders,
24
+ but it does not bound capture sessions.
25
+
26
+ ## Decision
27
+
28
+ - `createSemaphore(limit)` provides a FIFO capture limit.
29
+ - The default limit is `min(workers * 2, 12)`. `FRAMEYARD_CAPTURE_CONCURRENCY`
30
+ overrides it for local A/B measurements.
31
+ - Every composition and static-composition capture takes a slot for the whole
32
+ session and encoder lifetime. Nested timelines do not hold an outer slot;
33
+ their inner captures take slots from the same semaphore.
34
+ - A segment starts all layer tasks together and uses `Promise.allSettled`.
35
+ It reorders successful results by layer index before compositing and throws
36
+ the first failure after every task has settled, so no capture is left writing
37
+ into scratch after the segment fails.
38
+ - The direct one-layer segment path is unchanged; it takes one slot.
39
+
40
+ ## Considered Options
41
+
42
+ - **Keep the sequential layer loop.** Rejected: it serializes independent
43
+ captures and leaves CPU idle during FFV1.
44
+ - **Use unbounded `Promise.all`.** Rejected: capture sessions multiply with
45
+ workers and layers, causing memory and encoder oversubscription.
46
+ - **Limit only per segment.** Rejected: a per-segment limit still multiplies by
47
+ the worker count, so eight workers with two layers would open sixteen
48
+ sessions.
49
+ - **Reuse sessions instead.** Deferred for this change: a pool removes session
50
+ startup but does not shorten the critical path of a multi-layer segment. ADR
51
+ 0026 adds the pool as a separate optimization.
52
+
53
+ ## Consequences
54
+
55
+ - The two-layer 60-second gate fell from 59.60 s to 43.78 s (−26.5%) with four
56
+ workers; the output is byte-identical to the sequential run.
57
+ - The one-layer 60-minute gate is unaffected because every segment uses the
58
+ direct path.
59
+ - Peak capture sessions are bounded at 12, including eight-worker renders with
60
+ multi-layer segments.
61
+ - Captures that share a static-composition cache key still wait for one slot
62
+ each; the capture itself is deduplicated by the existing promise cache.
63
+ - A failed layer task settles its siblings before the segment throws; the
64
+ scratch directory is then cleaned by the render's existing failure path.
65
+ - The full timeline rendering suite passes with parallel captures, including
66
+ transparent compositions, blends, keyframes, transitions, nested timelines,
67
+ and static layers.
@@ -0,0 +1,70 @@
1
+ # Reuse loaded composition sessions across segments
2
+
3
+ **Status:** Accepted. Extends [ADR-0025](./0025-capture-timeline-layers-in-parallel-with-a-global-limit.md). Extended by [ADR-0027](./0027-encode-two-full-frame-layers-through-dual-pipes.md): two full-frame layers encode through dual image pipes.
4
+ **Date:** 2026-11-06
5
+
6
+ The timeline renderer keeps a bounded pool of loaded composition sessions and
7
+ leases them to segment captures. A session is keyed by composition entry, props,
8
+ and transparent-background mode, and is reused across segments and clips that
9
+ share those inputs. Sessions are discarded on a capture error and all sessions
10
+ are closed when the render ends.
11
+
12
+ ## Context
13
+
14
+ Every composition layer of every segment opened a new Chromium browser, loaded
15
+ the composition, waited for `networkidle` (roughly 450 ms), and closed it. The
16
+ one-layer 60-minute gate opened 360 sessions; a two-layer workload opens 720.
17
+ The 10-minute one-layer gate without a pool took 211.8 s, and its session stage
18
+ was a repeated fixed cost rather than frame work.
19
+
20
+ The host already has the right reuse semantics: `load` restores the pristine DOM
21
+ and mounts the controller once, while `seek` is required to be stateless. A
22
+ loaded session with the same props can seek to any frame of any segment.
23
+
24
+ ## Decision
25
+
26
+ - `CompositionSessionPool` stores idle sessions in a map keyed by
27
+ `entry + props + transparentBackground`.
28
+ - `maxIdle` is `max(2, workers)`. Active sessions are already bounded by the
29
+ capture semaphore; the idle cap bounds memory for workloads with many unique
30
+ compositions.
31
+ - `captureCompositionFrames` leases a session instead of opening one directly.
32
+ A successful capture releases the lease; a capture, diagnostics, or encoder
33
+ error discards it.
34
+ - Nested timelines share the render's pool. Static compositions keep their
35
+ one-shot capture and cache because they are captured once per props variant.
36
+ - The render closes the pool before the preview server in its cleanup path.
37
+ - The session timing event records `reused: true` when the lease came from the
38
+ pool.
39
+
40
+ ## Considered Options
41
+
42
+ - **Remove `networkidle` only.** Rejected as the primary change: it removes a
43
+ fixed wait but does not avoid browser launch, navigation, and host load. It
44
+ also weakens the asset-loading boundary for compositions with stylesheet
45
+ background images. Session reuse amortizes the whole startup cost instead.
46
+ - **Share one browser process without reusing pages.** Rejected: it saves the
47
+ browser launch but still pays navigation, host load, and `networkidle` per
48
+ segment.
49
+ - **Per-worker pools.** Rejected: a shared pool lets any worker reuse an idle
50
+ session and keeps the idle budget independent of worker count.
51
+ - **Unbounded idle caching.** Rejected: compositions with many unique props or
52
+ entries could retain an unbounded number of pages and media elements.
53
+
54
+ ## Consequences
55
+
56
+ - The 10-minute one-layer gate fell from 211.8 s to 192.6 s (−9.0%) with the
57
+ same 8 workers and byte-identical output. The full 60-minute gate fell from
58
+ 19.83 to 18.78 minutes (−5.3%) and opened eight sessions for 360 segments
59
+ instead of 360 sessions. Its output is byte-identical to the baseline.
60
+ - The two-layer 60-second gate did not show a clear gain (43.78 s to 45.43 s):
61
+ six segments are too few for reuse to pay back the pool's initial opens. The
62
+ long-form workload is the target.
63
+ - Reused sessions return accumulated diagnostics; the final merge continues to
64
+ deduplicate them. A page error discards the session so a broken render cannot
65
+ poison later segments.
66
+ - The pool is internal to a render. There is no public CLI or JSON contract
67
+ change.
68
+ - Tests cover same-key reuse, discard, idle-budget eviction, close, and the
69
+ existing timeline suite with embedded media, controllers, blends, keyframes,
70
+ transitions, and nested timelines.