frameyard 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +106 -0
- package/GLOSSARY.md +40 -16
- package/README.md +64 -55
- package/dist/assets/process.js.map +1 -1
- package/dist/assets/provenance.js.map +1 -1
- package/dist/cli.js +255 -59
- package/dist/cli.js.map +1 -1
- package/dist/composition/attributes.d.ts.map +1 -1
- package/dist/composition/descriptor.d.ts +17 -3
- package/dist/composition/descriptor.d.ts.map +1 -1
- package/dist/composition/descriptor.js +51 -28
- package/dist/composition/descriptor.js.map +1 -1
- package/dist/composition/diagnostics.js.map +1 -1
- package/dist/composition/format.d.ts +12 -0
- package/dist/composition/format.d.ts.map +1 -0
- package/dist/composition/format.js +28 -0
- package/dist/composition/format.js.map +1 -0
- package/dist/composition/index.d.ts +2 -1
- package/dist/composition/index.d.ts.map +1 -1
- package/dist/composition/index.js +1 -0
- package/dist/composition/index.js.map +1 -1
- package/dist/contracts.d.ts +55 -6
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +3 -0
- 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/document-adapter.d.ts +3 -3
- package/dist/html/document-adapter.d.ts.map +1 -1
- package/dist/html/document-adapter.js +1 -1
- package/dist/html/document-adapter.js.map +1 -1
- package/dist/html/index.d.ts +1 -1
- package/dist/html/index.d.ts.map +1 -1
- package/dist/html/parse.d.ts +10 -2
- package/dist/html/parse.d.ts.map +1 -1
- package/dist/html/parse.js +14 -5
- package/dist/html/parse.js.map +1 -1
- package/dist/html/validate.d.ts +12 -1
- package/dist/html/validate.d.ts.map +1 -1
- package/dist/html/validate.js +233 -61
- package/dist/html/validate.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/preview/main.d.ts +2 -2
- package/dist/preview/main.d.ts.map +1 -1
- package/dist/preview/main.js +47 -19
- package/dist/preview/main.js.map +1 -1
- package/dist/preview/timeline-data.d.ts +5 -1
- package/dist/preview/timeline-data.d.ts.map +1 -1
- package/dist/preview/timeline-host.d.ts +2 -0
- package/dist/preview/timeline-host.d.ts.map +1 -1
- package/dist/preview/timeline-host.js +31 -10
- package/dist/preview/timeline-host.js.map +1 -1
- package/dist/preview/timeline-main.js +12 -7
- package/dist/preview/timeline-main.js.map +1 -1
- package/dist/project.d.ts +3 -5
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js.map +1 -1
- package/dist/renderer/browser.d.ts +2 -2
- package/dist/renderer/browser.d.ts.map +1 -1
- package/dist/renderer/browser.js +10 -4
- package/dist/renderer/browser.js.map +1 -1
- package/dist/renderer/ffmpeg.d.ts +69 -2
- package/dist/renderer/ffmpeg.d.ts.map +1 -1
- package/dist/renderer/ffmpeg.js +381 -69
- package/dist/renderer/ffmpeg.js.map +1 -1
- package/dist/renderer/index.d.ts +66 -5
- package/dist/renderer/index.d.ts.map +1 -1
- package/dist/renderer/index.js +226 -51
- package/dist/renderer/index.js.map +1 -1
- package/dist/renderer/timeline.d.ts +41 -4
- package/dist/renderer/timeline.d.ts.map +1 -1
- package/dist/renderer/timeline.js +242 -39
- package/dist/renderer/timeline.js.map +1 -1
- package/dist/runtime/host.d.ts.map +1 -1
- package/dist/runtime/host.js +155 -31
- 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 +107 -103
- package/dist/scaffold.js.map +1 -1
- package/dist/server/composition-html.d.ts +13 -7
- package/dist/server/composition-html.d.ts.map +1 -1
- package/dist/server/composition-html.js +65 -16
- package/dist/server/composition-html.js.map +1 -1
- package/dist/server/preview-html.js +1 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +26 -6
- package/dist/server/server.js.map +1 -1
- package/dist/server/timeline-preview-data.d.ts.map +1 -1
- package/dist/server/timeline-preview-data.js +11 -0
- package/dist/server/timeline-preview-data.js.map +1 -1
- package/dist/server/timeline-preview-html.d.ts.map +1 -1
- package/dist/server/timeline-preview-html.js +2 -1
- package/dist/server/timeline-preview-html.js.map +1 -1
- package/dist/timeline/plan.js.map +1 -1
- package/dist/timeline/resolve.d.ts +4 -3
- package/dist/timeline/resolve.d.ts.map +1 -1
- package/dist/timeline/resolve.js +14 -4
- package/dist/timeline/resolve.js.map +1 -1
- package/dist/timeline/schema.d.ts +7 -0
- package/dist/timeline/schema.d.ts.map +1 -1
- package/dist/timeline/schema.js +50 -0
- package/dist/timeline/schema.js.map +1 -1
- package/dist/transcript.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/adr/0007-make-html-documents-the-composition-unit.md +1 -1
- package/docs/adr/0008-share-the-composition-document-grammar.md +1 -1
- package/docs/adr/0011-compose-timeline-frames-in-a-live-browser-host.md +1 -1
- package/docs/adr/0012-render-timelines-with-segmented-lossless-clip-intermediates.md +1 -1
- package/docs/adr/0013-capture-bounded-multi-frame-stills-for-inspection.md +33 -0
- package/docs/adr/0014-compose-timeline-clips-with-css-blend-modes.md +37 -0
- package/docs/adr/0015-inherit-composition-format-from-the-project.md +28 -0
- package/docs/adr/0016-author-compositions-as-html-fragments.md +32 -0
- package/docs/adr/0017-render-timeless-compositions-as-stills.md +99 -0
- package/docs/migration-0.4.md +3 -1
- package/docs/migration-0.8.md +163 -0
- package/docs/preview-testing.md +60 -0
- package/docs/v0.1-design.md +52 -42
- package/examples/blend/README.md +106 -0
- package/examples/blend/assets/ink.mp4 +0 -0
- package/examples/blend/compositions/aurora.html +55 -0
- package/examples/blend/compositions/backdrop.html +74 -0
- package/examples/blend/compositions/labels.html +149 -0
- package/examples/blend/scripts/make-ink.sh +26 -0
- package/examples/blend/timelines/blend.timeline.json +155 -0
- package/examples/blend/video.config.js +11 -0
- package/examples/demo/compositions/demo.html +243 -251
- package/examples/static/README.md +17 -0
- package/examples/static/compositions/still.html +37 -0
- package/examples/static/timelines/still.timeline.json +13 -0
- package/examples/static/video.config.js +9 -0
- package/examples/video/compositions/video-mp4.html +349 -357
- package/examples/video/compositions/video-overlay.html +337 -345
- package/examples/video/compositions/video.html +349 -357
- package/examples/webgl/compositions/webgl.html +205 -213
- package/package.json +6 -3
- package/skills/frameyard/README.md +7 -5
- package/skills/frameyard/SKILL.md +17 -13
- package/skills/frameyard/references/common-mistakes.md +30 -6
- package/skills/frameyard/references/controllers.md +5 -4
- package/skills/frameyard/references/deterministic-authoring.md +42 -36
- package/skills/frameyard/references/diagnostics.md +11 -3
- package/skills/frameyard/references/failure-recovery.md +28 -7
- package/skills/frameyard/references/json-results.md +58 -7
- package/skills/frameyard/references/subtitles-karaoke.md +2 -2
- package/skills/frameyard/references/timelines.md +21 -15
- package/skills/frameyard/scripts/frameyard.mjs +20 -3
|
@@ -2,12 +2,13 @@ import { link, mkdir, mkdtemp, rename, rm, stat, writeFile } from "node:fs/promi
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { isPlainObject, } from "../contracts.js";
|
|
6
|
+
import { FrameyardError, isFrameyardError } from "../errors.js";
|
|
6
7
|
import { validateHtmlComposition } from "../html/validate.js";
|
|
7
8
|
import { startPreviewServer } from "../server/server.js";
|
|
8
9
|
import { inspectTimelineAudio } from "./audio.js";
|
|
9
10
|
import { openCompositionSession } from "./browser.js";
|
|
10
|
-
import { assertFfmpegCapabilities, compositeTimelineFrame, compositeTimelineSegment, concatTimelineSegments, encodePngSequenceToFfv1, mixAudioTimeline, muxVideoWithAudio, } from "./ffmpeg.js";
|
|
11
|
+
import { assertContainerCodec, assertFfmpegCapabilities, compositeTimelineFrame, compositeTimelineSegment, concatTimelineSegments, CONTAINER_EXTENSION, defaultCodecForContainer, ENCODER_SESSION_LIMIT, encodePngSequenceToFfv1, mixAudioTimeline, muxVideoWithAudio, resolveVideoEncoder, } from "./ffmpeg.js";
|
|
11
12
|
import { inspectTranscriptClips } from "./transcripts.js";
|
|
12
13
|
import { planTimelineRender } from "../timeline/plan.js";
|
|
13
14
|
import { resolveProjectTimeline, } from "../timeline/resolve.js";
|
|
@@ -22,6 +23,13 @@ function validateWorkers(value) {
|
|
|
22
23
|
}
|
|
23
24
|
return workers;
|
|
24
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
|
+
}
|
|
25
33
|
function throwIfAborted(signal) {
|
|
26
34
|
if (signal?.aborted) {
|
|
27
35
|
throw new FrameyardError("RENDER_FAILED", "Operation cancelled.");
|
|
@@ -42,6 +50,25 @@ async function ensureOutputAvailable(outPath, overwrite) {
|
|
|
42
50
|
}
|
|
43
51
|
await mkdir(path.dirname(outPath), { recursive: true });
|
|
44
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
|
+
}
|
|
45
72
|
async function publishFile(tempPath, outPath, overwrite) {
|
|
46
73
|
if (overwrite) {
|
|
47
74
|
await rename(tempPath, outPath);
|
|
@@ -79,6 +106,21 @@ function mergeDiagnostics(...groups) {
|
|
|
79
106
|
}
|
|
80
107
|
return result;
|
|
81
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
|
+
}
|
|
82
124
|
function compositionDiagnostic(diagnostic, timelineId, clip, compositionId) {
|
|
83
125
|
return {
|
|
84
126
|
...diagnostic,
|
|
@@ -227,6 +269,58 @@ export function resolveTimelineAudio(timeline) {
|
|
|
227
269
|
transcripts,
|
|
228
270
|
};
|
|
229
271
|
}
|
|
272
|
+
async function captureStaticCompositionLayer(options) {
|
|
273
|
+
const { project, timeline, clip, source, serverUrl, scratchRoot, infoCache, staticCache } = options;
|
|
274
|
+
const propsKey = JSON.stringify(clip.props ?? null);
|
|
275
|
+
const cacheKey = `${source.entry}\u0000${propsKey}`;
|
|
276
|
+
const cached = staticCache.get(cacheKey);
|
|
277
|
+
if (cached) {
|
|
278
|
+
return cached;
|
|
279
|
+
}
|
|
280
|
+
const pending = (async () => {
|
|
281
|
+
let validation = infoCache.get(cacheKey);
|
|
282
|
+
if (!validation) {
|
|
283
|
+
validation = await validateHtmlComposition({
|
|
284
|
+
projectRoot: project.root,
|
|
285
|
+
entry: source.entry,
|
|
286
|
+
...(clip.props !== undefined ? { props: clip.props } : {}),
|
|
287
|
+
...(project.format ? { format: project.format } : {}),
|
|
288
|
+
});
|
|
289
|
+
infoCache.set(cacheKey, validation);
|
|
290
|
+
}
|
|
291
|
+
if (!validation.info) {
|
|
292
|
+
throw new FrameyardError("TIMELINE_CLIP_INVALID", `Composition "${source.id}" could not be loaded for clip "${clip.id}".`, { diagnostics: validation.diagnostics });
|
|
293
|
+
}
|
|
294
|
+
const session = await openCompositionSession({
|
|
295
|
+
serverUrl,
|
|
296
|
+
entry: source.entry,
|
|
297
|
+
props: clip.props,
|
|
298
|
+
info: validation.info,
|
|
299
|
+
staticDiagnostics: validation.diagnostics,
|
|
300
|
+
transparentBackground: true,
|
|
301
|
+
});
|
|
302
|
+
try {
|
|
303
|
+
throwIfAborted(options.signal);
|
|
304
|
+
await session.seek(0);
|
|
305
|
+
const png = await session.capture();
|
|
306
|
+
const filePath = path.join(scratchRoot, `static-${randomUUID()}.png`);
|
|
307
|
+
await writeFile(filePath, png);
|
|
308
|
+
const diagnostics = (await session.readDiagnostics()).map((diagnostic) => compositionDiagnostic(diagnostic, timeline.id, clip, source.id));
|
|
309
|
+
return { path: filePath, diagnostics };
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
await session.close();
|
|
313
|
+
}
|
|
314
|
+
})();
|
|
315
|
+
staticCache.set(cacheKey, pending);
|
|
316
|
+
try {
|
|
317
|
+
return await pending;
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
staticCache.delete(cacheKey);
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
230
324
|
async function captureCompositionLayer(options) {
|
|
231
325
|
const { project, timeline, clip, source, segment, layerIndex, serverUrl, segmentDir, infoCache } = options;
|
|
232
326
|
const propsKey = JSON.stringify(clip.props ?? null);
|
|
@@ -237,6 +331,7 @@ async function captureCompositionLayer(options) {
|
|
|
237
331
|
projectRoot: project.root,
|
|
238
332
|
entry: source.entry,
|
|
239
333
|
...(clip.props !== undefined ? { props: clip.props } : {}),
|
|
334
|
+
...(project.format ? { format: project.format } : {}),
|
|
240
335
|
});
|
|
241
336
|
infoCache.set(cacheKey, validation);
|
|
242
337
|
}
|
|
@@ -299,6 +394,7 @@ async function renderNestedTimelineRange(options) {
|
|
|
299
394
|
serverUrl: options.serverUrl,
|
|
300
395
|
scratchRoot: options.scratchRoot,
|
|
301
396
|
infoCache: options.infoCache,
|
|
397
|
+
staticCache: options.staticCache,
|
|
302
398
|
codec: "ffv1",
|
|
303
399
|
background: options.timeline.background ?? "transparent",
|
|
304
400
|
prefix,
|
|
@@ -315,6 +411,8 @@ async function renderNestedTimelineRange(options) {
|
|
|
315
411
|
segmentPaths: results.map((result) => result.path),
|
|
316
412
|
listPath: path.join(options.scratchRoot, `${prefix}-segments.txt`),
|
|
317
413
|
outputPath,
|
|
414
|
+
// Nested timeline segments are always lossless FFV1 in Matroska.
|
|
415
|
+
container: "mkv",
|
|
318
416
|
...(options.signal ? { signal: options.signal } : {}),
|
|
319
417
|
});
|
|
320
418
|
return { path: outputPath, diagnostics };
|
|
@@ -351,8 +449,31 @@ async function processTimelineSegment(options) {
|
|
|
351
449
|
scale: transform.scale,
|
|
352
450
|
rotation: transform.rotation,
|
|
353
451
|
opacity: transform.opacity,
|
|
452
|
+
blend: clip.blend ?? "normal",
|
|
354
453
|
};
|
|
355
454
|
if (source.kind === "composition") {
|
|
455
|
+
if (source.metadata.static === true) {
|
|
456
|
+
const captured = await captureStaticCompositionLayer({
|
|
457
|
+
project,
|
|
458
|
+
timeline,
|
|
459
|
+
clip,
|
|
460
|
+
source,
|
|
461
|
+
serverUrl,
|
|
462
|
+
scratchRoot,
|
|
463
|
+
infoCache,
|
|
464
|
+
staticCache: options.staticCache,
|
|
465
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
466
|
+
});
|
|
467
|
+
diagnostics.push(...captured.diagnostics);
|
|
468
|
+
layers.push({
|
|
469
|
+
kind: "image",
|
|
470
|
+
path: captured.path,
|
|
471
|
+
sourceWidth: source.metadata.width,
|
|
472
|
+
sourceHeight: source.metadata.height,
|
|
473
|
+
...base,
|
|
474
|
+
});
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
356
477
|
const captured = await captureCompositionLayer({
|
|
357
478
|
project,
|
|
358
479
|
timeline,
|
|
@@ -396,6 +517,7 @@ async function processTimelineSegment(options) {
|
|
|
396
517
|
serverUrl,
|
|
397
518
|
scratchRoot,
|
|
398
519
|
infoCache,
|
|
520
|
+
staticCache: options.staticCache,
|
|
399
521
|
depth: depth + 1,
|
|
400
522
|
...(options.signal ? { signal: options.signal } : {}),
|
|
401
523
|
});
|
|
@@ -411,7 +533,7 @@ async function processTimelineSegment(options) {
|
|
|
411
533
|
}
|
|
412
534
|
throw new FrameyardError("TIMELINE_INVALID", `Unsupported visual source for clip "${clip.id}".`);
|
|
413
535
|
}
|
|
414
|
-
const extension = codec === "ffv1" ? "mkv" : "mp4";
|
|
536
|
+
const extension = codec === "ffv1" ? "mkv" : CONTAINER_EXTENSION[options.container ?? "mp4"];
|
|
415
537
|
const segmentPath = path.join(scratchRoot, `${prefix}-segment-${String(segment.index).padStart(4, "0")}.${extension}`);
|
|
416
538
|
await compositeTimelineSegment({
|
|
417
539
|
outputPath: segmentPath,
|
|
@@ -422,6 +544,8 @@ async function processTimelineSegment(options) {
|
|
|
422
544
|
background,
|
|
423
545
|
layers,
|
|
424
546
|
codec,
|
|
547
|
+
...(options.container !== undefined ? { container: options.container } : {}),
|
|
548
|
+
...(options.encoder !== undefined ? { encoder: options.encoder } : {}),
|
|
425
549
|
...(options.signal ? { signal: options.signal } : {}),
|
|
426
550
|
});
|
|
427
551
|
await rm(segmentDir, { recursive: true, force: true });
|
|
@@ -447,6 +571,7 @@ async function checkResolvedTimelineRange(options) {
|
|
|
447
571
|
projectRoot: context.project.root,
|
|
448
572
|
entry: source.entry,
|
|
449
573
|
...(layer.clip.props !== undefined ? { props: layer.clip.props } : {}),
|
|
574
|
+
...(context.project.format ? { format: context.project.format } : {}),
|
|
450
575
|
});
|
|
451
576
|
if (!validation.info) {
|
|
452
577
|
throw new FrameyardError("TIMELINE_CLIP_INVALID", `Composition "${source.id}" could not be loaded for clip "${layer.clip.id}".`, { diagnostics: validation.diagnostics });
|
|
@@ -466,6 +591,9 @@ async function checkResolvedTimelineRange(options) {
|
|
|
466
591
|
staticDiagnostics: target.validation.diagnostics,
|
|
467
592
|
});
|
|
468
593
|
sessions.set(key, session);
|
|
594
|
+
if (target.source.metadata.static === true) {
|
|
595
|
+
await session.seek(0);
|
|
596
|
+
}
|
|
469
597
|
}
|
|
470
598
|
for (const segment of plan.segments) {
|
|
471
599
|
for (let frame = segment.start; frame < segment.end; frame += 1) {
|
|
@@ -475,6 +603,9 @@ async function checkResolvedTimelineRange(options) {
|
|
|
475
603
|
continue;
|
|
476
604
|
}
|
|
477
605
|
const source = layer.clip.source;
|
|
606
|
+
if (source.metadata.static === true) {
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
478
609
|
const key = `${source.entry}\u0000${JSON.stringify(layer.clip.props ?? null)}`;
|
|
479
610
|
const session = sessions.get(key);
|
|
480
611
|
if (!session) {
|
|
@@ -651,12 +782,14 @@ async function renderResolvedTimelineFrame(options) {
|
|
|
651
782
|
scale: transform.scale,
|
|
652
783
|
rotation: transform.rotation,
|
|
653
784
|
opacity: transform.opacity,
|
|
785
|
+
blend: clip.blend ?? "normal",
|
|
654
786
|
};
|
|
655
787
|
if (source.kind === "composition") {
|
|
656
788
|
const validation = await validateHtmlComposition({
|
|
657
789
|
projectRoot: context.project.root,
|
|
658
790
|
entry: source.entry,
|
|
659
791
|
...(clip.props !== undefined ? { props: clip.props } : {}),
|
|
792
|
+
...(context.project.format ? { format: context.project.format } : {}),
|
|
660
793
|
});
|
|
661
794
|
if (!validation.info) {
|
|
662
795
|
throw new FrameyardError("TIMELINE_CLIP_INVALID", `Composition "${source.id}" could not be loaded for clip "${clip.id}".`, { diagnostics: validation.diagnostics });
|
|
@@ -734,16 +867,31 @@ async function renderResolvedTimelineFrame(options) {
|
|
|
734
867
|
});
|
|
735
868
|
return diagnostics;
|
|
736
869
|
}
|
|
737
|
-
/** Captures
|
|
738
|
-
export async function
|
|
739
|
-
if (
|
|
740
|
-
throw new FrameyardError("
|
|
870
|
+
/** Captures timeline frames as PNGs through the clip capture and composite path. */
|
|
871
|
+
export async function captureTimelineStills(options) {
|
|
872
|
+
if (options.frames.length === 0) {
|
|
873
|
+
throw new FrameyardError("USAGE", "Still capture requires at least one frame.");
|
|
874
|
+
}
|
|
875
|
+
if (options.frames.length !== options.outPaths.length) {
|
|
876
|
+
throw new FrameyardError("USAGE", "Still frames and output paths must have the same length.");
|
|
877
|
+
}
|
|
878
|
+
for (const frame of options.frames) {
|
|
879
|
+
if (!Number.isInteger(frame) || frame < 0) {
|
|
880
|
+
throw new FrameyardError("FRAME_OUT_OF_RANGE", `--frame must be a non-negative integer; received ${frame}.`);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
if (new Set(options.frames).size !== options.frames.length) {
|
|
884
|
+
throw new FrameyardError("USAGE", "Still frames must be unique.");
|
|
741
885
|
}
|
|
742
|
-
if (
|
|
743
|
-
throw new FrameyardError("USAGE",
|
|
886
|
+
if (new Set(options.outPaths).size !== options.outPaths.length) {
|
|
887
|
+
throw new FrameyardError("USAGE", "Still output paths must be unique.");
|
|
888
|
+
}
|
|
889
|
+
for (const outPath of options.outPaths) {
|
|
890
|
+
if (path.extname(outPath).toLowerCase() !== ".png") {
|
|
891
|
+
throw new FrameyardError("USAGE", `Still output must use a .png extension: ${outPath}`);
|
|
892
|
+
}
|
|
744
893
|
}
|
|
745
894
|
const overwrite = options.overwrite ?? false;
|
|
746
|
-
await ensureOutputAvailable(options.outPath, overwrite);
|
|
747
895
|
const startedAt = performance.now();
|
|
748
896
|
const projectTimeline = options.project.timelines.get(options.timelineId);
|
|
749
897
|
if (!projectTimeline) {
|
|
@@ -762,37 +910,58 @@ export async function captureTimelineStill(options) {
|
|
|
762
910
|
});
|
|
763
911
|
}
|
|
764
912
|
const timeline = resolved.timeline;
|
|
765
|
-
|
|
766
|
-
|
|
913
|
+
const invalidFrames = options.frames.filter((frame) => frame >= timeline.durationInFrames);
|
|
914
|
+
if (invalidFrames.length > 0) {
|
|
915
|
+
const first = invalidFrames[0];
|
|
916
|
+
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 } } });
|
|
917
|
+
}
|
|
918
|
+
await assertOutputsAvailable(options.outPaths, overwrite);
|
|
919
|
+
for (const outPath of options.outPaths) {
|
|
920
|
+
await mkdir(path.dirname(outPath), { recursive: true });
|
|
767
921
|
}
|
|
768
922
|
const scratchBase = options.scratchDir ?? tmpdir();
|
|
769
923
|
await mkdir(scratchBase, { recursive: true });
|
|
770
924
|
const scratchRoot = await mkdtemp(path.join(scratchBase, "frameyard-timeline-still-"));
|
|
771
|
-
const
|
|
772
|
-
|
|
925
|
+
const tempPaths = options.outPaths.map((outPath) => path.join(path.dirname(outPath), `.${path.basename(outPath)}.frameyard-${process.pid}-${randomUUID()}.png`));
|
|
926
|
+
let server;
|
|
773
927
|
try {
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
928
|
+
server = await startPreviewServer({ projectRoot: options.project.root, port: 0 });
|
|
929
|
+
const diagnostics = [];
|
|
930
|
+
for (const [index, frame] of options.frames.entries()) {
|
|
931
|
+
throwIfAborted(options.signal);
|
|
932
|
+
try {
|
|
933
|
+
diagnostics.push(...(await renderResolvedTimelineFrame({
|
|
934
|
+
timeline,
|
|
935
|
+
frame,
|
|
936
|
+
outputPath: tempPaths[index],
|
|
937
|
+
background: timeline.background ?? "black",
|
|
938
|
+
context: {
|
|
939
|
+
project: options.project,
|
|
940
|
+
serverUrl: server.url,
|
|
941
|
+
scratchRoot,
|
|
942
|
+
depth: 0,
|
|
943
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
944
|
+
},
|
|
945
|
+
})));
|
|
946
|
+
}
|
|
947
|
+
catch (error) {
|
|
948
|
+
throw withBatchContext(error, options.frames, frame);
|
|
949
|
+
}
|
|
789
950
|
}
|
|
790
|
-
await
|
|
951
|
+
await assertOutputsAvailable(options.outPaths, overwrite);
|
|
952
|
+
for (const [index, outPath] of options.outPaths.entries()) {
|
|
953
|
+
await publishFile(tempPaths[index], outPath, overwrite);
|
|
954
|
+
}
|
|
955
|
+
const frames = options.frames.map((frame, index) => ({
|
|
956
|
+
frame,
|
|
957
|
+
path: options.outPaths[index],
|
|
958
|
+
}));
|
|
959
|
+
const first = frames[0];
|
|
791
960
|
return {
|
|
792
961
|
timeline: options.timelineId,
|
|
793
962
|
entry: timeline.entry,
|
|
794
|
-
frame:
|
|
795
|
-
|
|
963
|
+
...(frames.length === 1 && first !== undefined ? { frame: first.frame, path: first.path } : {}),
|
|
964
|
+
frames,
|
|
796
965
|
width: timeline.format.width,
|
|
797
966
|
height: timeline.format.height,
|
|
798
967
|
diagnostics: mergeDiagnostics(resolved.diagnostics, diagnostics),
|
|
@@ -800,9 +969,9 @@ export async function captureTimelineStill(options) {
|
|
|
800
969
|
};
|
|
801
970
|
}
|
|
802
971
|
finally {
|
|
803
|
-
await rm(tempPath, { force: true });
|
|
972
|
+
await Promise.all(tempPaths.map((tempPath) => rm(tempPath, { force: true })));
|
|
804
973
|
try {
|
|
805
|
-
await server
|
|
974
|
+
await server?.close();
|
|
806
975
|
}
|
|
807
976
|
catch {
|
|
808
977
|
// Preserve the still's original error.
|
|
@@ -810,16 +979,31 @@ export async function captureTimelineStill(options) {
|
|
|
810
979
|
await rm(scratchRoot, { recursive: true, force: true });
|
|
811
980
|
}
|
|
812
981
|
}
|
|
982
|
+
/** Captures one timeline frame as a PNG through the clip capture and composite path. */
|
|
983
|
+
export async function captureTimelineStill(options) {
|
|
984
|
+
const { frame, outPath, ...rest } = options;
|
|
985
|
+
return captureTimelineStills({
|
|
986
|
+
...rest,
|
|
987
|
+
frames: [frame],
|
|
988
|
+
outPaths: [outPath],
|
|
989
|
+
});
|
|
990
|
+
}
|
|
813
991
|
/** Renders one timeline to MP4 through the segmented lossless clip pipeline. */
|
|
814
992
|
export async function renderTimeline(options) {
|
|
815
|
-
|
|
816
|
-
|
|
993
|
+
const container = options.container ?? "mp4";
|
|
994
|
+
const codec = options.codec ?? defaultCodecForContainer(container);
|
|
995
|
+
assertContainerCodec(container, codec);
|
|
996
|
+
const extension = `.${CONTAINER_EXTENSION[container]}`;
|
|
997
|
+
if (path.extname(options.outPath).toLowerCase() !== extension) {
|
|
998
|
+
throw new FrameyardError("USAGE", `Timeline output for --container ${container} must use a ${extension} extension: ${options.outPath}`);
|
|
817
999
|
}
|
|
818
|
-
const
|
|
1000
|
+
const requestedWorkers = validateWorkers(options.workers);
|
|
819
1001
|
const overwrite = options.overwrite ?? false;
|
|
820
|
-
await ensureOutputAvailable(options.outPath, overwrite);
|
|
821
|
-
await assertFfmpegCapabilities(options.signal);
|
|
822
1002
|
const startedAt = performance.now();
|
|
1003
|
+
await ensureOutputAvailable(options.outPath, overwrite);
|
|
1004
|
+
await assertFfmpegCapabilities(options.signal, codec);
|
|
1005
|
+
const encoder = await resolveVideoEncoder(codec, options.encoder, options.signal);
|
|
1006
|
+
const workers = clampTimelineWorkers(requestedWorkers, encoder);
|
|
823
1007
|
const projectTimeline = options.project.timelines.get(options.timelineId);
|
|
824
1008
|
if (!projectTimeline) {
|
|
825
1009
|
throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${options.timelineId}" is not listed in video.config.js.`);
|
|
@@ -843,7 +1027,15 @@ export async function renderTimeline(options) {
|
|
|
843
1027
|
const scratchRoot = await mkdtemp(path.join(scratchBase, "frameyard-timeline-"));
|
|
844
1028
|
const server = await startPreviewServer({ projectRoot: options.project.root, port: 0 });
|
|
845
1029
|
const infoCache = new Map();
|
|
1030
|
+
const staticCache = new Map();
|
|
846
1031
|
const diagnostics = [...resolved.diagnostics];
|
|
1032
|
+
if (workers < requestedWorkers) {
|
|
1033
|
+
diagnostics.push({
|
|
1034
|
+
severity: "warning",
|
|
1035
|
+
code: "ENCODER_WARNING",
|
|
1036
|
+
message: `Encoder ${encoder} allows ${workers} concurrent encode sessions; using ${workers} workers instead of ${requestedWorkers}.`,
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
847
1039
|
const total = timeline.durationInFrames;
|
|
848
1040
|
const segmentResults = Array.from({
|
|
849
1041
|
length: plan.segments.length,
|
|
@@ -895,6 +1087,10 @@ export async function renderTimeline(options) {
|
|
|
895
1087
|
serverUrl: server.url,
|
|
896
1088
|
scratchRoot,
|
|
897
1089
|
infoCache,
|
|
1090
|
+
staticCache,
|
|
1091
|
+
codec,
|
|
1092
|
+
container,
|
|
1093
|
+
encoder,
|
|
898
1094
|
...(options.signal ? { signal: options.signal } : {}),
|
|
899
1095
|
});
|
|
900
1096
|
segmentResults[index] = result;
|
|
@@ -919,6 +1115,8 @@ export async function renderTimeline(options) {
|
|
|
919
1115
|
segmentPaths,
|
|
920
1116
|
listPath: path.join(scratchRoot, "segments.txt"),
|
|
921
1117
|
outputPath: tempVideo,
|
|
1118
|
+
codec,
|
|
1119
|
+
container,
|
|
922
1120
|
...(options.signal ? { signal: options.signal } : {}),
|
|
923
1121
|
});
|
|
924
1122
|
let finalPath = tempVideo;
|
|
@@ -941,6 +1139,8 @@ export async function renderTimeline(options) {
|
|
|
941
1139
|
videoPath: tempVideo,
|
|
942
1140
|
audioPath,
|
|
943
1141
|
outputPath: tempFinal,
|
|
1142
|
+
codec,
|
|
1143
|
+
container,
|
|
944
1144
|
...(options.signal ? { signal: options.signal } : {}),
|
|
945
1145
|
});
|
|
946
1146
|
finalPath = tempFinal;
|
|
@@ -962,6 +1162,9 @@ export async function renderTimeline(options) {
|
|
|
962
1162
|
framesRendered: total,
|
|
963
1163
|
segments: plan.segments.length,
|
|
964
1164
|
workers,
|
|
1165
|
+
codec,
|
|
1166
|
+
container,
|
|
1167
|
+
encoder,
|
|
965
1168
|
bytes: (await stat(options.outPath)).size,
|
|
966
1169
|
audio: { clips: audioInspection.clips.length },
|
|
967
1170
|
transcripts: { clips: transcripts.clips.length },
|