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
|
@@ -8,8 +8,11 @@ import { validateHtmlComposition } from "../html/validate.js";
|
|
|
8
8
|
import { startPreviewServer } from "../server/server.js";
|
|
9
9
|
import { inspectTimelineAudio } from "./audio.js";
|
|
10
10
|
import { openCompositionSession } from "./browser.js";
|
|
11
|
-
import { assertContainerCodec, assertFfmpegCapabilities, compositeTimelineFrame, compositeTimelineSegment, concatTimelineSegments, CONTAINER_EXTENSION, defaultCodecForContainer,
|
|
11
|
+
import { assertContainerCodec, assertFfmpegCapabilities, compositeTimelineFrame, compositeTimelineSegment, concatTimelineSegments, CONTAINER_EXTENSION, defaultCodecForContainer, encodeVideoSegmentDirect, ENCODER_SESSION_LIMIT, mixAudioTimeline, muxVideoWithAudio, resolveVideoEncoder, sceneLayerToCompositeLayer, startDirectSegmentEncoder, startDualPipeSegmentEncoder, startFfv1Encoder, } from "./ffmpeg.js";
|
|
12
|
+
import { createSemaphore } from "./concurrency.js";
|
|
13
|
+
import { CompositionSessionPool } from "./session-pool.js";
|
|
12
14
|
import { inspectTranscriptClips } from "./transcripts.js";
|
|
15
|
+
import { startTiming } from "./timings.js";
|
|
13
16
|
import { evaluatePresentation } from "../timeline/keyframes.js";
|
|
14
17
|
import { transitionProgress } from "../timeline/transitions.js";
|
|
15
18
|
import { KEYFRAME_CHANNELS } from "../timeline/schema.js";
|
|
@@ -34,11 +37,42 @@ function validateWorkers(value) {
|
|
|
34
37
|
export function clampTimelineWorkers(workers, encoder) {
|
|
35
38
|
return Math.min(workers, ENCODER_SESSION_LIMIT[encoder]);
|
|
36
39
|
}
|
|
40
|
+
const MAX_CAPTURE_CONCURRENCY = 12;
|
|
41
|
+
/**
|
|
42
|
+
* Capture sessions are bounded independently from segment workers. A segment
|
|
43
|
+
* with two or three layers can overlap its captures without letting an
|
|
44
|
+
* eight-worker render open sixteen browsers.
|
|
45
|
+
*/
|
|
46
|
+
function captureConcurrency(workers) {
|
|
47
|
+
const raw = process.env.FRAMEYARD_CAPTURE_CONCURRENCY;
|
|
48
|
+
if (raw !== undefined) {
|
|
49
|
+
const value = Number(raw);
|
|
50
|
+
if (Number.isInteger(value) && value > 0) {
|
|
51
|
+
return Math.min(value, 32);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return Math.min(workers * 2, MAX_CAPTURE_CONCURRENCY);
|
|
55
|
+
}
|
|
37
56
|
function throwIfAborted(signal) {
|
|
38
57
|
if (signal?.aborted) {
|
|
39
58
|
throw new FrameyardError("RENDER_FAILED", "Operation cancelled.");
|
|
40
59
|
}
|
|
41
60
|
}
|
|
61
|
+
async function withCaptureSlot(limiter, signal, task) {
|
|
62
|
+
if (!limiter) {
|
|
63
|
+
throwIfAborted(signal);
|
|
64
|
+
return task();
|
|
65
|
+
}
|
|
66
|
+
throwIfAborted(signal);
|
|
67
|
+
const release = await limiter.acquire();
|
|
68
|
+
try {
|
|
69
|
+
throwIfAborted(signal);
|
|
70
|
+
return await task();
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
release();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
42
76
|
async function pathExists(filePath) {
|
|
43
77
|
try {
|
|
44
78
|
await stat(filePath);
|
|
@@ -170,6 +204,9 @@ function mapCompositionAudio(clip, audio, fps, window) {
|
|
|
170
204
|
trimDuration: duration,
|
|
171
205
|
volume: clampVolume(audio.volume * window.gain),
|
|
172
206
|
...(window.envelopes.length > 0 ? { envelopes: window.envelopes } : {}),
|
|
207
|
+
// Without an explicit duration the source ends naturally; any remaining
|
|
208
|
+
// mapped window is silence, not an overrun.
|
|
209
|
+
...(audio.trimDuration === undefined ? { allowShortSource: true } : {}),
|
|
173
210
|
};
|
|
174
211
|
}
|
|
175
212
|
function collectTimelineAudio(timeline, window, clips, transcripts) {
|
|
@@ -266,7 +303,7 @@ export function resolveTimelineAudio(timeline) {
|
|
|
266
303
|
const { clips, transcripts } = buildAudioClips(timeline);
|
|
267
304
|
return {
|
|
268
305
|
clips: clips.map((clip) => {
|
|
269
|
-
const { allowSilent: _allowSilent, ...resolved } = clip;
|
|
306
|
+
const { allowSilent: _allowSilent, allowShortSource: _allowShortSource, ...resolved } = clip;
|
|
270
307
|
return resolved;
|
|
271
308
|
}),
|
|
272
309
|
transcripts,
|
|
@@ -324,8 +361,13 @@ async function captureStaticCompositionLayer(options) {
|
|
|
324
361
|
throw error;
|
|
325
362
|
}
|
|
326
363
|
}
|
|
327
|
-
|
|
328
|
-
|
|
364
|
+
/**
|
|
365
|
+
* Opens a composition session, captures every frame of the segment, and hands
|
|
366
|
+
* each PNG to `writeFrame`. The caller owns the encoder lifecycle so the same
|
|
367
|
+
* loop can feed an FFV1 intermediate or a direct delivery segment.
|
|
368
|
+
*/
|
|
369
|
+
async function captureCompositionFrames(options, writeFrame) {
|
|
370
|
+
const { project, timeline, clip, source, segment, layerIndex, serverUrl, infoCache } = options;
|
|
329
371
|
const propsKey = JSON.stringify(clip.props ?? null);
|
|
330
372
|
const cacheKey = `${source.entry}\u0000${propsKey}`;
|
|
331
373
|
let validation = infoCache.get(cacheKey);
|
|
@@ -345,38 +387,293 @@ async function captureCompositionLayer(options) {
|
|
|
345
387
|
if (!layer) {
|
|
346
388
|
throw new FrameyardError("INTERNAL", `Missing layer ${layerIndex} for segment ${segment.index}.`);
|
|
347
389
|
}
|
|
348
|
-
const
|
|
349
|
-
|
|
390
|
+
const sessionTiming = startTiming("timeline.layer.session", {
|
|
391
|
+
entry: source.entry,
|
|
392
|
+
layer: layerIndex,
|
|
393
|
+
segment: segment.index,
|
|
394
|
+
});
|
|
395
|
+
const sessionKey = JSON.stringify([source.entry, clip.props ?? null, true]);
|
|
396
|
+
const openOptions = {
|
|
350
397
|
serverUrl,
|
|
351
398
|
entry: source.entry,
|
|
352
399
|
props: clip.props,
|
|
353
400
|
info: validation.info,
|
|
354
401
|
staticDiagnostics: validation.diagnostics,
|
|
355
402
|
transparentBackground: true,
|
|
356
|
-
}
|
|
403
|
+
};
|
|
404
|
+
const lease = options.sessionPool
|
|
405
|
+
? await options.sessionPool.acquire(sessionKey, () => openCompositionSession(openOptions))
|
|
406
|
+
: await (async () => {
|
|
407
|
+
const session = await openCompositionSession(openOptions);
|
|
408
|
+
return {
|
|
409
|
+
session,
|
|
410
|
+
reused: false,
|
|
411
|
+
release: () => session.close(),
|
|
412
|
+
discard: () => session.close(),
|
|
413
|
+
};
|
|
414
|
+
})();
|
|
415
|
+
sessionTiming.finish({ reused: lease.reused });
|
|
416
|
+
const session = lease.session;
|
|
417
|
+
let healthy = false;
|
|
357
418
|
const diagnostics = [];
|
|
419
|
+
const captureTiming = startTiming("timeline.layer.capture_frames", {
|
|
420
|
+
entry: source.entry,
|
|
421
|
+
layer: layerIndex,
|
|
422
|
+
segment: segment.index,
|
|
423
|
+
frames: segment.frames,
|
|
424
|
+
});
|
|
425
|
+
let seekMs = 0;
|
|
426
|
+
let captureMs = 0;
|
|
427
|
+
let writeMs = 0;
|
|
428
|
+
let pngBytes = 0;
|
|
358
429
|
try {
|
|
359
430
|
for (let offset = 0; offset < segment.frames; offset += 1) {
|
|
360
431
|
throwIfAborted(options.signal);
|
|
432
|
+
const seekStarted = performance.now();
|
|
361
433
|
await session.seek(layer.sourceStart + offset);
|
|
434
|
+
seekMs += performance.now() - seekStarted;
|
|
435
|
+
const captureStarted = performance.now();
|
|
362
436
|
const png = await session.capture();
|
|
363
|
-
|
|
437
|
+
captureMs += performance.now() - captureStarted;
|
|
438
|
+
pngBytes += png.length;
|
|
439
|
+
const writeStarted = performance.now();
|
|
440
|
+
await writeFrame(png);
|
|
441
|
+
writeMs += performance.now() - writeStarted;
|
|
364
442
|
}
|
|
365
443
|
for (const diagnostic of await session.readDiagnostics()) {
|
|
366
444
|
diagnostics.push(compositionDiagnostic(diagnostic, timeline.id, clip, source.id));
|
|
367
445
|
}
|
|
446
|
+
healthy = true;
|
|
368
447
|
}
|
|
369
448
|
finally {
|
|
370
|
-
|
|
449
|
+
captureTiming.finish({
|
|
450
|
+
seekMs: Number(seekMs.toFixed(1)),
|
|
451
|
+
captureMs: Number(captureMs.toFixed(1)),
|
|
452
|
+
writeMs: Number(writeMs.toFixed(1)),
|
|
453
|
+
pngBytes,
|
|
454
|
+
});
|
|
455
|
+
if (healthy) {
|
|
456
|
+
await lease.release();
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
await lease.discard();
|
|
460
|
+
}
|
|
371
461
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
462
|
+
return diagnostics;
|
|
463
|
+
}
|
|
464
|
+
async function captureCompositionLayer(options) {
|
|
465
|
+
const prefix = `comp-${options.layerIndex}`;
|
|
466
|
+
const outputPath = path.join(options.segmentDir, `${prefix}.mkv`);
|
|
467
|
+
const ffv1Timing = startTiming("timeline.layer.ffv1", {
|
|
468
|
+
entry: options.source.entry,
|
|
469
|
+
layer: options.layerIndex,
|
|
470
|
+
segment: options.segment.index,
|
|
378
471
|
});
|
|
379
|
-
|
|
472
|
+
let encoder;
|
|
473
|
+
try {
|
|
474
|
+
encoder = await startFfv1Encoder({
|
|
475
|
+
outputPath,
|
|
476
|
+
frames: options.segment.frames,
|
|
477
|
+
fps: options.timeline.format.fps,
|
|
478
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
ffv1Timing.finish();
|
|
483
|
+
throw error;
|
|
484
|
+
}
|
|
485
|
+
try {
|
|
486
|
+
const diagnostics = await captureCompositionFrames(options, (png) => encoder.writeFrame(png));
|
|
487
|
+
await encoder.finish();
|
|
488
|
+
return { path: outputPath, diagnostics };
|
|
489
|
+
}
|
|
490
|
+
catch (error) {
|
|
491
|
+
await encoder.abort();
|
|
492
|
+
throw error;
|
|
493
|
+
}
|
|
494
|
+
finally {
|
|
495
|
+
ffv1Timing.finish();
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
function isFullFrameComposition(layer, timeline) {
|
|
499
|
+
if (layer.clip.source.kind !== "composition" || layer.capture !== "frames") {
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
if (layer.transform.width !== undefined || layer.transform.height !== undefined) {
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
if (layer.sourceWidth !== timeline.format.width || layer.sourceHeight !== timeline.format.height) {
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
const { x, y, scale, rotation, opacity } = layer.transform;
|
|
509
|
+
if (x !== 0 || y !== 0 || scale !== 1 || rotation !== 0 || opacity !== 1) {
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
return layer.blend === "normal" && layer.transition === undefined && layer.keyframes === undefined;
|
|
513
|
+
}
|
|
514
|
+
function canEncodeDirectly(options) {
|
|
515
|
+
if (options.background === "transparent" || options.segment.layers.length !== 1) {
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
const layer = options.segment.layers[0];
|
|
519
|
+
return layer !== undefined && isFullFrameComposition(layer, options.timeline);
|
|
520
|
+
}
|
|
521
|
+
function isStaticCompositionOverlay(layer) {
|
|
522
|
+
if (layer.clip.source.kind !== "composition" || layer.capture !== "frames") {
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
const { scale, rotation, opacity } = layer.transform;
|
|
526
|
+
if (scale !== 1 || rotation !== 0 || opacity !== 1) {
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
return layer.blend === "normal" && layer.transition === undefined && layer.keyframes === undefined;
|
|
530
|
+
}
|
|
531
|
+
function canEncodeVideoDirectly(options) {
|
|
532
|
+
if (process.env.FRAMEYARD_DISABLE_VIDEO_DIRECT === "1") {
|
|
533
|
+
return false;
|
|
534
|
+
}
|
|
535
|
+
if (options.background === "transparent" || options.segment.layers.length !== 1) {
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
const layer = options.segment.layers[0];
|
|
539
|
+
if (!layer || layer.clip.source.kind !== "video" || layer.capture !== "video") {
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
if (layer.sourceWidth !== options.timeline.format.width || layer.sourceHeight !== options.timeline.format.height) {
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
const { x, y, scale, rotation, opacity, width, height } = layer.transform;
|
|
546
|
+
if (width !== undefined ||
|
|
547
|
+
height !== undefined ||
|
|
548
|
+
x !== 0 ||
|
|
549
|
+
y !== 0 ||
|
|
550
|
+
scale !== 1 ||
|
|
551
|
+
rotation !== 0 ||
|
|
552
|
+
opacity !== 1) {
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
return layer.blend === "normal" && layer.transition === undefined && layer.keyframes === undefined;
|
|
556
|
+
}
|
|
557
|
+
function canEncodeDualPipe(options) {
|
|
558
|
+
// FFmpeg reads the second image pipe through fd 3; keep the conservative
|
|
559
|
+
// fallback on hosts where extra stdio pipes are not portable.
|
|
560
|
+
if (process.platform === "win32" ||
|
|
561
|
+
process.env.FRAMEYARD_DISABLE_DUAL_PIPE === "1" ||
|
|
562
|
+
options.background === "transparent" ||
|
|
563
|
+
options.segment.layers.length !== 2) {
|
|
564
|
+
return false;
|
|
565
|
+
}
|
|
566
|
+
const base = options.segment.layers[0];
|
|
567
|
+
const overlay = options.segment.layers[1];
|
|
568
|
+
return (base !== undefined &&
|
|
569
|
+
overlay !== undefined &&
|
|
570
|
+
isFullFrameComposition(base, options.timeline) &&
|
|
571
|
+
isStaticCompositionOverlay(overlay));
|
|
572
|
+
}
|
|
573
|
+
async function captureDirectCompositionLayer(options) {
|
|
574
|
+
const timing = startTiming("timeline.layer.composition", {
|
|
575
|
+
entry: options.source.entry,
|
|
576
|
+
layer: options.layerIndex,
|
|
577
|
+
segment: options.segment.index,
|
|
578
|
+
});
|
|
579
|
+
let encoder;
|
|
580
|
+
try {
|
|
581
|
+
encoder = await startDirectSegmentEncoder({
|
|
582
|
+
outputPath: options.outputPath,
|
|
583
|
+
width: options.timeline.format.width,
|
|
584
|
+
height: options.timeline.format.height,
|
|
585
|
+
fps: options.timeline.format.fps,
|
|
586
|
+
frames: options.segment.frames,
|
|
587
|
+
background: options.background,
|
|
588
|
+
codec: options.codec,
|
|
589
|
+
container: options.container,
|
|
590
|
+
...(options.encoderName !== undefined ? { encoder: options.encoderName } : {}),
|
|
591
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
catch (error) {
|
|
595
|
+
timing.finish();
|
|
596
|
+
throw error;
|
|
597
|
+
}
|
|
598
|
+
try {
|
|
599
|
+
const diagnostics = await captureCompositionFrames(options, (png) => encoder.writeFrame(png));
|
|
600
|
+
await encoder.finish();
|
|
601
|
+
return { path: options.outputPath, diagnostics };
|
|
602
|
+
}
|
|
603
|
+
catch (error) {
|
|
604
|
+
await encoder.abort();
|
|
605
|
+
throw error;
|
|
606
|
+
}
|
|
607
|
+
finally {
|
|
608
|
+
timing.finish();
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
async function captureTwoLayerDirectComposition(options) {
|
|
612
|
+
const overlayLayer = options.segment.layers[1];
|
|
613
|
+
if (!overlayLayer) {
|
|
614
|
+
throw new FrameyardError("INTERNAL", "Dual-pipe capture requires an overlay layer.");
|
|
615
|
+
}
|
|
616
|
+
const overlay = sceneLayerToCompositeLayer(overlayLayer, { path: "pipe:3", input: "sequence" });
|
|
617
|
+
const encoder = await startDualPipeSegmentEncoder({
|
|
618
|
+
outputPath: options.outputPath,
|
|
619
|
+
width: options.timeline.format.width,
|
|
620
|
+
height: options.timeline.format.height,
|
|
621
|
+
fps: options.timeline.format.fps,
|
|
622
|
+
frames: options.segment.frames,
|
|
623
|
+
background: options.background,
|
|
624
|
+
codec: options.codec,
|
|
625
|
+
container: options.container,
|
|
626
|
+
overlay,
|
|
627
|
+
...(options.encoderName !== undefined ? { encoder: options.encoderName } : {}),
|
|
628
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
629
|
+
});
|
|
630
|
+
const tasks = options.segment.layers.map(async (layer, layerIndex) => {
|
|
631
|
+
if (layer.clip.source.kind !== "composition") {
|
|
632
|
+
throw new FrameyardError("INTERNAL", "Dual-pipe capture requires composition layers.");
|
|
633
|
+
}
|
|
634
|
+
const source = layer.clip.source;
|
|
635
|
+
return withCaptureSlot(options.captureLimiter, options.signal, async () => {
|
|
636
|
+
const timing = startTiming("timeline.layer.composition", {
|
|
637
|
+
entry: source.entry,
|
|
638
|
+
layer: layerIndex,
|
|
639
|
+
segment: options.segment.index,
|
|
640
|
+
});
|
|
641
|
+
try {
|
|
642
|
+
return await captureCompositionFrames({
|
|
643
|
+
project: options.project,
|
|
644
|
+
timeline: options.timeline,
|
|
645
|
+
clip: layer.clip,
|
|
646
|
+
source,
|
|
647
|
+
segment: options.segment,
|
|
648
|
+
layerIndex,
|
|
649
|
+
serverUrl: options.serverUrl,
|
|
650
|
+
infoCache: options.infoCache,
|
|
651
|
+
...(options.sessionPool !== undefined ? { sessionPool: options.sessionPool } : {}),
|
|
652
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
653
|
+
}, (png) => encoder.writeFrame(layerIndex, png));
|
|
654
|
+
}
|
|
655
|
+
finally {
|
|
656
|
+
timing.finish();
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
});
|
|
660
|
+
const settled = await Promise.allSettled(tasks);
|
|
661
|
+
const diagnostics = [];
|
|
662
|
+
for (const result of settled) {
|
|
663
|
+
if (result.status === "rejected") {
|
|
664
|
+
await encoder.abort();
|
|
665
|
+
throw result.reason;
|
|
666
|
+
}
|
|
667
|
+
diagnostics.push(...result.value);
|
|
668
|
+
}
|
|
669
|
+
try {
|
|
670
|
+
await encoder.finish();
|
|
671
|
+
}
|
|
672
|
+
catch (error) {
|
|
673
|
+
await encoder.abort();
|
|
674
|
+
throw error;
|
|
675
|
+
}
|
|
676
|
+
return { path: options.outputPath, diagnostics };
|
|
380
677
|
}
|
|
381
678
|
async function renderNestedTimelineRange(options) {
|
|
382
679
|
if (options.depth > 32) {
|
|
@@ -398,6 +695,8 @@ async function renderNestedTimelineRange(options) {
|
|
|
398
695
|
scratchRoot: options.scratchRoot,
|
|
399
696
|
infoCache: options.infoCache,
|
|
400
697
|
staticCache: options.staticCache,
|
|
698
|
+
...(options.captureLimiter !== undefined ? { captureLimiter: options.captureLimiter } : {}),
|
|
699
|
+
...(options.sessionPool !== undefined ? { sessionPool: options.sessionPool } : {}),
|
|
401
700
|
codec: "ffv1",
|
|
402
701
|
background: options.timeline.background ?? "transparent",
|
|
403
702
|
prefix,
|
|
@@ -427,16 +726,118 @@ async function processTimelineSegment(options) {
|
|
|
427
726
|
const prefix = options.prefix ?? "timeline";
|
|
428
727
|
const depth = options.depth ?? 0;
|
|
429
728
|
const segmentDir = path.join(scratchRoot, `${prefix}-seg-${String(segment.index).padStart(4, "0")}`);
|
|
729
|
+
const segmentTiming = startTiming("timeline.segment", {
|
|
730
|
+
segment: segment.index,
|
|
731
|
+
start: segment.start,
|
|
732
|
+
frames: segment.frames,
|
|
733
|
+
layers: segment.layers.length,
|
|
734
|
+
});
|
|
735
|
+
const extension = codec === "ffv1" ? "mkv" : CONTAINER_EXTENSION[options.container ?? "mp4"];
|
|
736
|
+
const segmentPath = path.join(scratchRoot, `${prefix}-segment-${String(segment.index).padStart(4, "0")}.${extension}`);
|
|
737
|
+
if (codec !== "ffv1" && canEncodeDirectly({ segment, timeline, background })) {
|
|
738
|
+
const layer = segment.layers[0];
|
|
739
|
+
if (!layer) {
|
|
740
|
+
throw new FrameyardError("INTERNAL", "Direct segment capture requires one composition layer.");
|
|
741
|
+
}
|
|
742
|
+
const clip = layer.clip;
|
|
743
|
+
if (clip.source.kind !== "composition") {
|
|
744
|
+
throw new FrameyardError("INTERNAL", "Direct segment capture requires one composition layer.");
|
|
745
|
+
}
|
|
746
|
+
const source = clip.source;
|
|
747
|
+
const directTiming = startTiming("timeline.segment.direct", {
|
|
748
|
+
segment: segment.index,
|
|
749
|
+
frames: segment.frames,
|
|
750
|
+
codec,
|
|
751
|
+
container: options.container ?? "mp4",
|
|
752
|
+
});
|
|
753
|
+
const captured = await withCaptureSlot(options.captureLimiter, options.signal, () => captureDirectCompositionLayer({
|
|
754
|
+
project,
|
|
755
|
+
timeline,
|
|
756
|
+
clip,
|
|
757
|
+
source,
|
|
758
|
+
segment,
|
|
759
|
+
layerIndex: 0,
|
|
760
|
+
serverUrl,
|
|
761
|
+
infoCache,
|
|
762
|
+
outputPath: segmentPath,
|
|
763
|
+
background,
|
|
764
|
+
codec,
|
|
765
|
+
container: options.container ?? "mp4",
|
|
766
|
+
...(options.sessionPool !== undefined ? { sessionPool: options.sessionPool } : {}),
|
|
767
|
+
...(options.encoder !== undefined ? { encoderName: options.encoder } : {}),
|
|
768
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
769
|
+
}));
|
|
770
|
+
directTiming.finish();
|
|
771
|
+
segmentTiming.finish();
|
|
772
|
+
return { path: segmentPath, diagnostics: captured.diagnostics };
|
|
773
|
+
}
|
|
774
|
+
if (codec !== "ffv1" && canEncodeDualPipe({ segment, timeline, background })) {
|
|
775
|
+
const directTiming = startTiming("timeline.segment.direct", {
|
|
776
|
+
segment: segment.index,
|
|
777
|
+
frames: segment.frames,
|
|
778
|
+
codec,
|
|
779
|
+
container: options.container ?? "mp4",
|
|
780
|
+
layers: 2,
|
|
781
|
+
});
|
|
782
|
+
const captured = await captureTwoLayerDirectComposition({
|
|
783
|
+
project,
|
|
784
|
+
timeline,
|
|
785
|
+
segment,
|
|
786
|
+
serverUrl,
|
|
787
|
+
infoCache,
|
|
788
|
+
outputPath: segmentPath,
|
|
789
|
+
background,
|
|
790
|
+
codec,
|
|
791
|
+
container: options.container ?? "mp4",
|
|
792
|
+
...(options.captureLimiter !== undefined ? { captureLimiter: options.captureLimiter } : {}),
|
|
793
|
+
...(options.sessionPool !== undefined ? { sessionPool: options.sessionPool } : {}),
|
|
794
|
+
...(options.encoder !== undefined ? { encoderName: options.encoder } : {}),
|
|
795
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
796
|
+
});
|
|
797
|
+
directTiming.finish();
|
|
798
|
+
segmentTiming.finish();
|
|
799
|
+
return { path: segmentPath, diagnostics: captured.diagnostics };
|
|
800
|
+
}
|
|
801
|
+
if (codec !== "ffv1" && canEncodeVideoDirectly({ segment, timeline, background })) {
|
|
802
|
+
const layer = segment.layers[0];
|
|
803
|
+
if (!layer || layer.clip.source.kind !== "video") {
|
|
804
|
+
throw new FrameyardError("INTERNAL", "Direct video capture requires one video layer.");
|
|
805
|
+
}
|
|
806
|
+
const source = layer.clip.source;
|
|
807
|
+
const directTiming = startTiming("timeline.segment.direct", {
|
|
808
|
+
segment: segment.index,
|
|
809
|
+
frames: segment.frames,
|
|
810
|
+
codec,
|
|
811
|
+
container: options.container ?? "mp4",
|
|
812
|
+
layers: 1,
|
|
813
|
+
kind: "video",
|
|
814
|
+
});
|
|
815
|
+
await encodeVideoSegmentDirect({
|
|
816
|
+
inputPath: source.absolutePath,
|
|
817
|
+
outputPath: segmentPath,
|
|
818
|
+
sourceStartFrame: layer.sourceStart,
|
|
819
|
+
frames: segment.frames,
|
|
820
|
+
fps: timeline.format.fps,
|
|
821
|
+
codec,
|
|
822
|
+
container: options.container ?? "mp4",
|
|
823
|
+
...(options.encoder !== undefined ? { encoder: options.encoder } : {}),
|
|
824
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
825
|
+
});
|
|
826
|
+
directTiming.finish();
|
|
827
|
+
segmentTiming.finish();
|
|
828
|
+
return { path: segmentPath, diagnostics: [] };
|
|
829
|
+
}
|
|
430
830
|
await mkdir(segmentDir, { recursive: true });
|
|
431
831
|
const diagnostics = [];
|
|
432
832
|
const layers = [];
|
|
433
|
-
|
|
833
|
+
const layerTasks = segment.layers.map(async (layer, layerIndex) => {
|
|
434
834
|
throwIfAborted(options.signal);
|
|
435
835
|
const clip = layer.clip;
|
|
436
836
|
const source = clip.source;
|
|
437
837
|
if (source.kind === "composition") {
|
|
438
838
|
if (layer.capture === "still") {
|
|
439
|
-
const
|
|
839
|
+
const timing = startTiming("timeline.layer.static", { layer: layerIndex, segment: segment.index });
|
|
840
|
+
const captured = await withCaptureSlot(options.captureLimiter, options.signal, () => captureStaticCompositionLayer({
|
|
440
841
|
project,
|
|
441
842
|
timeline,
|
|
442
843
|
clip,
|
|
@@ -446,12 +847,20 @@ async function processTimelineSegment(options) {
|
|
|
446
847
|
infoCache,
|
|
447
848
|
staticCache: options.staticCache,
|
|
448
849
|
...(options.signal ? { signal: options.signal } : {}),
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
850
|
+
}));
|
|
851
|
+
timing.finish();
|
|
852
|
+
return {
|
|
853
|
+
layerIndex,
|
|
854
|
+
compositeLayer: sceneLayerToCompositeLayer(layer, { path: captured.path, input: "image" }),
|
|
855
|
+
diagnostics: captured.diagnostics,
|
|
856
|
+
};
|
|
453
857
|
}
|
|
454
|
-
const
|
|
858
|
+
const timing = startTiming("timeline.layer.composition", {
|
|
859
|
+
entry: source.entry,
|
|
860
|
+
layer: layerIndex,
|
|
861
|
+
segment: segment.index,
|
|
862
|
+
});
|
|
863
|
+
const captured = await withCaptureSlot(options.captureLimiter, options.signal, () => captureCompositionLayer({
|
|
455
864
|
project,
|
|
456
865
|
timeline,
|
|
457
866
|
clip,
|
|
@@ -461,21 +870,29 @@ async function processTimelineSegment(options) {
|
|
|
461
870
|
serverUrl,
|
|
462
871
|
segmentDir,
|
|
463
872
|
infoCache,
|
|
873
|
+
...(options.sessionPool !== undefined ? { sessionPool: options.sessionPool } : {}),
|
|
464
874
|
...(options.signal ? { signal: options.signal } : {}),
|
|
465
|
-
});
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
875
|
+
}));
|
|
876
|
+
timing.finish();
|
|
877
|
+
return {
|
|
878
|
+
layerIndex,
|
|
879
|
+
compositeLayer: sceneLayerToCompositeLayer(layer, { path: captured.path, input: "sequence" }),
|
|
880
|
+
diagnostics: captured.diagnostics,
|
|
881
|
+
};
|
|
469
882
|
}
|
|
470
883
|
if (source.kind === "video" || source.kind === "image") {
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
884
|
+
return {
|
|
885
|
+
layerIndex,
|
|
886
|
+
compositeLayer: sceneLayerToCompositeLayer(layer, {
|
|
887
|
+
path: source.absolutePath,
|
|
888
|
+
input: source.kind,
|
|
889
|
+
...(source.kind === "video" ? { sourceStartFrame: layer.sourceStart } : {}),
|
|
890
|
+
}),
|
|
891
|
+
diagnostics: [],
|
|
892
|
+
};
|
|
477
893
|
}
|
|
478
894
|
if (source.kind === "timeline") {
|
|
895
|
+
const timing = startTiming("timeline.layer.nested", { layer: layerIndex, segment: segment.index });
|
|
479
896
|
const nested = await renderNestedTimelineRange({
|
|
480
897
|
project,
|
|
481
898
|
timeline: source.timeline,
|
|
@@ -485,17 +902,39 @@ async function processTimelineSegment(options) {
|
|
|
485
902
|
scratchRoot,
|
|
486
903
|
infoCache,
|
|
487
904
|
staticCache: options.staticCache,
|
|
905
|
+
...(options.captureLimiter !== undefined ? { captureLimiter: options.captureLimiter } : {}),
|
|
906
|
+
...(options.sessionPool !== undefined ? { sessionPool: options.sessionPool } : {}),
|
|
488
907
|
depth: depth + 1,
|
|
489
908
|
...(options.signal ? { signal: options.signal } : {}),
|
|
490
909
|
});
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
910
|
+
timing.finish();
|
|
911
|
+
return {
|
|
912
|
+
layerIndex,
|
|
913
|
+
compositeLayer: sceneLayerToCompositeLayer(layer, { path: nested.path, input: "sequence" }),
|
|
914
|
+
diagnostics: nested.diagnostics,
|
|
915
|
+
};
|
|
494
916
|
}
|
|
495
917
|
throw new FrameyardError("TIMELINE_INVALID", `Unsupported visual source for clip "${clip.id}".`);
|
|
918
|
+
});
|
|
919
|
+
const settled = await Promise.allSettled(layerTasks);
|
|
920
|
+
const capturedLayers = [];
|
|
921
|
+
for (const result of settled) {
|
|
922
|
+
if (result.status === "rejected") {
|
|
923
|
+
throw result.reason;
|
|
924
|
+
}
|
|
925
|
+
capturedLayers.push(result.value);
|
|
496
926
|
}
|
|
497
|
-
|
|
498
|
-
const
|
|
927
|
+
capturedLayers.sort((left, right) => left.layerIndex - right.layerIndex);
|
|
928
|
+
for (const captured of capturedLayers) {
|
|
929
|
+
diagnostics.push(...captured.diagnostics);
|
|
930
|
+
layers.push(captured.compositeLayer);
|
|
931
|
+
}
|
|
932
|
+
const compositeTiming = startTiming("timeline.segment.composite", {
|
|
933
|
+
segment: segment.index,
|
|
934
|
+
frames: segment.frames,
|
|
935
|
+
layers: layers.length,
|
|
936
|
+
codec,
|
|
937
|
+
});
|
|
499
938
|
await compositeTimelineSegment({
|
|
500
939
|
outputPath: segmentPath,
|
|
501
940
|
width: timeline.format.width,
|
|
@@ -510,7 +949,11 @@ async function processTimelineSegment(options) {
|
|
|
510
949
|
...(options.encoder !== undefined ? { encoder: options.encoder } : {}),
|
|
511
950
|
...(options.signal ? { signal: options.signal } : {}),
|
|
512
951
|
});
|
|
952
|
+
compositeTiming.finish();
|
|
953
|
+
const cleanupTiming = startTiming("timeline.segment.cleanup", { segment: segment.index });
|
|
513
954
|
await rm(segmentDir, { recursive: true, force: true });
|
|
955
|
+
cleanupTiming.finish();
|
|
956
|
+
segmentTiming.finish();
|
|
514
957
|
return { path: segmentPath, diagnostics };
|
|
515
958
|
}
|
|
516
959
|
function checkKeyframeEvaluation(timeline, segments) {
|
|
@@ -971,20 +1414,25 @@ export async function renderTimeline(options) {
|
|
|
971
1414
|
const requestedWorkers = validateWorkers(options.workers);
|
|
972
1415
|
const overwrite = options.overwrite ?? false;
|
|
973
1416
|
const startedAt = performance.now();
|
|
1417
|
+
const renderTiming = startTiming("timeline.render", { timeline: options.timelineId });
|
|
974
1418
|
await ensureOutputAvailable(options.outPath, overwrite);
|
|
975
1419
|
await assertFfmpegCapabilities(options.signal, codec);
|
|
976
1420
|
const encoder = await resolveVideoEncoder(codec, options.encoder, options.signal);
|
|
977
1421
|
const workers = clampTimelineWorkers(requestedWorkers, encoder);
|
|
1422
|
+
const captureLimiter = createSemaphore(captureConcurrency(workers));
|
|
1423
|
+
const sessionPool = new CompositionSessionPool({ maxIdle: Math.max(2, workers) });
|
|
978
1424
|
const projectTimeline = options.project.timelines.get(options.timelineId);
|
|
979
1425
|
if (!projectTimeline) {
|
|
980
1426
|
throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${options.timelineId}" is not listed in fy.config.js.`);
|
|
981
1427
|
}
|
|
1428
|
+
const resolveTiming = startTiming("timeline.resolve", { timeline: options.timelineId });
|
|
982
1429
|
const resolved = await resolveProjectTimeline({
|
|
983
1430
|
project: options.project,
|
|
984
1431
|
timeline: projectTimeline,
|
|
985
1432
|
...(options.signal ? { signal: options.signal } : {}),
|
|
986
1433
|
...(options.propsOverrides ? { propsOverrides: options.propsOverrides } : {}),
|
|
987
1434
|
});
|
|
1435
|
+
resolveTiming.finish({ clips: resolved.timeline?.clips.length ?? 0, diagnostics: resolved.diagnostics.length });
|
|
988
1436
|
if (!resolved.timeline) {
|
|
989
1437
|
const first = resolved.diagnostics.find((diagnostic) => diagnostic.severity === "error");
|
|
990
1438
|
throw new FrameyardError(first?.code ?? "TIMELINE_INVALID", first?.message ?? "Timeline resolution failed.", {
|
|
@@ -996,7 +1444,9 @@ export async function renderTimeline(options) {
|
|
|
996
1444
|
const scratchBase = options.scratchDir ?? tmpdir();
|
|
997
1445
|
await mkdir(scratchBase, { recursive: true });
|
|
998
1446
|
const scratchRoot = await mkdtemp(path.join(scratchBase, "frameyard-timeline-"));
|
|
1447
|
+
const serverTiming = startTiming("timeline.server", { project: options.project.root });
|
|
999
1448
|
const server = await startPreviewServer({ projectRoot: options.project.root, port: 0 });
|
|
1449
|
+
serverTiming.finish({ url: server.url });
|
|
1000
1450
|
const infoCache = new Map();
|
|
1001
1451
|
const staticCache = new Map();
|
|
1002
1452
|
const diagnostics = [...resolved.diagnostics];
|
|
@@ -1013,6 +1463,7 @@ export async function renderTimeline(options) {
|
|
|
1013
1463
|
});
|
|
1014
1464
|
let nextSegment = 0;
|
|
1015
1465
|
const audio = buildAudioClips(timeline);
|
|
1466
|
+
const audioTiming = startTiming("timeline.audio_inspect", { clips: audio.clips.length });
|
|
1016
1467
|
const audioInspection = audio.clips.length
|
|
1017
1468
|
? await inspectTimelineAudio({
|
|
1018
1469
|
projectRoot: options.project.root,
|
|
@@ -1022,11 +1473,13 @@ export async function renderTimeline(options) {
|
|
|
1022
1473
|
...(options.signal ? { signal: options.signal } : {}),
|
|
1023
1474
|
})
|
|
1024
1475
|
: { clips: [], assets: [], warnings: [] };
|
|
1476
|
+
audioTiming.finish({ assets: audioInspection.assets.length });
|
|
1025
1477
|
diagnostics.push(...audioInspection.warnings.map((message) => ({
|
|
1026
1478
|
severity: "warning",
|
|
1027
1479
|
code: "AUDIO_WARNING",
|
|
1028
1480
|
message,
|
|
1029
1481
|
})));
|
|
1482
|
+
const transcriptTiming = startTiming("timeline.transcript_inspect", { clips: audio.transcripts.length });
|
|
1030
1483
|
const transcripts = audio.transcripts.length
|
|
1031
1484
|
? await inspectTranscriptClips({
|
|
1032
1485
|
projectRoot: options.project.root,
|
|
@@ -1034,6 +1487,7 @@ export async function renderTimeline(options) {
|
|
|
1034
1487
|
...(options.signal ? { signal: options.signal } : {}),
|
|
1035
1488
|
})
|
|
1036
1489
|
: { clips: [], assets: [], warnings: [] };
|
|
1490
|
+
transcriptTiming.finish({ assets: transcripts.assets.length });
|
|
1037
1491
|
diagnostics.push(...transcripts.warnings.map((message) => ({
|
|
1038
1492
|
severity: "warning",
|
|
1039
1493
|
code: "TRANSCRIPT_WARNING",
|
|
@@ -1059,6 +1513,8 @@ export async function renderTimeline(options) {
|
|
|
1059
1513
|
scratchRoot,
|
|
1060
1514
|
infoCache,
|
|
1061
1515
|
staticCache,
|
|
1516
|
+
captureLimiter,
|
|
1517
|
+
sessionPool,
|
|
1062
1518
|
codec,
|
|
1063
1519
|
container,
|
|
1064
1520
|
encoder,
|
|
@@ -1074,7 +1530,14 @@ export async function renderTimeline(options) {
|
|
|
1074
1530
|
});
|
|
1075
1531
|
}
|
|
1076
1532
|
};
|
|
1533
|
+
const segmentsTiming = startTiming("timeline.segments", {
|
|
1534
|
+
segments: plan.segments.length,
|
|
1535
|
+
workers,
|
|
1536
|
+
codec,
|
|
1537
|
+
encoder,
|
|
1538
|
+
});
|
|
1077
1539
|
await Promise.all(Array.from({ length: workers }, () => worker()));
|
|
1540
|
+
segmentsTiming.finish();
|
|
1078
1541
|
const segmentPaths = segmentResults.map((result) => {
|
|
1079
1542
|
if (!result) {
|
|
1080
1543
|
throw new FrameyardError("INTERNAL", "A timeline segment was not rendered.");
|
|
@@ -1082,6 +1545,7 @@ export async function renderTimeline(options) {
|
|
|
1082
1545
|
diagnostics.push(...result.diagnostics);
|
|
1083
1546
|
return result.path;
|
|
1084
1547
|
});
|
|
1548
|
+
const concatTiming = startTiming("timeline.concat", { segments: segmentPaths.length });
|
|
1085
1549
|
await concatTimelineSegments({
|
|
1086
1550
|
segmentPaths,
|
|
1087
1551
|
listPath: path.join(scratchRoot, "segments.txt"),
|
|
@@ -1090,9 +1554,11 @@ export async function renderTimeline(options) {
|
|
|
1090
1554
|
container,
|
|
1091
1555
|
...(options.signal ? { signal: options.signal } : {}),
|
|
1092
1556
|
});
|
|
1557
|
+
concatTiming.finish();
|
|
1093
1558
|
let finalPath = tempVideo;
|
|
1094
1559
|
if (audioInspection.assets.length > 0) {
|
|
1095
1560
|
const audioPath = path.join(scratchRoot, "audio.wav");
|
|
1561
|
+
const mixTiming = startTiming("timeline.audio_mix", { assets: audioInspection.assets.length });
|
|
1096
1562
|
await mixAudioTimeline({
|
|
1097
1563
|
clips: audioInspection.assets.map((asset) => ({
|
|
1098
1564
|
path: asset.path,
|
|
@@ -1107,6 +1573,8 @@ export async function renderTimeline(options) {
|
|
|
1107
1573
|
outputPath: audioPath,
|
|
1108
1574
|
...(options.signal ? { signal: options.signal } : {}),
|
|
1109
1575
|
});
|
|
1576
|
+
mixTiming.finish();
|
|
1577
|
+
const muxTiming = startTiming("timeline.mux", { codec, container });
|
|
1110
1578
|
await muxVideoWithAudio({
|
|
1111
1579
|
videoPath: tempVideo,
|
|
1112
1580
|
audioPath,
|
|
@@ -1115,13 +1583,24 @@ export async function renderTimeline(options) {
|
|
|
1115
1583
|
container,
|
|
1116
1584
|
...(options.signal ? { signal: options.signal } : {}),
|
|
1117
1585
|
});
|
|
1586
|
+
muxTiming.finish();
|
|
1118
1587
|
finalPath = tempFinal;
|
|
1119
1588
|
}
|
|
1120
1589
|
if (!overwrite && (await pathExists(options.outPath))) {
|
|
1121
1590
|
throw new FrameyardError("OUTPUT_EXISTS", `Output already exists: ${options.outPath}. Pass --overwrite to replace it.`);
|
|
1122
1591
|
}
|
|
1592
|
+
const publishTiming = startTiming("timeline.publish", { out: options.outPath });
|
|
1123
1593
|
await publishFile(finalPath, options.outPath, overwrite);
|
|
1594
|
+
publishTiming.finish();
|
|
1124
1595
|
const uniqueDiagnostics = mergeDiagnostics(diagnostics);
|
|
1596
|
+
renderTiming.finish({
|
|
1597
|
+
segments: plan.segments.length,
|
|
1598
|
+
workers,
|
|
1599
|
+
codec,
|
|
1600
|
+
encoder,
|
|
1601
|
+
frames: total,
|
|
1602
|
+
outputBytes: (await stat(options.outPath)).size,
|
|
1603
|
+
});
|
|
1125
1604
|
return {
|
|
1126
1605
|
timeline: options.timelineId,
|
|
1127
1606
|
entry: timeline.entry,
|
|
@@ -1148,6 +1627,7 @@ export async function renderTimeline(options) {
|
|
|
1148
1627
|
finally {
|
|
1149
1628
|
await rm(tempVideo, { force: true });
|
|
1150
1629
|
await rm(tempFinal, { force: true });
|
|
1630
|
+
await sessionPool.close();
|
|
1151
1631
|
try {
|
|
1152
1632
|
await server.close();
|
|
1153
1633
|
}
|