frameyard 0.5.0 → 0.7.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 +100 -0
- package/GLOSSARY.md +152 -24
- package/README.md +99 -31
- package/dist/assets/process.js.map +1 -1
- package/dist/assets/provenance.js.map +1 -1
- package/dist/cli.js +326 -40
- package/dist/cli.js.map +1 -1
- package/dist/composition/attributes.d.ts.map +1 -1
- package/dist/composition/diagnostics.js.map +1 -1
- package/dist/contracts.d.ts +12 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js.map +1 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +79 -0
- package/dist/doctor.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/html/validate.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/preview/main.js +96 -11
- package/dist/preview/main.js.map +1 -1
- package/dist/preview/timeline-data.d.ts +85 -0
- package/dist/preview/timeline-data.d.ts.map +1 -0
- package/dist/preview/timeline-data.js +2 -0
- package/dist/preview/timeline-data.js.map +1 -0
- package/dist/preview/timeline-host.d.ts +21 -0
- package/dist/preview/timeline-host.d.ts.map +1 -0
- package/dist/preview/timeline-host.js +308 -0
- package/dist/preview/timeline-host.js.map +1 -0
- package/dist/preview/timeline-main.d.ts +17 -0
- package/dist/preview/timeline-main.d.ts.map +1 -0
- package/dist/preview/timeline-main.js +368 -0
- package/dist/preview/timeline-main.js.map +1 -0
- package/dist/project.d.ts +16 -1
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +81 -26
- package/dist/project.js.map +1 -1
- package/dist/renderer/browser.d.ts +2 -0
- package/dist/renderer/browser.d.ts.map +1 -1
- package/dist/renderer/browser.js +6 -1
- package/dist/renderer/browser.js.map +1 -1
- package/dist/renderer/ffmpeg.d.ts +126 -1
- package/dist/renderer/ffmpeg.d.ts.map +1 -1
- package/dist/renderer/ffmpeg.js +450 -6
- package/dist/renderer/ffmpeg.js.map +1 -1
- package/dist/renderer/index.d.ts +28 -3
- package/dist/renderer/index.d.ts.map +1 -1
- package/dist/renderer/index.js +138 -38
- package/dist/renderer/index.js.map +1 -1
- package/dist/renderer/timeline.d.ts +146 -0
- package/dist/renderer/timeline.d.ts.map +1 -0
- package/dist/renderer/timeline.js +1098 -0
- package/dist/renderer/timeline.js.map +1 -0
- package/dist/runtime/host.js.map +1 -1
- package/dist/runtime/media.d.ts.map +1 -1
- package/dist/runtime/media.js +3 -6
- package/dist/runtime/media.js.map +1 -1
- package/dist/runtime/video.d.ts +13 -0
- package/dist/runtime/video.d.ts.map +1 -0
- package/dist/runtime/video.js +41 -0
- package/dist/runtime/video.js.map +1 -0
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +90 -24
- package/dist/scaffold.js.map +1 -1
- package/dist/server/composition-html.d.ts.map +1 -1
- package/dist/server/preview-html.d.ts.map +1 -1
- package/dist/server/preview-html.js +15 -1
- package/dist/server/preview-html.js.map +1 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +39 -1
- package/dist/server/server.js.map +1 -1
- package/dist/server/timeline-preview-data.d.ts +4 -0
- package/dist/server/timeline-preview-data.d.ts.map +1 -0
- package/dist/server/timeline-preview-data.js +97 -0
- package/dist/server/timeline-preview-data.js.map +1 -0
- package/dist/server/timeline-preview-html.d.ts +4 -0
- package/dist/server/timeline-preview-html.d.ts.map +1 -0
- package/dist/server/timeline-preview-html.js +227 -0
- package/dist/server/timeline-preview-html.js.map +1 -0
- package/dist/timeline/plan.d.ts +32 -0
- package/dist/timeline/plan.d.ts.map +1 -0
- package/dist/timeline/plan.js +68 -0
- package/dist/timeline/plan.js.map +1 -0
- package/dist/timeline/resolve.d.ts +60 -0
- package/dist/timeline/resolve.d.ts.map +1 -0
- package/dist/timeline/resolve.js +523 -0
- package/dist/timeline/resolve.js.map +1 -0
- package/dist/timeline/schema.d.ts +71 -0
- package/dist/timeline/schema.d.ts.map +1 -0
- package/dist/timeline/schema.js +425 -0
- package/dist/timeline/schema.js.map +1 -0
- package/dist/transcript.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/adr/0002-make-frame-and-props-the-only-animation-state.md +2 -0
- package/docs/adr/0005-keep-parallel-rendering-opt-in-and-frame-ordered.md +2 -0
- package/docs/adr/0007-make-html-documents-the-composition-unit.md +1 -1
- package/docs/adr/0009-composite-production-frames-in-software.md +1 -1
- package/docs/adr/0010-make-timelines-the-render-unit.md +24 -0
- package/docs/adr/0011-compose-timeline-frames-in-a-live-browser-host.md +22 -0
- package/docs/adr/0012-render-timelines-with-segmented-lossless-clip-intermediates.md +31 -0
- package/docs/adr/0013-capture-bounded-multi-frame-stills-for-inspection.md +33 -0
- package/docs/preview-testing.md +53 -0
- package/docs/v0.1-design.md +138 -87
- package/examples/demo/README.md +10 -3
- package/examples/demo/timelines/demo.timeline.json +12 -0
- package/examples/demo/video.config.js +4 -0
- package/examples/video/README.md +50 -6
- package/examples/video/compositions/video-overlay.html +350 -0
- package/examples/video/props/video-timeline.json +10 -0
- package/examples/video/timelines/video-timeline-mp4.timeline.json +44 -0
- package/examples/video/timelines/video-timeline.timeline.json +44 -0
- package/examples/video/video.config.js +6 -0
- package/package.json +5 -3
- package/skills/frameyard/README.md +7 -5
- package/skills/frameyard/SKILL.md +19 -13
- package/skills/frameyard/references/common-mistakes.md +1 -0
- package/skills/frameyard/references/deterministic-authoring.md +2 -1
- package/skills/frameyard/references/diagnostics.md +1 -1
- package/skills/frameyard/references/failure-recovery.md +3 -2
- package/skills/frameyard/references/json-results.md +15 -6
- package/skills/frameyard/references/subtitles-karaoke.md +2 -2
- package/skills/frameyard/references/timelines.md +143 -0
- package/skills/frameyard/scripts/frameyard.mjs +20 -3
|
@@ -0,0 +1,1098 @@
|
|
|
1
|
+
import { link, mkdir, mkdtemp, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { isPlainObject, } from "../contracts.js";
|
|
6
|
+
import { FrameyardError, isFrameyardError } from "../errors.js";
|
|
7
|
+
import { validateHtmlComposition } from "../html/validate.js";
|
|
8
|
+
import { startPreviewServer } from "../server/server.js";
|
|
9
|
+
import { inspectTimelineAudio } from "./audio.js";
|
|
10
|
+
import { openCompositionSession } from "./browser.js";
|
|
11
|
+
import { assertContainerCodec, assertFfmpegCapabilities, compositeTimelineFrame, compositeTimelineSegment, concatTimelineSegments, CONTAINER_EXTENSION, defaultCodecForContainer, ENCODER_SESSION_LIMIT, encodePngSequenceToFfv1, mixAudioTimeline, muxVideoWithAudio, resolveVideoEncoder, } from "./ffmpeg.js";
|
|
12
|
+
import { inspectTranscriptClips } from "./transcripts.js";
|
|
13
|
+
import { planTimelineRender } from "../timeline/plan.js";
|
|
14
|
+
import { resolveProjectTimeline, } from "../timeline/resolve.js";
|
|
15
|
+
export const MAX_TIMELINE_WORKERS = 16;
|
|
16
|
+
function validateWorkers(value) {
|
|
17
|
+
const workers = value ?? 8;
|
|
18
|
+
if (!Number.isInteger(workers) || workers < 1) {
|
|
19
|
+
throw new FrameyardError("USAGE", `--workers must be a positive integer; received ${String(value)}.`);
|
|
20
|
+
}
|
|
21
|
+
if (workers > MAX_TIMELINE_WORKERS) {
|
|
22
|
+
throw new FrameyardError("USAGE", `--workers must be at most ${MAX_TIMELINE_WORKERS}; received ${workers}.`);
|
|
23
|
+
}
|
|
24
|
+
return workers;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Caps workers at the encoder's concurrent-session limit. Consumer NVIDIA
|
|
28
|
+
* drivers allow eight NVENC sessions, and each worker opens one encoder.
|
|
29
|
+
*/
|
|
30
|
+
export function clampTimelineWorkers(workers, encoder) {
|
|
31
|
+
return Math.min(workers, ENCODER_SESSION_LIMIT[encoder]);
|
|
32
|
+
}
|
|
33
|
+
function throwIfAborted(signal) {
|
|
34
|
+
if (signal?.aborted) {
|
|
35
|
+
throw new FrameyardError("RENDER_FAILED", "Operation cancelled.");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function pathExists(filePath) {
|
|
39
|
+
try {
|
|
40
|
+
await stat(filePath);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function ensureOutputAvailable(outPath, overwrite) {
|
|
48
|
+
if (!overwrite && (await pathExists(outPath))) {
|
|
49
|
+
throw new FrameyardError("OUTPUT_EXISTS", `Output already exists: ${outPath}. Pass --overwrite to replace it.`);
|
|
50
|
+
}
|
|
51
|
+
await mkdir(path.dirname(outPath), { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
async function assertOutputsAvailable(outPaths, overwrite) {
|
|
54
|
+
if (overwrite) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const conflicts = [];
|
|
58
|
+
for (const outPath of outPaths) {
|
|
59
|
+
if (await pathExists(outPath)) {
|
|
60
|
+
conflicts.push(outPath);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (conflicts.length === 0) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const first = conflicts[0];
|
|
67
|
+
const message = conflicts.length === 1
|
|
68
|
+
? `Output already exists: ${first}. Pass --overwrite to replace it.`
|
|
69
|
+
: `Output already exists: ${first} (and ${conflicts.length - 1} more). Pass --overwrite to replace them.`;
|
|
70
|
+
throw new FrameyardError("OUTPUT_EXISTS", message, { details: { conflicts } });
|
|
71
|
+
}
|
|
72
|
+
async function publishFile(tempPath, outPath, overwrite) {
|
|
73
|
+
if (overwrite) {
|
|
74
|
+
await rename(tempPath, outPath);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
await link(tempPath, outPath);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error.code === "EEXIST") {
|
|
82
|
+
throw new FrameyardError("OUTPUT_EXISTS", `Output already exists: ${outPath}. Pass --overwrite to replace it.`);
|
|
83
|
+
}
|
|
84
|
+
throw new FrameyardError("RENDER_FAILED", `Could not publish output ${outPath}: ${error.message}`, {
|
|
85
|
+
cause: error,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
await rm(tempPath, { force: true });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function diagnosticKey(diagnostic) {
|
|
93
|
+
return JSON.stringify([diagnostic.severity, diagnostic.code, diagnostic.message, diagnostic.details ?? null]);
|
|
94
|
+
}
|
|
95
|
+
function mergeDiagnostics(...groups) {
|
|
96
|
+
const seen = new Set();
|
|
97
|
+
const result = [];
|
|
98
|
+
for (const group of groups) {
|
|
99
|
+
for (const diagnostic of group) {
|
|
100
|
+
const key = diagnosticKey(diagnostic);
|
|
101
|
+
if (!seen.has(key)) {
|
|
102
|
+
seen.add(key);
|
|
103
|
+
result.push(diagnostic);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
function withBatchContext(error, frames, failedFrame) {
|
|
110
|
+
if (!isFrameyardError(error)) {
|
|
111
|
+
return error;
|
|
112
|
+
}
|
|
113
|
+
const details = error.details === undefined
|
|
114
|
+
? {}
|
|
115
|
+
: isPlainObject(error.details)
|
|
116
|
+
? { ...error.details }
|
|
117
|
+
: { originalDetails: error.details };
|
|
118
|
+
return new FrameyardError(error.code, error.message, {
|
|
119
|
+
cause: error,
|
|
120
|
+
details: { ...details, frames: [...frames], failedFrame },
|
|
121
|
+
diagnostics: error.diagnostics,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
function compositionDiagnostic(diagnostic, timelineId, clip, compositionId) {
|
|
125
|
+
return {
|
|
126
|
+
...diagnostic,
|
|
127
|
+
details: {
|
|
128
|
+
...diagnostic.details,
|
|
129
|
+
timeline: timelineId,
|
|
130
|
+
clip: clip.id,
|
|
131
|
+
track: clip.track,
|
|
132
|
+
composition: compositionId,
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function clampVolume(value) {
|
|
137
|
+
return Math.min(1, Math.max(0, value));
|
|
138
|
+
}
|
|
139
|
+
function visualSourceSize(clip, timeline) {
|
|
140
|
+
const source = clip.source;
|
|
141
|
+
if (source.kind === "composition") {
|
|
142
|
+
return { width: source.metadata.width, height: source.metadata.height };
|
|
143
|
+
}
|
|
144
|
+
if (source.kind === "video" || source.kind === "image") {
|
|
145
|
+
if (source.width === undefined || source.height === undefined) {
|
|
146
|
+
throw new FrameyardError("TIMELINE_MEDIA_INVALID", `Media ${source.src} is missing width/height metadata for clip "${clip.id}".`);
|
|
147
|
+
}
|
|
148
|
+
return { width: source.width, height: source.height };
|
|
149
|
+
}
|
|
150
|
+
return { width: timeline.format.width, height: timeline.format.height };
|
|
151
|
+
}
|
|
152
|
+
function mapCompositionAudio(clip, audio, fps, window) {
|
|
153
|
+
if (window.muted) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
const clipOuterStart = clip.start + window.frameOffset;
|
|
157
|
+
const clipOuterEnd = clip.end + window.frameOffset;
|
|
158
|
+
const windowStart = Math.max(window.startFrame, clipOuterStart);
|
|
159
|
+
const windowEnd = Math.min(window.endFrame, clipOuterEnd);
|
|
160
|
+
if (windowEnd <= windowStart) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
const sourceAtWindowStart = clip.sourceIn + (windowStart - clipOuterStart);
|
|
164
|
+
const sourceAtWindowEnd = clip.sourceIn + (windowEnd - clipOuterStart);
|
|
165
|
+
const audioDurationFrames = audio.trimDuration !== undefined ? audio.trimDuration * fps : Number.POSITIVE_INFINITY;
|
|
166
|
+
const effectiveStart = Math.max(audio.startFrame, sourceAtWindowStart, clip.sourceIn);
|
|
167
|
+
const effectiveEnd = Math.min(sourceAtWindowEnd, clip.sourceIn + clip.duration, audio.startFrame + audioDurationFrames);
|
|
168
|
+
if (effectiveEnd <= effectiveStart) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
const duration = (effectiveEnd - effectiveStart) / fps;
|
|
172
|
+
if (duration <= 0) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
src: audio.src,
|
|
177
|
+
startFrame: clip.start + (effectiveStart - clip.sourceIn) + window.frameOffset,
|
|
178
|
+
trimStart: audio.trimStart + (effectiveStart - audio.startFrame) / fps,
|
|
179
|
+
trimDuration: duration,
|
|
180
|
+
volume: clampVolume(audio.volume * window.gain),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
function collectTimelineAudio(timeline, window, clips, transcripts) {
|
|
184
|
+
const fps = timeline.format.fps;
|
|
185
|
+
const trackById = new Map(timeline.tracks.map((track) => [track.id, track]));
|
|
186
|
+
for (const clip of timeline.clips) {
|
|
187
|
+
const source = clip.source;
|
|
188
|
+
const mappedStart = clip.start + window.frameOffset;
|
|
189
|
+
const mappedEnd = clip.end + window.frameOffset;
|
|
190
|
+
const startFrame = Math.max(mappedStart, window.startFrame);
|
|
191
|
+
const endFrame = Math.min(mappedEnd, window.endFrame);
|
|
192
|
+
if (endFrame <= startFrame) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const clipGain = window.gain * (clip.audio?.gain ?? 1);
|
|
196
|
+
const clipMuted = window.muted || (clip.audio?.muted ?? false);
|
|
197
|
+
if (source.kind === "composition") {
|
|
198
|
+
for (const transcript of source.transcriptClips) {
|
|
199
|
+
transcripts.set(`${transcript.src}\u0000${transcript.words}`, transcript);
|
|
200
|
+
}
|
|
201
|
+
if (clipMuted) {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
for (const audio of source.audioClips) {
|
|
205
|
+
const mapped = mapCompositionAudio(clip, audio, fps, { ...window, gain: clipGain });
|
|
206
|
+
if (mapped) {
|
|
207
|
+
clips.push(mapped);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
for (const audio of source.embeddedAudioClips) {
|
|
211
|
+
const mapped = mapCompositionAudio(clip, audio, fps, { ...window, gain: clipGain });
|
|
212
|
+
if (mapped) {
|
|
213
|
+
clips.push({ ...mapped, allowSilent: true });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (source.kind === "timeline") {
|
|
219
|
+
collectTimelineAudio(source.timeline, {
|
|
220
|
+
frameOffset: window.frameOffset + clip.start - clip.sourceIn,
|
|
221
|
+
startFrame,
|
|
222
|
+
endFrame,
|
|
223
|
+
gain: clipGain,
|
|
224
|
+
muted: clipMuted,
|
|
225
|
+
}, clips, transcripts);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (source.kind === "video") {
|
|
229
|
+
if (source.hasAudio && !clipMuted) {
|
|
230
|
+
clips.push({
|
|
231
|
+
src: source.src,
|
|
232
|
+
startFrame,
|
|
233
|
+
trimStart: clip.sourceIn / fps + (startFrame - mappedStart) / fps,
|
|
234
|
+
trimDuration: (endFrame - startFrame) / fps,
|
|
235
|
+
volume: clampVolume(clipGain),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (source.kind === "audio") {
|
|
241
|
+
const track = trackById.get(clip.track);
|
|
242
|
+
if (!track || track.kind !== "audio" || track.muted || clipMuted) {
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
clips.push({
|
|
246
|
+
src: source.src,
|
|
247
|
+
startFrame,
|
|
248
|
+
trimStart: clip.sourceIn / fps + (startFrame - mappedStart) / fps,
|
|
249
|
+
trimDuration: (endFrame - startFrame) / fps,
|
|
250
|
+
volume: clampVolume(clipGain * (track.gain ?? 1)),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function buildAudioClips(timeline) {
|
|
256
|
+
const clips = [];
|
|
257
|
+
const transcripts = new Map();
|
|
258
|
+
collectTimelineAudio(timeline, { frameOffset: 0, startFrame: 0, endFrame: timeline.durationInFrames, gain: 1, muted: false }, clips, transcripts);
|
|
259
|
+
return { clips, transcripts: [...transcripts.values()] };
|
|
260
|
+
}
|
|
261
|
+
/** Resolved timeline audio for preview surfaces. */
|
|
262
|
+
export function resolveTimelineAudio(timeline) {
|
|
263
|
+
const { clips, transcripts } = buildAudioClips(timeline);
|
|
264
|
+
return {
|
|
265
|
+
clips: clips.map((clip) => {
|
|
266
|
+
const { allowSilent: _allowSilent, ...resolved } = clip;
|
|
267
|
+
return resolved;
|
|
268
|
+
}),
|
|
269
|
+
transcripts,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
async function captureCompositionLayer(options) {
|
|
273
|
+
const { project, timeline, clip, source, segment, layerIndex, serverUrl, segmentDir, infoCache } = options;
|
|
274
|
+
const propsKey = JSON.stringify(clip.props ?? null);
|
|
275
|
+
const cacheKey = `${source.entry}\u0000${propsKey}`;
|
|
276
|
+
let validation = infoCache.get(cacheKey);
|
|
277
|
+
if (!validation) {
|
|
278
|
+
validation = await validateHtmlComposition({
|
|
279
|
+
projectRoot: project.root,
|
|
280
|
+
entry: source.entry,
|
|
281
|
+
...(clip.props !== undefined ? { props: clip.props } : {}),
|
|
282
|
+
});
|
|
283
|
+
infoCache.set(cacheKey, validation);
|
|
284
|
+
}
|
|
285
|
+
if (!validation.info) {
|
|
286
|
+
throw new FrameyardError("TIMELINE_CLIP_INVALID", `Composition "${source.id}" could not be loaded for clip "${clip.id}".`, { diagnostics: validation.diagnostics });
|
|
287
|
+
}
|
|
288
|
+
const layer = segment.layers[layerIndex];
|
|
289
|
+
if (!layer) {
|
|
290
|
+
throw new FrameyardError("INTERNAL", `Missing layer ${layerIndex} for segment ${segment.index}.`);
|
|
291
|
+
}
|
|
292
|
+
const prefix = `comp-${layerIndex}`;
|
|
293
|
+
const session = await openCompositionSession({
|
|
294
|
+
serverUrl,
|
|
295
|
+
entry: source.entry,
|
|
296
|
+
props: clip.props,
|
|
297
|
+
info: validation.info,
|
|
298
|
+
staticDiagnostics: validation.diagnostics,
|
|
299
|
+
transparentBackground: true,
|
|
300
|
+
});
|
|
301
|
+
const diagnostics = [];
|
|
302
|
+
try {
|
|
303
|
+
for (let offset = 0; offset < segment.frames; offset += 1) {
|
|
304
|
+
throwIfAborted(options.signal);
|
|
305
|
+
await session.seek(layer.sourceStart + offset);
|
|
306
|
+
const png = await session.capture();
|
|
307
|
+
await writeFile(path.join(segmentDir, `${prefix}-${String(offset).padStart(4, "0")}.png`), png);
|
|
308
|
+
}
|
|
309
|
+
for (const diagnostic of await session.readDiagnostics()) {
|
|
310
|
+
diagnostics.push(compositionDiagnostic(diagnostic, timeline.id, clip, source.id));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
await session.close();
|
|
315
|
+
}
|
|
316
|
+
const outputPath = path.join(segmentDir, `${prefix}.mkv`);
|
|
317
|
+
await encodePngSequenceToFfv1({
|
|
318
|
+
pattern: path.join(segmentDir, `${prefix}-%04d.png`),
|
|
319
|
+
outputPath,
|
|
320
|
+
fps: timeline.format.fps,
|
|
321
|
+
signal: options.signal,
|
|
322
|
+
});
|
|
323
|
+
return { path: outputPath, diagnostics };
|
|
324
|
+
}
|
|
325
|
+
async function renderNestedTimelineRange(options) {
|
|
326
|
+
if (options.depth > 32) {
|
|
327
|
+
throw new FrameyardError("TIMELINE_INVALID", "Nested timeline depth exceeds 32.");
|
|
328
|
+
}
|
|
329
|
+
const plan = planTimelineRender(options.timeline, {
|
|
330
|
+
startFrame: options.startFrame,
|
|
331
|
+
endFrame: options.startFrame + options.frames,
|
|
332
|
+
});
|
|
333
|
+
const unique = randomUUID();
|
|
334
|
+
const prefix = `nested-${options.depth}-${options.startFrame}-${unique}`;
|
|
335
|
+
const results = [];
|
|
336
|
+
for (const segment of plan.segments) {
|
|
337
|
+
results.push(await processTimelineSegment({
|
|
338
|
+
project: options.project,
|
|
339
|
+
timeline: options.timeline,
|
|
340
|
+
segment,
|
|
341
|
+
serverUrl: options.serverUrl,
|
|
342
|
+
scratchRoot: options.scratchRoot,
|
|
343
|
+
infoCache: options.infoCache,
|
|
344
|
+
codec: "ffv1",
|
|
345
|
+
background: options.timeline.background ?? "transparent",
|
|
346
|
+
prefix,
|
|
347
|
+
depth: options.depth,
|
|
348
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
349
|
+
}));
|
|
350
|
+
}
|
|
351
|
+
const diagnostics = results.flatMap((result) => result.diagnostics);
|
|
352
|
+
if (results.length === 1) {
|
|
353
|
+
return { path: results[0].path, diagnostics };
|
|
354
|
+
}
|
|
355
|
+
const outputPath = path.join(options.scratchRoot, `${prefix}.mkv`);
|
|
356
|
+
await concatTimelineSegments({
|
|
357
|
+
segmentPaths: results.map((result) => result.path),
|
|
358
|
+
listPath: path.join(options.scratchRoot, `${prefix}-segments.txt`),
|
|
359
|
+
outputPath,
|
|
360
|
+
// Nested timeline segments are always lossless FFV1 in Matroska.
|
|
361
|
+
container: "mkv",
|
|
362
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
363
|
+
});
|
|
364
|
+
return { path: outputPath, diagnostics };
|
|
365
|
+
}
|
|
366
|
+
async function processTimelineSegment(options) {
|
|
367
|
+
const { project, timeline, segment, serverUrl, scratchRoot, infoCache } = options;
|
|
368
|
+
const codec = options.codec ?? "h264";
|
|
369
|
+
const background = options.background ?? (codec === "ffv1" ? "transparent" : (timeline.background ?? "black"));
|
|
370
|
+
const prefix = options.prefix ?? "timeline";
|
|
371
|
+
const depth = options.depth ?? 0;
|
|
372
|
+
const segmentDir = path.join(scratchRoot, `${prefix}-seg-${String(segment.index).padStart(4, "0")}`);
|
|
373
|
+
await mkdir(segmentDir, { recursive: true });
|
|
374
|
+
const diagnostics = [];
|
|
375
|
+
const layers = [];
|
|
376
|
+
for (const [layerIndex, layer] of segment.layers.entries()) {
|
|
377
|
+
throwIfAborted(options.signal);
|
|
378
|
+
const clip = layer.clip;
|
|
379
|
+
const source = clip.source;
|
|
380
|
+
const transform = clip.transform ?? {
|
|
381
|
+
fit: "contain",
|
|
382
|
+
x: 0,
|
|
383
|
+
y: 0,
|
|
384
|
+
scale: 1,
|
|
385
|
+
rotation: 0,
|
|
386
|
+
opacity: 1,
|
|
387
|
+
};
|
|
388
|
+
const base = {
|
|
389
|
+
fit: transform.fit,
|
|
390
|
+
x: transform.x,
|
|
391
|
+
y: transform.y,
|
|
392
|
+
...(transform.width !== undefined && transform.height !== undefined
|
|
393
|
+
? { width: transform.width, height: transform.height }
|
|
394
|
+
: {}),
|
|
395
|
+
scale: transform.scale,
|
|
396
|
+
rotation: transform.rotation,
|
|
397
|
+
opacity: transform.opacity,
|
|
398
|
+
};
|
|
399
|
+
if (source.kind === "composition") {
|
|
400
|
+
const captured = await captureCompositionLayer({
|
|
401
|
+
project,
|
|
402
|
+
timeline,
|
|
403
|
+
clip,
|
|
404
|
+
source,
|
|
405
|
+
segment,
|
|
406
|
+
layerIndex,
|
|
407
|
+
serverUrl,
|
|
408
|
+
segmentDir,
|
|
409
|
+
infoCache,
|
|
410
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
411
|
+
});
|
|
412
|
+
diagnostics.push(...captured.diagnostics);
|
|
413
|
+
layers.push({
|
|
414
|
+
kind: "composition",
|
|
415
|
+
path: captured.path,
|
|
416
|
+
sourceWidth: source.metadata.width,
|
|
417
|
+
sourceHeight: source.metadata.height,
|
|
418
|
+
...base,
|
|
419
|
+
});
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
if (source.kind === "video" || source.kind === "image") {
|
|
423
|
+
const size = visualSourceSize(clip, timeline);
|
|
424
|
+
layers.push({
|
|
425
|
+
kind: source.kind,
|
|
426
|
+
path: source.absolutePath,
|
|
427
|
+
sourceWidth: size.width,
|
|
428
|
+
sourceHeight: size.height,
|
|
429
|
+
...base,
|
|
430
|
+
...(source.kind === "video" ? { sourceStartFrame: layer.sourceStart } : {}),
|
|
431
|
+
});
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (source.kind === "timeline") {
|
|
435
|
+
const nested = await renderNestedTimelineRange({
|
|
436
|
+
project,
|
|
437
|
+
timeline: source.timeline,
|
|
438
|
+
startFrame: layer.sourceStart,
|
|
439
|
+
frames: segment.frames,
|
|
440
|
+
serverUrl,
|
|
441
|
+
scratchRoot,
|
|
442
|
+
infoCache,
|
|
443
|
+
depth: depth + 1,
|
|
444
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
445
|
+
});
|
|
446
|
+
diagnostics.push(...nested.diagnostics);
|
|
447
|
+
layers.push({
|
|
448
|
+
kind: "timeline",
|
|
449
|
+
path: nested.path,
|
|
450
|
+
sourceWidth: source.timeline.format.width,
|
|
451
|
+
sourceHeight: source.timeline.format.height,
|
|
452
|
+
...base,
|
|
453
|
+
});
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
throw new FrameyardError("TIMELINE_INVALID", `Unsupported visual source for clip "${clip.id}".`);
|
|
457
|
+
}
|
|
458
|
+
const extension = codec === "ffv1" ? "mkv" : CONTAINER_EXTENSION[options.container ?? "mp4"];
|
|
459
|
+
const segmentPath = path.join(scratchRoot, `${prefix}-segment-${String(segment.index).padStart(4, "0")}.${extension}`);
|
|
460
|
+
await compositeTimelineSegment({
|
|
461
|
+
outputPath: segmentPath,
|
|
462
|
+
width: timeline.format.width,
|
|
463
|
+
height: timeline.format.height,
|
|
464
|
+
fps: timeline.format.fps,
|
|
465
|
+
frames: segment.frames,
|
|
466
|
+
background,
|
|
467
|
+
layers,
|
|
468
|
+
codec,
|
|
469
|
+
...(options.container !== undefined ? { container: options.container } : {}),
|
|
470
|
+
...(options.encoder !== undefined ? { encoder: options.encoder } : {}),
|
|
471
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
472
|
+
});
|
|
473
|
+
await rm(segmentDir, { recursive: true, force: true });
|
|
474
|
+
return { path: segmentPath, diagnostics };
|
|
475
|
+
}
|
|
476
|
+
async function checkResolvedTimelineRange(options) {
|
|
477
|
+
if (options.context.depth > 32) {
|
|
478
|
+
throw new FrameyardError("TIMELINE_INVALID", "Nested timeline depth exceeds 32.");
|
|
479
|
+
}
|
|
480
|
+
const { timeline, startFrame, endFrame, context } = options;
|
|
481
|
+
const plan = planTimelineRender(timeline, { startFrame, endFrame });
|
|
482
|
+
const diagnostics = [];
|
|
483
|
+
const targets = new Map();
|
|
484
|
+
for (const segment of plan.segments) {
|
|
485
|
+
for (const layer of segment.layers) {
|
|
486
|
+
const source = layer.clip.source;
|
|
487
|
+
if (source.kind !== "composition") {
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
const key = `${source.entry}\u0000${JSON.stringify(layer.clip.props ?? null)}`;
|
|
491
|
+
if (!targets.has(key)) {
|
|
492
|
+
const validation = await validateHtmlComposition({
|
|
493
|
+
projectRoot: context.project.root,
|
|
494
|
+
entry: source.entry,
|
|
495
|
+
...(layer.clip.props !== undefined ? { props: layer.clip.props } : {}),
|
|
496
|
+
});
|
|
497
|
+
if (!validation.info) {
|
|
498
|
+
throw new FrameyardError("TIMELINE_CLIP_INVALID", `Composition "${source.id}" could not be loaded for clip "${layer.clip.id}".`, { diagnostics: validation.diagnostics });
|
|
499
|
+
}
|
|
500
|
+
targets.set(key, { source, clip: layer.clip, validation });
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const sessions = new Map();
|
|
505
|
+
try {
|
|
506
|
+
for (const [key, target] of targets) {
|
|
507
|
+
const session = await openCompositionSession({
|
|
508
|
+
serverUrl: context.serverUrl,
|
|
509
|
+
entry: target.source.entry,
|
|
510
|
+
props: target.clip.props,
|
|
511
|
+
info: target.validation.info,
|
|
512
|
+
staticDiagnostics: target.validation.diagnostics,
|
|
513
|
+
});
|
|
514
|
+
sessions.set(key, session);
|
|
515
|
+
}
|
|
516
|
+
for (const segment of plan.segments) {
|
|
517
|
+
for (let frame = segment.start; frame < segment.end; frame += 1) {
|
|
518
|
+
throwIfAborted(context.signal);
|
|
519
|
+
for (const layer of segment.layers) {
|
|
520
|
+
if (layer.clip.source.kind !== "composition") {
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
const source = layer.clip.source;
|
|
524
|
+
const key = `${source.entry}\u0000${JSON.stringify(layer.clip.props ?? null)}`;
|
|
525
|
+
const session = sessions.get(key);
|
|
526
|
+
if (!session) {
|
|
527
|
+
throw new FrameyardError("INTERNAL", `Missing composition session for clip "${layer.clip.id}".`);
|
|
528
|
+
}
|
|
529
|
+
await session.seek(layer.sourceStart + (frame - segment.start));
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (context.depth === 0) {
|
|
533
|
+
context.onProgress?.({
|
|
534
|
+
frame: segment.end,
|
|
535
|
+
total: context.totalFrames,
|
|
536
|
+
elapsedMs: performance.now() - context.startedAt,
|
|
537
|
+
segment: segment.index + 1,
|
|
538
|
+
segments: plan.segments.length,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
for (const [key, target] of targets) {
|
|
543
|
+
const session = sessions.get(key);
|
|
544
|
+
if (!session) {
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
for (const diagnostic of await session.readDiagnostics()) {
|
|
548
|
+
diagnostics.push(compositionDiagnostic(diagnostic, timeline.id, target.clip, target.source.id));
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
finally {
|
|
553
|
+
await Promise.all([...sessions.values()].map(async (session) => {
|
|
554
|
+
try {
|
|
555
|
+
await session.close();
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
// Preserve the check's original error.
|
|
559
|
+
}
|
|
560
|
+
}));
|
|
561
|
+
}
|
|
562
|
+
for (const segment of plan.segments) {
|
|
563
|
+
for (const layer of segment.layers) {
|
|
564
|
+
if (layer.clip.source.kind !== "timeline") {
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
diagnostics.push(...(await checkResolvedTimelineRange({
|
|
568
|
+
timeline: layer.clip.source.timeline,
|
|
569
|
+
startFrame: layer.sourceStart,
|
|
570
|
+
endFrame: layer.sourceStart + segment.frames,
|
|
571
|
+
context: { ...context, depth: context.depth + 1 },
|
|
572
|
+
})));
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return diagnostics;
|
|
576
|
+
}
|
|
577
|
+
/** Validates a timeline and evaluates every timeline frame without capturing. */
|
|
578
|
+
export async function checkTimeline(options) {
|
|
579
|
+
const startedAt = performance.now();
|
|
580
|
+
const projectTimeline = options.project.timelines.get(options.timelineId);
|
|
581
|
+
if (!projectTimeline) {
|
|
582
|
+
throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${options.timelineId}" is not listed in video.config.js.`);
|
|
583
|
+
}
|
|
584
|
+
const resolved = await resolveProjectTimeline({
|
|
585
|
+
project: options.project,
|
|
586
|
+
timeline: projectTimeline,
|
|
587
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
588
|
+
...(options.propsOverrides ? { propsOverrides: options.propsOverrides } : {}),
|
|
589
|
+
});
|
|
590
|
+
if (!resolved.timeline) {
|
|
591
|
+
const first = resolved.diagnostics.find((diagnostic) => diagnostic.severity === "error");
|
|
592
|
+
throw new FrameyardError(first?.code ?? "TIMELINE_INVALID", first?.message ?? "Timeline resolution failed.", {
|
|
593
|
+
diagnostics: resolved.diagnostics,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
const timeline = resolved.timeline;
|
|
597
|
+
const diagnostics = [...resolved.diagnostics];
|
|
598
|
+
const server = await startPreviewServer({ projectRoot: options.project.root, port: 0 });
|
|
599
|
+
try {
|
|
600
|
+
diagnostics.push(...(await checkResolvedTimelineRange({
|
|
601
|
+
timeline,
|
|
602
|
+
startFrame: 0,
|
|
603
|
+
endFrame: timeline.durationInFrames,
|
|
604
|
+
context: {
|
|
605
|
+
project: options.project,
|
|
606
|
+
serverUrl: server.url,
|
|
607
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
608
|
+
...(options.onProgress ? { onProgress: options.onProgress } : {}),
|
|
609
|
+
totalFrames: timeline.durationInFrames,
|
|
610
|
+
startedAt,
|
|
611
|
+
depth: 0,
|
|
612
|
+
},
|
|
613
|
+
})));
|
|
614
|
+
}
|
|
615
|
+
finally {
|
|
616
|
+
try {
|
|
617
|
+
await server.close();
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
// Preserve the check's original error.
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
const audio = buildAudioClips(timeline);
|
|
624
|
+
const audioInspection = audio.clips.length
|
|
625
|
+
? await inspectTimelineAudio({
|
|
626
|
+
projectRoot: options.project.root,
|
|
627
|
+
clips: audio.clips,
|
|
628
|
+
fps: timeline.format.fps,
|
|
629
|
+
durationInFrames: timeline.durationInFrames,
|
|
630
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
631
|
+
})
|
|
632
|
+
: { clips: [], assets: [], warnings: [] };
|
|
633
|
+
diagnostics.push(...audioInspection.warnings.map((message) => ({
|
|
634
|
+
severity: "warning",
|
|
635
|
+
code: "AUDIO_WARNING",
|
|
636
|
+
message,
|
|
637
|
+
})));
|
|
638
|
+
const transcripts = audio.transcripts.length
|
|
639
|
+
? await inspectTranscriptClips({
|
|
640
|
+
projectRoot: options.project.root,
|
|
641
|
+
clips: audio.transcripts,
|
|
642
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
643
|
+
})
|
|
644
|
+
: { clips: [], assets: [], warnings: [] };
|
|
645
|
+
diagnostics.push(...transcripts.warnings.map((message) => ({
|
|
646
|
+
severity: "warning",
|
|
647
|
+
code: "TRANSCRIPT_WARNING",
|
|
648
|
+
message,
|
|
649
|
+
})));
|
|
650
|
+
return {
|
|
651
|
+
timeline: options.timelineId,
|
|
652
|
+
entry: timeline.entry,
|
|
653
|
+
format: { ...timeline.format },
|
|
654
|
+
durationInFrames: timeline.durationInFrames,
|
|
655
|
+
framesChecked: timeline.durationInFrames,
|
|
656
|
+
tracks: timeline.tracks.length,
|
|
657
|
+
clips: timeline.clips.length,
|
|
658
|
+
audio: { clips: audioInspection.clips.length, details: audioInspection.clips },
|
|
659
|
+
transcripts: {
|
|
660
|
+
clips: transcripts.clips.length,
|
|
661
|
+
details: transcripts.clips,
|
|
662
|
+
},
|
|
663
|
+
diagnostics: mergeDiagnostics(diagnostics),
|
|
664
|
+
elapsedMs: performance.now() - startedAt,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
async function renderResolvedTimelineFrame(options) {
|
|
668
|
+
if (options.context.depth > 32) {
|
|
669
|
+
throw new FrameyardError("TIMELINE_INVALID", "Nested timeline depth exceeds 32.");
|
|
670
|
+
}
|
|
671
|
+
const { timeline, frame, context } = options;
|
|
672
|
+
const plan = planTimelineRender(timeline, { startFrame: frame, endFrame: frame + 1 });
|
|
673
|
+
const segment = plan.segments[0];
|
|
674
|
+
if (!segment) {
|
|
675
|
+
throw new FrameyardError("FRAME_OUT_OF_RANGE", `Frame ${frame} does not belong to a timeline segment.`);
|
|
676
|
+
}
|
|
677
|
+
const diagnostics = [];
|
|
678
|
+
const layers = [];
|
|
679
|
+
for (const [layerIndex, layer] of segment.layers.entries()) {
|
|
680
|
+
const clip = layer.clip;
|
|
681
|
+
const source = clip.source;
|
|
682
|
+
const transform = clip.transform ?? {
|
|
683
|
+
fit: "contain",
|
|
684
|
+
x: 0,
|
|
685
|
+
y: 0,
|
|
686
|
+
scale: 1,
|
|
687
|
+
rotation: 0,
|
|
688
|
+
opacity: 1,
|
|
689
|
+
};
|
|
690
|
+
const base = {
|
|
691
|
+
fit: transform.fit,
|
|
692
|
+
x: transform.x,
|
|
693
|
+
y: transform.y,
|
|
694
|
+
...(transform.width !== undefined && transform.height !== undefined
|
|
695
|
+
? { width: transform.width, height: transform.height }
|
|
696
|
+
: {}),
|
|
697
|
+
scale: transform.scale,
|
|
698
|
+
rotation: transform.rotation,
|
|
699
|
+
opacity: transform.opacity,
|
|
700
|
+
};
|
|
701
|
+
if (source.kind === "composition") {
|
|
702
|
+
const validation = await validateHtmlComposition({
|
|
703
|
+
projectRoot: context.project.root,
|
|
704
|
+
entry: source.entry,
|
|
705
|
+
...(clip.props !== undefined ? { props: clip.props } : {}),
|
|
706
|
+
});
|
|
707
|
+
if (!validation.info) {
|
|
708
|
+
throw new FrameyardError("TIMELINE_CLIP_INVALID", `Composition "${source.id}" could not be loaded for clip "${clip.id}".`, { diagnostics: validation.diagnostics });
|
|
709
|
+
}
|
|
710
|
+
const session = await openCompositionSession({
|
|
711
|
+
serverUrl: context.serverUrl,
|
|
712
|
+
entry: source.entry,
|
|
713
|
+
props: clip.props,
|
|
714
|
+
info: validation.info,
|
|
715
|
+
staticDiagnostics: validation.diagnostics,
|
|
716
|
+
transparentBackground: true,
|
|
717
|
+
});
|
|
718
|
+
const framePath = path.join(context.scratchRoot, `frame-${context.depth}-${layerIndex}-${randomUUID()}.png`);
|
|
719
|
+
try {
|
|
720
|
+
await session.seek(layer.sourceStart);
|
|
721
|
+
const png = await session.capture();
|
|
722
|
+
await writeFile(framePath, png);
|
|
723
|
+
for (const diagnostic of await session.readDiagnostics()) {
|
|
724
|
+
diagnostics.push(compositionDiagnostic(diagnostic, timeline.id, clip, source.id));
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
finally {
|
|
728
|
+
await session.close();
|
|
729
|
+
}
|
|
730
|
+
layers.push({
|
|
731
|
+
kind: "image",
|
|
732
|
+
path: framePath,
|
|
733
|
+
sourceWidth: source.metadata.width,
|
|
734
|
+
sourceHeight: source.metadata.height,
|
|
735
|
+
...base,
|
|
736
|
+
});
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
if (source.kind === "video" || source.kind === "image") {
|
|
740
|
+
const size = visualSourceSize(clip, timeline);
|
|
741
|
+
layers.push({
|
|
742
|
+
kind: source.kind,
|
|
743
|
+
path: source.absolutePath,
|
|
744
|
+
sourceWidth: size.width,
|
|
745
|
+
sourceHeight: size.height,
|
|
746
|
+
...base,
|
|
747
|
+
...(source.kind === "video" ? { sourceStartFrame: layer.sourceStart } : {}),
|
|
748
|
+
});
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
if (source.kind === "timeline") {
|
|
752
|
+
const nestedPath = path.join(context.scratchRoot, `nested-frame-${context.depth}-${layerIndex}-${randomUUID()}.png`);
|
|
753
|
+
const nestedDiagnostics = await renderResolvedTimelineFrame({
|
|
754
|
+
timeline: source.timeline,
|
|
755
|
+
frame: layer.sourceStart,
|
|
756
|
+
outputPath: nestedPath,
|
|
757
|
+
background: source.timeline.background ?? "transparent",
|
|
758
|
+
context: { ...context, depth: context.depth + 1 },
|
|
759
|
+
});
|
|
760
|
+
diagnostics.push(...nestedDiagnostics);
|
|
761
|
+
layers.push({
|
|
762
|
+
kind: "image",
|
|
763
|
+
path: nestedPath,
|
|
764
|
+
sourceWidth: source.timeline.format.width,
|
|
765
|
+
sourceHeight: source.timeline.format.height,
|
|
766
|
+
...base,
|
|
767
|
+
});
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
throw new FrameyardError("TIMELINE_INVALID", `Unsupported visual source for clip "${clip.id}".`);
|
|
771
|
+
}
|
|
772
|
+
await compositeTimelineFrame({
|
|
773
|
+
outputPath: options.outputPath,
|
|
774
|
+
width: timeline.format.width,
|
|
775
|
+
height: timeline.format.height,
|
|
776
|
+
fps: timeline.format.fps,
|
|
777
|
+
background: options.background,
|
|
778
|
+
layers,
|
|
779
|
+
...(context.signal ? { signal: context.signal } : {}),
|
|
780
|
+
});
|
|
781
|
+
return diagnostics;
|
|
782
|
+
}
|
|
783
|
+
/** Captures timeline frames as PNGs through the clip capture and composite path. */
|
|
784
|
+
export async function captureTimelineStills(options) {
|
|
785
|
+
if (options.frames.length === 0) {
|
|
786
|
+
throw new FrameyardError("USAGE", "Still capture requires at least one frame.");
|
|
787
|
+
}
|
|
788
|
+
if (options.frames.length !== options.outPaths.length) {
|
|
789
|
+
throw new FrameyardError("USAGE", "Still frames and output paths must have the same length.");
|
|
790
|
+
}
|
|
791
|
+
for (const frame of options.frames) {
|
|
792
|
+
if (!Number.isInteger(frame) || frame < 0) {
|
|
793
|
+
throw new FrameyardError("FRAME_OUT_OF_RANGE", `--frame must be a non-negative integer; received ${frame}.`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
if (new Set(options.frames).size !== options.frames.length) {
|
|
797
|
+
throw new FrameyardError("USAGE", "Still frames must be unique.");
|
|
798
|
+
}
|
|
799
|
+
if (new Set(options.outPaths).size !== options.outPaths.length) {
|
|
800
|
+
throw new FrameyardError("USAGE", "Still output paths must be unique.");
|
|
801
|
+
}
|
|
802
|
+
for (const outPath of options.outPaths) {
|
|
803
|
+
if (path.extname(outPath).toLowerCase() !== ".png") {
|
|
804
|
+
throw new FrameyardError("USAGE", `Still output must use a .png extension: ${outPath}`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
const overwrite = options.overwrite ?? false;
|
|
808
|
+
const startedAt = performance.now();
|
|
809
|
+
const projectTimeline = options.project.timelines.get(options.timelineId);
|
|
810
|
+
if (!projectTimeline) {
|
|
811
|
+
throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${options.timelineId}" is not listed in video.config.js.`);
|
|
812
|
+
}
|
|
813
|
+
const resolved = await resolveProjectTimeline({
|
|
814
|
+
project: options.project,
|
|
815
|
+
timeline: projectTimeline,
|
|
816
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
817
|
+
...(options.propsOverrides ? { propsOverrides: options.propsOverrides } : {}),
|
|
818
|
+
});
|
|
819
|
+
if (!resolved.timeline) {
|
|
820
|
+
const first = resolved.diagnostics.find((diagnostic) => diagnostic.severity === "error");
|
|
821
|
+
throw new FrameyardError(first?.code ?? "TIMELINE_INVALID", first?.message ?? "Timeline resolution failed.", {
|
|
822
|
+
diagnostics: resolved.diagnostics,
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
const timeline = resolved.timeline;
|
|
826
|
+
const invalidFrames = options.frames.filter((frame) => frame >= timeline.durationInFrames);
|
|
827
|
+
if (invalidFrames.length > 0) {
|
|
828
|
+
const first = invalidFrames[0];
|
|
829
|
+
throw new FrameyardError("FRAME_OUT_OF_RANGE", `Frame ${first} is outside the valid range 0..${timeline.durationInFrames - 1}.`, { details: { invalidFrames, validRange: { min: 0, max: timeline.durationInFrames - 1 } } });
|
|
830
|
+
}
|
|
831
|
+
await assertOutputsAvailable(options.outPaths, overwrite);
|
|
832
|
+
for (const outPath of options.outPaths) {
|
|
833
|
+
await mkdir(path.dirname(outPath), { recursive: true });
|
|
834
|
+
}
|
|
835
|
+
const scratchBase = options.scratchDir ?? tmpdir();
|
|
836
|
+
await mkdir(scratchBase, { recursive: true });
|
|
837
|
+
const scratchRoot = await mkdtemp(path.join(scratchBase, "frameyard-timeline-still-"));
|
|
838
|
+
const tempPaths = options.outPaths.map((outPath) => path.join(path.dirname(outPath), `.${path.basename(outPath)}.frameyard-${process.pid}-${randomUUID()}.png`));
|
|
839
|
+
let server;
|
|
840
|
+
try {
|
|
841
|
+
server = await startPreviewServer({ projectRoot: options.project.root, port: 0 });
|
|
842
|
+
const diagnostics = [];
|
|
843
|
+
for (const [index, frame] of options.frames.entries()) {
|
|
844
|
+
throwIfAborted(options.signal);
|
|
845
|
+
try {
|
|
846
|
+
diagnostics.push(...(await renderResolvedTimelineFrame({
|
|
847
|
+
timeline,
|
|
848
|
+
frame,
|
|
849
|
+
outputPath: tempPaths[index],
|
|
850
|
+
background: timeline.background ?? "black",
|
|
851
|
+
context: {
|
|
852
|
+
project: options.project,
|
|
853
|
+
serverUrl: server.url,
|
|
854
|
+
scratchRoot,
|
|
855
|
+
depth: 0,
|
|
856
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
857
|
+
},
|
|
858
|
+
})));
|
|
859
|
+
}
|
|
860
|
+
catch (error) {
|
|
861
|
+
throw withBatchContext(error, options.frames, frame);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
await assertOutputsAvailable(options.outPaths, overwrite);
|
|
865
|
+
for (const [index, outPath] of options.outPaths.entries()) {
|
|
866
|
+
await publishFile(tempPaths[index], outPath, overwrite);
|
|
867
|
+
}
|
|
868
|
+
const frames = options.frames.map((frame, index) => ({
|
|
869
|
+
frame,
|
|
870
|
+
path: options.outPaths[index],
|
|
871
|
+
}));
|
|
872
|
+
const first = frames[0];
|
|
873
|
+
return {
|
|
874
|
+
timeline: options.timelineId,
|
|
875
|
+
entry: timeline.entry,
|
|
876
|
+
...(frames.length === 1 && first !== undefined ? { frame: first.frame, path: first.path } : {}),
|
|
877
|
+
frames,
|
|
878
|
+
width: timeline.format.width,
|
|
879
|
+
height: timeline.format.height,
|
|
880
|
+
diagnostics: mergeDiagnostics(resolved.diagnostics, diagnostics),
|
|
881
|
+
elapsedMs: performance.now() - startedAt,
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
finally {
|
|
885
|
+
await Promise.all(tempPaths.map((tempPath) => rm(tempPath, { force: true })));
|
|
886
|
+
try {
|
|
887
|
+
await server?.close();
|
|
888
|
+
}
|
|
889
|
+
catch {
|
|
890
|
+
// Preserve the still's original error.
|
|
891
|
+
}
|
|
892
|
+
await rm(scratchRoot, { recursive: true, force: true });
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
/** Captures one timeline frame as a PNG through the clip capture and composite path. */
|
|
896
|
+
export async function captureTimelineStill(options) {
|
|
897
|
+
const { frame, outPath, ...rest } = options;
|
|
898
|
+
return captureTimelineStills({
|
|
899
|
+
...rest,
|
|
900
|
+
frames: [frame],
|
|
901
|
+
outPaths: [outPath],
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
/** Renders one timeline to MP4 through the segmented lossless clip pipeline. */
|
|
905
|
+
export async function renderTimeline(options) {
|
|
906
|
+
const container = options.container ?? "mp4";
|
|
907
|
+
const codec = options.codec ?? defaultCodecForContainer(container);
|
|
908
|
+
assertContainerCodec(container, codec);
|
|
909
|
+
const extension = `.${CONTAINER_EXTENSION[container]}`;
|
|
910
|
+
if (path.extname(options.outPath).toLowerCase() !== extension) {
|
|
911
|
+
throw new FrameyardError("USAGE", `Timeline output for --container ${container} must use a ${extension} extension: ${options.outPath}`);
|
|
912
|
+
}
|
|
913
|
+
const requestedWorkers = validateWorkers(options.workers);
|
|
914
|
+
const overwrite = options.overwrite ?? false;
|
|
915
|
+
const startedAt = performance.now();
|
|
916
|
+
await ensureOutputAvailable(options.outPath, overwrite);
|
|
917
|
+
await assertFfmpegCapabilities(options.signal, codec);
|
|
918
|
+
const encoder = await resolveVideoEncoder(codec, options.encoder, options.signal);
|
|
919
|
+
const workers = clampTimelineWorkers(requestedWorkers, encoder);
|
|
920
|
+
const projectTimeline = options.project.timelines.get(options.timelineId);
|
|
921
|
+
if (!projectTimeline) {
|
|
922
|
+
throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${options.timelineId}" is not listed in video.config.js.`);
|
|
923
|
+
}
|
|
924
|
+
const resolved = await resolveProjectTimeline({
|
|
925
|
+
project: options.project,
|
|
926
|
+
timeline: projectTimeline,
|
|
927
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
928
|
+
...(options.propsOverrides ? { propsOverrides: options.propsOverrides } : {}),
|
|
929
|
+
});
|
|
930
|
+
if (!resolved.timeline) {
|
|
931
|
+
const first = resolved.diagnostics.find((diagnostic) => diagnostic.severity === "error");
|
|
932
|
+
throw new FrameyardError(first?.code ?? "TIMELINE_INVALID", first?.message ?? "Timeline resolution failed.", {
|
|
933
|
+
diagnostics: resolved.diagnostics,
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
const timeline = resolved.timeline;
|
|
937
|
+
const plan = planTimelineRender(timeline, { maxSegmentFrames: options.maxSegmentFrames });
|
|
938
|
+
const scratchBase = options.scratchDir ?? tmpdir();
|
|
939
|
+
await mkdir(scratchBase, { recursive: true });
|
|
940
|
+
const scratchRoot = await mkdtemp(path.join(scratchBase, "frameyard-timeline-"));
|
|
941
|
+
const server = await startPreviewServer({ projectRoot: options.project.root, port: 0 });
|
|
942
|
+
const infoCache = new Map();
|
|
943
|
+
const diagnostics = [...resolved.diagnostics];
|
|
944
|
+
if (workers < requestedWorkers) {
|
|
945
|
+
diagnostics.push({
|
|
946
|
+
severity: "warning",
|
|
947
|
+
code: "ENCODER_WARNING",
|
|
948
|
+
message: `Encoder ${encoder} allows ${workers} concurrent encode sessions; using ${workers} workers instead of ${requestedWorkers}.`,
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
const total = timeline.durationInFrames;
|
|
952
|
+
const segmentResults = Array.from({
|
|
953
|
+
length: plan.segments.length,
|
|
954
|
+
});
|
|
955
|
+
let nextSegment = 0;
|
|
956
|
+
const audio = buildAudioClips(timeline);
|
|
957
|
+
const audioInspection = audio.clips.length
|
|
958
|
+
? await inspectTimelineAudio({
|
|
959
|
+
projectRoot: options.project.root,
|
|
960
|
+
clips: audio.clips,
|
|
961
|
+
fps: timeline.format.fps,
|
|
962
|
+
durationInFrames: total,
|
|
963
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
964
|
+
})
|
|
965
|
+
: { clips: [], assets: [], warnings: [] };
|
|
966
|
+
diagnostics.push(...audioInspection.warnings.map((message) => ({
|
|
967
|
+
severity: "warning",
|
|
968
|
+
code: "AUDIO_WARNING",
|
|
969
|
+
message,
|
|
970
|
+
})));
|
|
971
|
+
const transcripts = audio.transcripts.length
|
|
972
|
+
? await inspectTranscriptClips({
|
|
973
|
+
projectRoot: options.project.root,
|
|
974
|
+
clips: audio.transcripts,
|
|
975
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
976
|
+
})
|
|
977
|
+
: { clips: [], assets: [], warnings: [] };
|
|
978
|
+
diagnostics.push(...transcripts.warnings.map((message) => ({
|
|
979
|
+
severity: "warning",
|
|
980
|
+
code: "TRANSCRIPT_WARNING",
|
|
981
|
+
message,
|
|
982
|
+
})));
|
|
983
|
+
const tempVideo = path.join(path.dirname(options.outPath), `.${path.basename(options.outPath)}.frameyard-${process.pid}-${randomUUID()}.video.mp4`);
|
|
984
|
+
const tempFinal = path.join(path.dirname(options.outPath), `.${path.basename(options.outPath)}.frameyard-${process.pid}-${randomUUID()}.mp4`);
|
|
985
|
+
try {
|
|
986
|
+
const worker = async () => {
|
|
987
|
+
while (true) {
|
|
988
|
+
throwIfAborted(options.signal);
|
|
989
|
+
const index = nextSegment;
|
|
990
|
+
nextSegment += 1;
|
|
991
|
+
if (index >= plan.segments.length) {
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
const segment = plan.segments[index];
|
|
995
|
+
const result = await processTimelineSegment({
|
|
996
|
+
project: options.project,
|
|
997
|
+
timeline,
|
|
998
|
+
segment,
|
|
999
|
+
serverUrl: server.url,
|
|
1000
|
+
scratchRoot,
|
|
1001
|
+
infoCache,
|
|
1002
|
+
codec,
|
|
1003
|
+
container,
|
|
1004
|
+
encoder,
|
|
1005
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
1006
|
+
});
|
|
1007
|
+
segmentResults[index] = result;
|
|
1008
|
+
options.onProgress?.({
|
|
1009
|
+
frame: segment.end,
|
|
1010
|
+
total,
|
|
1011
|
+
elapsedMs: performance.now() - startedAt,
|
|
1012
|
+
segment: index + 1,
|
|
1013
|
+
segments: plan.segments.length,
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
};
|
|
1017
|
+
await Promise.all(Array.from({ length: workers }, () => worker()));
|
|
1018
|
+
const segmentPaths = segmentResults.map((result) => {
|
|
1019
|
+
if (!result) {
|
|
1020
|
+
throw new FrameyardError("INTERNAL", "A timeline segment was not rendered.");
|
|
1021
|
+
}
|
|
1022
|
+
diagnostics.push(...result.diagnostics);
|
|
1023
|
+
return result.path;
|
|
1024
|
+
});
|
|
1025
|
+
await concatTimelineSegments({
|
|
1026
|
+
segmentPaths,
|
|
1027
|
+
listPath: path.join(scratchRoot, "segments.txt"),
|
|
1028
|
+
outputPath: tempVideo,
|
|
1029
|
+
codec,
|
|
1030
|
+
container,
|
|
1031
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
1032
|
+
});
|
|
1033
|
+
let finalPath = tempVideo;
|
|
1034
|
+
if (audioInspection.assets.length > 0) {
|
|
1035
|
+
const audioPath = path.join(scratchRoot, "audio.wav");
|
|
1036
|
+
await mixAudioTimeline({
|
|
1037
|
+
clips: audioInspection.assets.map((asset) => ({
|
|
1038
|
+
path: asset.path,
|
|
1039
|
+
startFrame: asset.clip.startFrame,
|
|
1040
|
+
trimStart: asset.clip.trimStart,
|
|
1041
|
+
...(asset.clip.trimDuration !== undefined ? { trimDuration: asset.clip.trimDuration } : {}),
|
|
1042
|
+
volume: asset.clip.volume,
|
|
1043
|
+
})),
|
|
1044
|
+
fps: timeline.format.fps,
|
|
1045
|
+
durationSeconds: total / timeline.format.fps,
|
|
1046
|
+
outputPath: audioPath,
|
|
1047
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
1048
|
+
});
|
|
1049
|
+
await muxVideoWithAudio({
|
|
1050
|
+
videoPath: tempVideo,
|
|
1051
|
+
audioPath,
|
|
1052
|
+
outputPath: tempFinal,
|
|
1053
|
+
codec,
|
|
1054
|
+
container,
|
|
1055
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
1056
|
+
});
|
|
1057
|
+
finalPath = tempFinal;
|
|
1058
|
+
}
|
|
1059
|
+
if (!overwrite && (await pathExists(options.outPath))) {
|
|
1060
|
+
throw new FrameyardError("OUTPUT_EXISTS", `Output already exists: ${options.outPath}. Pass --overwrite to replace it.`);
|
|
1061
|
+
}
|
|
1062
|
+
await publishFile(finalPath, options.outPath, overwrite);
|
|
1063
|
+
const uniqueDiagnostics = mergeDiagnostics(diagnostics);
|
|
1064
|
+
return {
|
|
1065
|
+
timeline: options.timelineId,
|
|
1066
|
+
entry: timeline.entry,
|
|
1067
|
+
path: options.outPath,
|
|
1068
|
+
width: timeline.format.width,
|
|
1069
|
+
height: timeline.format.height,
|
|
1070
|
+
fps: timeline.format.fps,
|
|
1071
|
+
durationInFrames: total,
|
|
1072
|
+
durationSeconds: total / timeline.format.fps,
|
|
1073
|
+
framesRendered: total,
|
|
1074
|
+
segments: plan.segments.length,
|
|
1075
|
+
workers,
|
|
1076
|
+
codec,
|
|
1077
|
+
container,
|
|
1078
|
+
encoder,
|
|
1079
|
+
bytes: (await stat(options.outPath)).size,
|
|
1080
|
+
audio: { clips: audioInspection.clips.length },
|
|
1081
|
+
transcripts: { clips: transcripts.clips.length },
|
|
1082
|
+
diagnostics: uniqueDiagnostics,
|
|
1083
|
+
elapsedMs: performance.now() - startedAt,
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
finally {
|
|
1087
|
+
await rm(tempVideo, { force: true });
|
|
1088
|
+
await rm(tempFinal, { force: true });
|
|
1089
|
+
try {
|
|
1090
|
+
await server.close();
|
|
1091
|
+
}
|
|
1092
|
+
catch {
|
|
1093
|
+
// Preserve the render's original error.
|
|
1094
|
+
}
|
|
1095
|
+
await rm(scratchRoot, { recursive: true, force: true });
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
//# sourceMappingURL=timeline.js.map
|