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.
- package/CHANGELOG.md +67 -0
- package/README.md +1 -1
- package/dist/preview/fonts/LICENSE-figtree.txt +93 -0
- package/dist/preview/fonts/LICENSE-space-mono.txt +93 -0
- package/dist/preview/fonts/README.md +23 -0
- package/dist/preview/fonts/figtree-latin-wght-normal.woff2 +0 -0
- package/dist/preview/fonts/space-mono-latin-400-normal.woff2 +0 -0
- package/dist/preview/fonts/space-mono-latin-700-normal.woff2 +0 -0
- package/dist/preview/main.js +5 -2
- package/dist/preview/main.js.map +1 -1
- package/dist/preview/timeline-main.js +41 -23
- package/dist/preview/timeline-main.js.map +1 -1
- package/dist/renderer/audio.d.ts +6 -0
- package/dist/renderer/audio.d.ts.map +1 -1
- package/dist/renderer/audio.js +3 -1
- package/dist/renderer/audio.js.map +1 -1
- package/dist/renderer/browser.d.ts.map +1 -1
- package/dist/renderer/browser.js +13 -0
- package/dist/renderer/browser.js.map +1 -1
- package/dist/renderer/concurrency.d.ts +6 -0
- package/dist/renderer/concurrency.d.ts.map +1 -0
- package/dist/renderer/concurrency.js +27 -0
- package/dist/renderer/concurrency.js.map +1 -0
- package/dist/renderer/ffmpeg.d.ts +62 -3
- package/dist/renderer/ffmpeg.d.ts.map +1 -1
- package/dist/renderer/ffmpeg.js +326 -45
- package/dist/renderer/ffmpeg.js.map +1 -1
- package/dist/renderer/session-pool.d.ts +28 -0
- package/dist/renderer/session-pool.d.ts.map +1 -0
- package/dist/renderer/session-pool.js +91 -0
- package/dist/renderer/session-pool.js.map +1 -0
- package/dist/renderer/timeline.d.ts.map +1 -1
- package/dist/renderer/timeline.js +518 -38
- package/dist/renderer/timeline.js.map +1 -1
- package/dist/renderer/timings.d.ts +13 -0
- package/dist/renderer/timings.d.ts.map +1 -0
- package/dist/renderer/timings.js +29 -0
- package/dist/renderer/timings.js.map +1 -0
- package/dist/server/preview-html.d.ts.map +1 -1
- package/dist/server/preview-html.js +74 -188
- package/dist/server/preview-html.js.map +1 -1
- package/dist/server/preview-style.d.ts +11 -0
- package/dist/server/preview-style.d.ts.map +1 -0
- package/dist/server/preview-style.js +298 -0
- package/dist/server/preview-style.js.map +1 -0
- package/dist/server/timeline-preview-html.d.ts.map +1 -1
- package/dist/server/timeline-preview-html.js +140 -164
- package/dist/server/timeline-preview-html.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/docs/adr/0012-render-timelines-with-segmented-lossless-clip-intermediates.md +1 -1
- package/docs/adr/0024-stream-composition-captures-and-skip-trivial-composites.md +86 -0
- package/docs/adr/0025-capture-timeline-layers-in-parallel-with-a-global-limit.md +67 -0
- package/docs/adr/0026-reuse-loaded-composition-sessions-across-segments.md +70 -0
- package/docs/adr/0027-encode-two-full-frame-layers-through-dual-pipes.md +76 -0
- package/docs/adr/0028-encode-full-frame-video-segments-directly.md +65 -0
- package/docs/preview-testing.md +8 -0
- package/docs/v0.x-design.md +3 -3
- package/package.json +2 -2
- 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
|
-
|
|
54
|
-
|
|
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"
|
|
216
|
-
|
|
217
|
-
|
|
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/">← 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"
|
|
189
|
+
<div id="stage-wrap">
|
|
190
|
+
<div id="stage"></div>
|
|
191
|
+
</div>
|
|
222
192
|
</main>
|
|
223
193
|
<section id="timeline">
|
|
224
|
-
<div id="
|
|
225
|
-
|
|
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">▶</button>
|
|
233
209
|
<button id="step-forward" class="step" type="button" aria-label="Next frame" aria-keyshortcuts="ArrowRight" title="Next frame (→)">›</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"><
|
|
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
|
|
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.
|
|
1
|
+
export declare const VERSION = "0.10.0";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,OAAO,
|
|
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.
|
|
1
|
+
export const VERSION = "0.10.0";
|
|
2
2
|
//# sourceMappingURL=version.js.map
|
package/dist/version.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"version.js","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,OAAO,GAAG,
|
|
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.
|