frameyard 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +1 -1
  3. package/dist/preview/fonts/LICENSE-figtree.txt +93 -0
  4. package/dist/preview/fonts/LICENSE-space-mono.txt +93 -0
  5. package/dist/preview/fonts/README.md +23 -0
  6. package/dist/preview/fonts/figtree-latin-wght-normal.woff2 +0 -0
  7. package/dist/preview/fonts/space-mono-latin-400-normal.woff2 +0 -0
  8. package/dist/preview/fonts/space-mono-latin-700-normal.woff2 +0 -0
  9. package/dist/preview/main.js +5 -2
  10. package/dist/preview/main.js.map +1 -1
  11. package/dist/preview/timeline-main.js +41 -23
  12. package/dist/preview/timeline-main.js.map +1 -1
  13. package/dist/renderer/audio.d.ts +6 -0
  14. package/dist/renderer/audio.d.ts.map +1 -1
  15. package/dist/renderer/audio.js +3 -1
  16. package/dist/renderer/audio.js.map +1 -1
  17. package/dist/renderer/browser.d.ts.map +1 -1
  18. package/dist/renderer/browser.js +13 -0
  19. package/dist/renderer/browser.js.map +1 -1
  20. package/dist/renderer/concurrency.d.ts +6 -0
  21. package/dist/renderer/concurrency.d.ts.map +1 -0
  22. package/dist/renderer/concurrency.js +27 -0
  23. package/dist/renderer/concurrency.js.map +1 -0
  24. package/dist/renderer/ffmpeg.d.ts +62 -3
  25. package/dist/renderer/ffmpeg.d.ts.map +1 -1
  26. package/dist/renderer/ffmpeg.js +326 -45
  27. package/dist/renderer/ffmpeg.js.map +1 -1
  28. package/dist/renderer/session-pool.d.ts +28 -0
  29. package/dist/renderer/session-pool.d.ts.map +1 -0
  30. package/dist/renderer/session-pool.js +91 -0
  31. package/dist/renderer/session-pool.js.map +1 -0
  32. package/dist/renderer/timeline.d.ts.map +1 -1
  33. package/dist/renderer/timeline.js +518 -38
  34. package/dist/renderer/timeline.js.map +1 -1
  35. package/dist/renderer/timings.d.ts +13 -0
  36. package/dist/renderer/timings.d.ts.map +1 -0
  37. package/dist/renderer/timings.js +29 -0
  38. package/dist/renderer/timings.js.map +1 -0
  39. package/dist/server/preview-html.d.ts.map +1 -1
  40. package/dist/server/preview-html.js +74 -188
  41. package/dist/server/preview-html.js.map +1 -1
  42. package/dist/server/preview-style.d.ts +11 -0
  43. package/dist/server/preview-style.d.ts.map +1 -0
  44. package/dist/server/preview-style.js +298 -0
  45. package/dist/server/preview-style.js.map +1 -0
  46. package/dist/server/timeline-preview-html.d.ts.map +1 -1
  47. package/dist/server/timeline-preview-html.js +140 -164
  48. package/dist/server/timeline-preview-html.js.map +1 -1
  49. package/dist/version.d.ts +1 -1
  50. package/dist/version.d.ts.map +1 -1
  51. package/dist/version.js +1 -1
  52. package/dist/version.js.map +1 -1
  53. package/docs/adr/0012-render-timelines-with-segmented-lossless-clip-intermediates.md +1 -1
  54. package/docs/adr/0024-stream-composition-captures-and-skip-trivial-composites.md +86 -0
  55. package/docs/adr/0025-capture-timeline-layers-in-parallel-with-a-global-limit.md +67 -0
  56. package/docs/adr/0026-reuse-loaded-composition-sessions-across-segments.md +70 -0
  57. package/docs/adr/0027-encode-two-full-frame-layers-through-dual-pipes.md +76 -0
  58. package/docs/adr/0028-encode-full-frame-video-segments-directly.md +65 -0
  59. package/docs/preview-testing.md +8 -0
  60. package/docs/v0.x-design.md +3 -3
  61. package/package.json +2 -2
  62. package/skills/frameyard/SKILL.md +1 -1
@@ -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, ENCODER_SESSION_LIMIT, encodePngSequenceToFfv1, mixAudioTimeline, muxVideoWithAudio, resolveVideoEncoder, sceneLayerToCompositeLayer, } from "./ffmpeg.js";
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
- async function captureCompositionLayer(options) {
328
- const { project, timeline, clip, source, segment, layerIndex, serverUrl, segmentDir, infoCache } = options;
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 prefix = `comp-${layerIndex}`;
349
- const session = await openCompositionSession({
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
- await writeFile(path.join(segmentDir, `${prefix}-${String(offset).padStart(4, "0")}.png`), png);
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
- await session.close();
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
- const outputPath = path.join(segmentDir, `${prefix}.mkv`);
373
- await encodePngSequenceToFfv1({
374
- pattern: path.join(segmentDir, `${prefix}-%04d.png`),
375
- outputPath,
376
- fps: timeline.format.fps,
377
- signal: options.signal,
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
- return { path: outputPath, diagnostics };
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
- for (const [layerIndex, layer] of segment.layers.entries()) {
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 captured = await captureStaticCompositionLayer({
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
- diagnostics.push(...captured.diagnostics);
451
- layers.push(sceneLayerToCompositeLayer(layer, { path: captured.path, input: "image" }));
452
- continue;
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 captured = await captureCompositionLayer({
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
- diagnostics.push(...captured.diagnostics);
467
- layers.push(sceneLayerToCompositeLayer(layer, { path: captured.path, input: "sequence" }));
468
- continue;
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
- layers.push(sceneLayerToCompositeLayer(layer, {
472
- path: source.absolutePath,
473
- input: source.kind,
474
- ...(source.kind === "video" ? { sourceStartFrame: layer.sourceStart } : {}),
475
- }));
476
- continue;
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
- diagnostics.push(...nested.diagnostics);
492
- layers.push(sceneLayerToCompositeLayer(layer, { path: nested.path, input: "sequence" }));
493
- continue;
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
- const extension = codec === "ffv1" ? "mkv" : CONTAINER_EXTENSION[options.container ?? "mp4"];
498
- const segmentPath = path.join(scratchRoot, `${prefix}-segment-${String(segment.index).padStart(4, "0")}.${extension}`);
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
  }