frameyard 0.8.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 +177 -14
- package/GLOSSARY.md +112 -2
- package/README.md +302 -265
- package/dist/assets/integrations.d.ts +8 -4
- package/dist/assets/integrations.d.ts.map +1 -1
- package/dist/assets/integrations.js +8 -4
- package/dist/assets/integrations.js.map +1 -1
- package/dist/assets/provenance.d.ts +2 -2
- package/dist/assets/provenance.d.ts.map +1 -1
- package/dist/assets/provenance.js +5 -3
- package/dist/assets/provenance.js.map +1 -1
- package/dist/assets/tts-supertonic.d.ts +28 -0
- package/dist/assets/tts-supertonic.d.ts.map +1 -0
- package/dist/assets/tts-supertonic.js +156 -0
- package/dist/assets/tts-supertonic.js.map +1 -0
- package/dist/assets/tts.d.ts +18 -6
- package/dist/assets/tts.d.ts.map +1 -1
- package/dist/assets/tts.js +219 -97
- package/dist/assets/tts.js.map +1 -1
- package/dist/cli.js +1685 -93
- package/dist/cli.js.map +1 -1
- package/dist/composition/format.d.ts +1 -1
- package/dist/composition/format.d.ts.map +1 -1
- package/dist/composition/format.js +1 -1
- package/dist/composition/format.js.map +1 -1
- package/dist/context.d.ts +82 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +277 -0
- package/dist/context.js.map +1 -0
- package/dist/contracts.d.ts +25 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js.map +1 -1
- package/dist/doctor.d.ts +14 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +166 -7
- 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/fonts/add.d.ts +41 -0
- package/dist/fonts/add.d.ts.map +1 -0
- package/dist/fonts/add.js +259 -0
- package/dist/fonts/add.js.map +1 -0
- package/dist/fonts/binary.d.ts +4 -0
- package/dist/fonts/binary.d.ts.map +1 -0
- package/dist/fonts/binary.js +21 -0
- package/dist/fonts/binary.js.map +1 -0
- package/dist/fonts/css.d.ts +4 -0
- package/dist/fonts/css.d.ts.map +1 -0
- package/dist/fonts/css.js +50 -0
- package/dist/fonts/css.js.map +1 -0
- package/dist/fonts/fontsource.d.ts +107 -0
- package/dist/fonts/fontsource.d.ts.map +1 -0
- package/dist/fonts/fontsource.js +565 -0
- package/dist/fonts/fontsource.js.map +1 -0
- package/dist/fonts/index.d.ts +11 -0
- package/dist/fonts/index.d.ts.map +1 -0
- package/dist/fonts/index.js +11 -0
- package/dist/fonts/index.js.map +1 -0
- package/dist/fonts/install.d.ts +24 -0
- package/dist/fonts/install.d.ts.map +1 -0
- package/dist/fonts/install.js +145 -0
- package/dist/fonts/install.js.map +1 -0
- package/dist/fonts/manifest.d.ts +21 -0
- package/dist/fonts/manifest.d.ts.map +1 -0
- package/dist/fonts/manifest.js +96 -0
- package/dist/fonts/manifest.js.map +1 -0
- package/dist/fonts/options.d.ts +9 -0
- package/dist/fonts/options.d.ts.map +1 -0
- package/dist/fonts/options.js +66 -0
- package/dist/fonts/options.js.map +1 -0
- package/dist/fonts/schema.d.ts +4 -0
- package/dist/fonts/schema.d.ts.map +1 -0
- package/dist/fonts/schema.js +316 -0
- package/dist/fonts/schema.js.map +1 -0
- package/dist/fonts/summary.d.ts +16 -0
- package/dist/fonts/summary.d.ts.map +1 -0
- package/dist/fonts/summary.js +22 -0
- package/dist/fonts/summary.js.map +1 -0
- package/dist/fonts/types.d.ts +56 -0
- package/dist/fonts/types.d.ts.map +1 -0
- package/dist/fonts/types.js +8 -0
- package/dist/fonts/types.js.map +1 -0
- package/dist/fonts/verify.d.ts +33 -0
- package/dist/fonts/verify.d.ts.map +1 -0
- package/dist/fonts/verify.js +200 -0
- package/dist/fonts/verify.js.map +1 -0
- package/dist/html/validate.d.ts.map +1 -1
- package/dist/html/validate.js +59 -0
- package/dist/html/validate.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/inspect.d.ts +61 -0
- package/dist/inspect.d.ts.map +1 -0
- package/dist/inspect.js +325 -0
- package/dist/inspect.js.map +1 -0
- package/dist/lint.d.ts +63 -0
- package/dist/lint.d.ts.map +1 -0
- package/dist/lint.js +265 -0
- package/dist/lint.js.map +1 -0
- package/dist/policy.d.ts +69 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +361 -0
- package/dist/policy.js.map +1 -0
- 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-data.d.ts +17 -69
- package/dist/preview/timeline-data.d.ts.map +1 -1
- package/dist/preview/timeline-host.d.ts.map +1 -1
- package/dist/preview/timeline-host.js +70 -45
- package/dist/preview/timeline-host.js.map +1 -1
- package/dist/preview/timeline-main.d.ts.map +1 -1
- package/dist/preview/timeline-main.js +88 -27
- package/dist/preview/timeline-main.js.map +1 -1
- package/dist/project.d.ts +10 -3
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +20 -17
- package/dist/project.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 +104 -5
- package/dist/renderer/ffmpeg.d.ts.map +1 -1
- package/dist/renderer/ffmpeg.js +580 -67
- package/dist/renderer/ffmpeg.js.map +1 -1
- package/dist/renderer/index.js +1 -1
- package/dist/renderer/index.js.map +1 -1
- package/dist/renderer/keyframe-expressions.d.ts +39 -0
- package/dist/renderer/keyframe-expressions.d.ts.map +1 -0
- package/dist/renderer/keyframe-expressions.js +180 -0
- package/dist/renderer/keyframe-expressions.js.map +1 -0
- 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 +2 -0
- package/dist/renderer/timeline.d.ts.map +1 -1
- package/dist/renderer/timeline.js +600 -147
- 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/runtime/host.d.ts.map +1 -1
- package/dist/runtime/host.js +4 -0
- package/dist/runtime/host.js.map +1 -1
- package/dist/scaffold.d.ts +3 -0
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +48 -320
- package/dist/scaffold.js.map +1 -1
- package/dist/server/composition-html.d.ts +2 -0
- package/dist/server/composition-html.d.ts.map +1 -1
- package/dist/server/composition-html.js +7 -0
- package/dist/server/composition-html.js.map +1 -1
- 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/server.d.ts.map +1 -1
- package/dist/server/server.js +31 -1
- 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 +12 -34
- 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 +140 -148
- package/dist/server/timeline-preview-html.js.map +1 -1
- package/dist/templates.d.ts +16 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +186 -0
- package/dist/templates.js.map +1 -0
- package/dist/timeline/easing.d.ts +36 -0
- package/dist/timeline/easing.d.ts.map +1 -0
- package/dist/timeline/easing.js +225 -0
- package/dist/timeline/easing.js.map +1 -0
- package/dist/timeline/keyframes.d.ts +23 -0
- package/dist/timeline/keyframes.d.ts.map +1 -0
- package/dist/timeline/keyframes.js +95 -0
- package/dist/timeline/keyframes.js.map +1 -0
- package/dist/timeline/plan.d.ts +6 -12
- package/dist/timeline/plan.d.ts.map +1 -1
- package/dist/timeline/plan.js +17 -30
- package/dist/timeline/plan.js.map +1 -1
- package/dist/timeline/resolve.d.ts +12 -0
- package/dist/timeline/resolve.d.ts.map +1 -1
- package/dist/timeline/resolve.js +105 -9
- package/dist/timeline/resolve.js.map +1 -1
- package/dist/timeline/scene.d.ts +126 -0
- package/dist/timeline/scene.d.ts.map +1 -0
- package/dist/timeline/scene.js +109 -0
- package/dist/timeline/scene.js.map +1 -0
- package/dist/timeline/schema.d.ts +31 -0
- package/dist/timeline/schema.d.ts.map +1 -1
- package/dist/timeline/schema.js +240 -9
- package/dist/timeline/schema.js.map +1 -1
- package/dist/timeline/transitions.d.ts +21 -0
- package/dist/timeline/transitions.d.ts.map +1 -0
- package/dist/timeline/transitions.js +23 -0
- package/dist/timeline/transitions.js.map +1 -0
- 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/0007-make-html-documents-the-composition-unit.md +1 -1
- package/docs/adr/0010-make-timelines-the-render-unit.md +3 -2
- package/docs/adr/0012-render-timelines-with-segmented-lossless-clip-intermediates.md +1 -1
- package/docs/adr/0014-compose-timeline-clips-with-css-blend-modes.md +2 -1
- package/docs/adr/0015-inherit-composition-format-from-the-project.md +2 -2
- package/docs/adr/0016-author-compositions-as-html-fragments.md +1 -1
- package/docs/adr/0018-animate-timeline-clips-with-keyframes.md +35 -0
- package/docs/adr/0019-make-same-track-overlap-the-transition-window.md +36 -0
- package/docs/adr/0020-crossfade-audio-clips-on-audio-tracks.md +27 -0
- package/docs/adr/0021-project-timeline-scenes-once.md +32 -0
- package/docs/adr/0022-gate-heavy-operations-with-a-workspace-resource-policy.md +35 -0
- package/docs/adr/0023-pin-fonts-as-project-assets.md +98 -0
- 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/migration-0.4.md +2 -2
- package/docs/preview-testing.md +27 -6
- package/docs/{v0.1-design.md → v0.x-design.md} +57 -32
- package/examples/blend/README.md +3 -3
- package/examples/demo/README.md +2 -2
- package/examples/intro/README.md +71 -0
- package/examples/intro/assets/fonts/figtree/LICENSE.txt +93 -0
- package/examples/intro/assets/fonts/figtree/figtree-latin-wght-normal.woff2 +0 -0
- package/examples/intro/assets/fonts/instrument-serif/LICENSE.txt +93 -0
- package/examples/intro/assets/fonts/instrument-serif/instrument-serif-latin-400-italic.woff2 +0 -0
- package/examples/intro/assets/fonts/space-mono/LICENSE.txt +93 -0
- package/examples/intro/assets/fonts/space-mono/space-mono-latin-400-normal.woff2 +0 -0
- package/examples/intro/assets/fonts/space-mono/space-mono-latin-700-normal.woff2 +0 -0
- package/examples/intro/assets/grain.png +0 -0
- package/examples/intro/assets/score.ogg +0 -0
- package/examples/intro/compositions/brand.html +412 -0
- package/examples/intro/compositions/chrome.controller.js +63 -0
- package/examples/intro/compositions/chrome.html +256 -0
- package/examples/intro/compositions/lattice.controller.js +148 -0
- package/examples/intro/compositions/lattice.html +308 -0
- package/examples/intro/compositions/opening.html +504 -0
- package/examples/intro/compositions/studio.html +930 -0
- package/examples/intro/compositions/theme.css +218 -0
- package/examples/intro/fy.config.js +13 -0
- package/examples/intro/fy.fonts.json +100 -0
- package/examples/intro/scripts/make-grain.mjs +79 -0
- package/examples/intro/scripts/make-score.mjs +702 -0
- package/examples/intro/timelines/intro.timeline.json +60 -0
- package/examples/keyframes/README.md +76 -0
- package/examples/keyframes/compositions/backdrop.html +60 -0
- package/examples/keyframes/compositions/card.html +99 -0
- package/examples/keyframes/fy.config.js +10 -0
- package/examples/keyframes/timelines/keyframes.timeline.json +60 -0
- package/examples/nested/README.md +112 -0
- package/examples/nested/assets/pulse.wav +0 -0
- package/examples/nested/compositions/backdrop.html +84 -0
- package/examples/nested/compositions/card.html +275 -0
- package/examples/nested/compositions/overlay.html +307 -0
- package/examples/nested/fy.config.js +13 -0
- package/examples/nested/scripts/make-pulse.sh +20 -0
- package/examples/nested/timelines/chapter-a.timeline.json +39 -0
- package/examples/nested/timelines/chapter-b.timeline.json +39 -0
- package/examples/nested/timelines/nested-reel.timeline.json +52 -0
- package/examples/static/README.md +2 -2
- package/examples/transitions/README.md +128 -0
- package/examples/transitions/assets/alpha.wav +0 -0
- package/examples/transitions/assets/beta.wav +0 -0
- package/examples/transitions/assets/gamma.wav +0 -0
- package/examples/transitions/compositions/alpha.html +56 -0
- package/examples/transitions/compositions/beta.html +56 -0
- package/examples/transitions/compositions/gamma.html +56 -0
- package/examples/transitions/fy.config.js +12 -0
- package/examples/transitions/timelines/audio-crossfade.timeline.json +44 -0
- package/examples/transitions/timelines/transitions.timeline.json +31 -0
- package/examples/video/README.md +4 -4
- package/examples/webgl/README.md +1 -1
- package/package.json +13 -6
- package/scaffolds/init/AGENTS.md +131 -0
- package/scaffolds/init/README.md +56 -0
- package/scaffolds/init/compositions/hello.html +79 -0
- package/scaffolds/init/fy.config.js +9 -0
- package/scaffolds/init/gitignore.template +1 -0
- package/scaffolds/init/package.json +11 -0
- package/scaffolds/init/props/hello.json +4 -0
- package/scaffolds/init/props/main.json +6 -0
- package/scaffolds/init/timelines/main.timeline.json +20 -0
- package/scaffolds/local-models/LOCAL-MODELS.md +77 -0
- package/scaffolds/local-models/fy.policy.json +5 -0
- package/scaffolds/local-models/opencode/opencode.jsonc +17 -0
- package/scaffolds/local-models/pi/APPEND_SYSTEM.md +21 -0
- package/scaffolds/local-models/pi/sandbox.json +15 -0
- package/scaffolds/local-models/pi/settings.json +15 -0
- package/skills/frameyard/README.md +1 -0
- package/skills/frameyard/SKILL.md +59 -61
- package/skills/frameyard/references/audio-assets.md +32 -3
- package/skills/frameyard/references/common-mistakes.md +53 -2
- package/skills/frameyard/references/deterministic-authoring.md +10 -3
- package/skills/frameyard/references/diagnostics.md +31 -1
- package/skills/frameyard/references/failure-recovery.md +32 -6
- package/skills/frameyard/references/fonts.md +132 -0
- package/skills/frameyard/references/json-results.md +119 -6
- package/skills/frameyard/references/quick-reference.md +222 -0
- package/skills/frameyard/references/timelines.md +97 -7
- /package/examples/blend/{video.config.js → fy.config.js} +0 -0
- /package/examples/demo/{video.config.js → fy.config.js} +0 -0
- /package/examples/static/{video.config.js → fy.config.js} +0 -0
- /package/examples/video/{video.config.js → fy.config.js} +0 -0
- /package/examples/webgl/{video.config.js → fy.config.js} +0 -0
|
@@ -8,9 +8,16 @@ 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";
|
|
16
|
+
import { evaluatePresentation } from "../timeline/keyframes.js";
|
|
17
|
+
import { transitionProgress } from "../timeline/transitions.js";
|
|
18
|
+
import { KEYFRAME_CHANNELS } from "../timeline/schema.js";
|
|
13
19
|
import { planTimelineRender } from "../timeline/plan.js";
|
|
20
|
+
import { projectScene, transitionSpans } from "../timeline/scene.js";
|
|
14
21
|
import { resolveProjectTimeline, } from "../timeline/resolve.js";
|
|
15
22
|
export const MAX_TIMELINE_WORKERS = 16;
|
|
16
23
|
function validateWorkers(value) {
|
|
@@ -30,11 +37,42 @@ function validateWorkers(value) {
|
|
|
30
37
|
export function clampTimelineWorkers(workers, encoder) {
|
|
31
38
|
return Math.min(workers, ENCODER_SESSION_LIMIT[encoder]);
|
|
32
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
|
+
}
|
|
33
56
|
function throwIfAborted(signal) {
|
|
34
57
|
if (signal?.aborted) {
|
|
35
58
|
throw new FrameyardError("RENDER_FAILED", "Operation cancelled.");
|
|
36
59
|
}
|
|
37
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
|
+
}
|
|
38
76
|
async function pathExists(filePath) {
|
|
39
77
|
try {
|
|
40
78
|
await stat(filePath);
|
|
@@ -136,19 +174,6 @@ function compositionDiagnostic(diagnostic, timelineId, clip, compositionId) {
|
|
|
136
174
|
function clampVolume(value) {
|
|
137
175
|
return Math.min(1, Math.max(0, value));
|
|
138
176
|
}
|
|
139
|
-
function visualSourceSize(clip, timeline) {
|
|
140
|
-
const source = clip.source;
|
|
141
|
-
if (source.kind === "composition") {
|
|
142
|
-
return { width: source.metadata.width, height: source.metadata.height };
|
|
143
|
-
}
|
|
144
|
-
if (source.kind === "video" || source.kind === "image") {
|
|
145
|
-
if (source.width === undefined || source.height === undefined) {
|
|
146
|
-
throw new FrameyardError("TIMELINE_MEDIA_INVALID", `Media ${source.src} is missing width/height metadata for clip "${clip.id}".`);
|
|
147
|
-
}
|
|
148
|
-
return { width: source.width, height: source.height };
|
|
149
|
-
}
|
|
150
|
-
return { width: timeline.format.width, height: timeline.format.height };
|
|
151
|
-
}
|
|
152
177
|
function mapCompositionAudio(clip, audio, fps, window) {
|
|
153
178
|
if (window.muted) {
|
|
154
179
|
return null;
|
|
@@ -178,11 +203,16 @@ function mapCompositionAudio(clip, audio, fps, window) {
|
|
|
178
203
|
trimStart: audio.trimStart + (effectiveStart - audio.startFrame) / fps,
|
|
179
204
|
trimDuration: duration,
|
|
180
205
|
volume: clampVolume(audio.volume * window.gain),
|
|
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 } : {}),
|
|
181
210
|
};
|
|
182
211
|
}
|
|
183
212
|
function collectTimelineAudio(timeline, window, clips, transcripts) {
|
|
184
213
|
const fps = timeline.format.fps;
|
|
185
214
|
const trackById = new Map(timeline.tracks.map((track) => [track.id, track]));
|
|
215
|
+
const spansByClip = transitionSpans(timeline);
|
|
186
216
|
for (const clip of timeline.clips) {
|
|
187
217
|
const source = clip.source;
|
|
188
218
|
const mappedStart = clip.start + window.frameOffset;
|
|
@@ -194,6 +224,13 @@ function collectTimelineAudio(timeline, window, clips, transcripts) {
|
|
|
194
224
|
}
|
|
195
225
|
const clipGain = window.gain * (clip.audio?.gain ?? 1);
|
|
196
226
|
const clipMuted = window.muted || (clip.audio?.muted ?? false);
|
|
227
|
+
const ownEnvelopes = (spansByClip.get(clip.id) ?? []).map((span) => ({
|
|
228
|
+
startFrame: span.start + window.frameOffset,
|
|
229
|
+
endFrame: span.end + window.frameOffset,
|
|
230
|
+
...(span.easing !== undefined ? { easing: span.easing } : {}),
|
|
231
|
+
role: span.role,
|
|
232
|
+
}));
|
|
233
|
+
const envelopes = ownEnvelopes.length > 0 ? [...window.envelopes, ...ownEnvelopes] : window.envelopes;
|
|
197
234
|
if (source.kind === "composition") {
|
|
198
235
|
for (const transcript of source.transcriptClips) {
|
|
199
236
|
transcripts.set(`${transcript.src}\u0000${transcript.words}`, transcript);
|
|
@@ -202,13 +239,13 @@ function collectTimelineAudio(timeline, window, clips, transcripts) {
|
|
|
202
239
|
continue;
|
|
203
240
|
}
|
|
204
241
|
for (const audio of source.audioClips) {
|
|
205
|
-
const mapped = mapCompositionAudio(clip, audio, fps, { ...window, gain: clipGain });
|
|
242
|
+
const mapped = mapCompositionAudio(clip, audio, fps, { ...window, gain: clipGain, envelopes });
|
|
206
243
|
if (mapped) {
|
|
207
244
|
clips.push(mapped);
|
|
208
245
|
}
|
|
209
246
|
}
|
|
210
247
|
for (const audio of source.embeddedAudioClips) {
|
|
211
|
-
const mapped = mapCompositionAudio(clip, audio, fps, { ...window, gain: clipGain });
|
|
248
|
+
const mapped = mapCompositionAudio(clip, audio, fps, { ...window, gain: clipGain, envelopes });
|
|
212
249
|
if (mapped) {
|
|
213
250
|
clips.push({ ...mapped, allowSilent: true });
|
|
214
251
|
}
|
|
@@ -222,6 +259,7 @@ function collectTimelineAudio(timeline, window, clips, transcripts) {
|
|
|
222
259
|
endFrame,
|
|
223
260
|
gain: clipGain,
|
|
224
261
|
muted: clipMuted,
|
|
262
|
+
envelopes,
|
|
225
263
|
}, clips, transcripts);
|
|
226
264
|
continue;
|
|
227
265
|
}
|
|
@@ -233,6 +271,7 @@ function collectTimelineAudio(timeline, window, clips, transcripts) {
|
|
|
233
271
|
trimStart: clip.sourceIn / fps + (startFrame - mappedStart) / fps,
|
|
234
272
|
trimDuration: (endFrame - startFrame) / fps,
|
|
235
273
|
volume: clampVolume(clipGain),
|
|
274
|
+
...(envelopes.length > 0 ? { envelopes } : {}),
|
|
236
275
|
});
|
|
237
276
|
}
|
|
238
277
|
continue;
|
|
@@ -248,6 +287,7 @@ function collectTimelineAudio(timeline, window, clips, transcripts) {
|
|
|
248
287
|
trimStart: clip.sourceIn / fps + (startFrame - mappedStart) / fps,
|
|
249
288
|
trimDuration: (endFrame - startFrame) / fps,
|
|
250
289
|
volume: clampVolume(clipGain * (track.gain ?? 1)),
|
|
290
|
+
...(envelopes.length > 0 ? { envelopes } : {}),
|
|
251
291
|
});
|
|
252
292
|
}
|
|
253
293
|
}
|
|
@@ -255,7 +295,7 @@ function collectTimelineAudio(timeline, window, clips, transcripts) {
|
|
|
255
295
|
function buildAudioClips(timeline) {
|
|
256
296
|
const clips = [];
|
|
257
297
|
const transcripts = new Map();
|
|
258
|
-
collectTimelineAudio(timeline, { frameOffset: 0, startFrame: 0, endFrame: timeline.durationInFrames, gain: 1, muted: false }, clips, transcripts);
|
|
298
|
+
collectTimelineAudio(timeline, { frameOffset: 0, startFrame: 0, endFrame: timeline.durationInFrames, gain: 1, muted: false, envelopes: [] }, clips, transcripts);
|
|
259
299
|
return { clips, transcripts: [...transcripts.values()] };
|
|
260
300
|
}
|
|
261
301
|
/** Resolved timeline audio for preview surfaces. */
|
|
@@ -263,7 +303,7 @@ export function resolveTimelineAudio(timeline) {
|
|
|
263
303
|
const { clips, transcripts } = buildAudioClips(timeline);
|
|
264
304
|
return {
|
|
265
305
|
clips: clips.map((clip) => {
|
|
266
|
-
const { allowSilent: _allowSilent, ...resolved } = clip;
|
|
306
|
+
const { allowSilent: _allowSilent, allowShortSource: _allowShortSource, ...resolved } = clip;
|
|
267
307
|
return resolved;
|
|
268
308
|
}),
|
|
269
309
|
transcripts,
|
|
@@ -321,8 +361,13 @@ async function captureStaticCompositionLayer(options) {
|
|
|
321
361
|
throw error;
|
|
322
362
|
}
|
|
323
363
|
}
|
|
324
|
-
|
|
325
|
-
|
|
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;
|
|
326
371
|
const propsKey = JSON.stringify(clip.props ?? null);
|
|
327
372
|
const cacheKey = `${source.entry}\u0000${propsKey}`;
|
|
328
373
|
let validation = infoCache.get(cacheKey);
|
|
@@ -342,38 +387,293 @@ async function captureCompositionLayer(options) {
|
|
|
342
387
|
if (!layer) {
|
|
343
388
|
throw new FrameyardError("INTERNAL", `Missing layer ${layerIndex} for segment ${segment.index}.`);
|
|
344
389
|
}
|
|
345
|
-
const
|
|
346
|
-
|
|
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 = {
|
|
347
397
|
serverUrl,
|
|
348
398
|
entry: source.entry,
|
|
349
399
|
props: clip.props,
|
|
350
400
|
info: validation.info,
|
|
351
401
|
staticDiagnostics: validation.diagnostics,
|
|
352
402
|
transparentBackground: true,
|
|
353
|
-
}
|
|
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;
|
|
354
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;
|
|
355
429
|
try {
|
|
356
430
|
for (let offset = 0; offset < segment.frames; offset += 1) {
|
|
357
431
|
throwIfAborted(options.signal);
|
|
432
|
+
const seekStarted = performance.now();
|
|
358
433
|
await session.seek(layer.sourceStart + offset);
|
|
434
|
+
seekMs += performance.now() - seekStarted;
|
|
435
|
+
const captureStarted = performance.now();
|
|
359
436
|
const png = await session.capture();
|
|
360
|
-
|
|
437
|
+
captureMs += performance.now() - captureStarted;
|
|
438
|
+
pngBytes += png.length;
|
|
439
|
+
const writeStarted = performance.now();
|
|
440
|
+
await writeFrame(png);
|
|
441
|
+
writeMs += performance.now() - writeStarted;
|
|
361
442
|
}
|
|
362
443
|
for (const diagnostic of await session.readDiagnostics()) {
|
|
363
444
|
diagnostics.push(compositionDiagnostic(diagnostic, timeline.id, clip, source.id));
|
|
364
445
|
}
|
|
446
|
+
healthy = true;
|
|
365
447
|
}
|
|
366
448
|
finally {
|
|
367
|
-
|
|
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
|
+
}
|
|
368
461
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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,
|
|
375
471
|
});
|
|
376
|
-
|
|
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 };
|
|
377
677
|
}
|
|
378
678
|
async function renderNestedTimelineRange(options) {
|
|
379
679
|
if (options.depth > 32) {
|
|
@@ -395,6 +695,8 @@ async function renderNestedTimelineRange(options) {
|
|
|
395
695
|
scratchRoot: options.scratchRoot,
|
|
396
696
|
infoCache: options.infoCache,
|
|
397
697
|
staticCache: options.staticCache,
|
|
698
|
+
...(options.captureLimiter !== undefined ? { captureLimiter: options.captureLimiter } : {}),
|
|
699
|
+
...(options.sessionPool !== undefined ? { sessionPool: options.sessionPool } : {}),
|
|
398
700
|
codec: "ffv1",
|
|
399
701
|
background: options.timeline.background ?? "transparent",
|
|
400
702
|
prefix,
|
|
@@ -424,36 +726,118 @@ async function processTimelineSegment(options) {
|
|
|
424
726
|
const prefix = options.prefix ?? "timeline";
|
|
425
727
|
const depth = options.depth ?? 0;
|
|
426
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
|
+
}
|
|
427
830
|
await mkdir(segmentDir, { recursive: true });
|
|
428
831
|
const diagnostics = [];
|
|
429
832
|
const layers = [];
|
|
430
|
-
|
|
833
|
+
const layerTasks = segment.layers.map(async (layer, layerIndex) => {
|
|
431
834
|
throwIfAborted(options.signal);
|
|
432
835
|
const clip = layer.clip;
|
|
433
836
|
const source = clip.source;
|
|
434
|
-
const transform = clip.transform ?? {
|
|
435
|
-
fit: "contain",
|
|
436
|
-
x: 0,
|
|
437
|
-
y: 0,
|
|
438
|
-
scale: 1,
|
|
439
|
-
rotation: 0,
|
|
440
|
-
opacity: 1,
|
|
441
|
-
};
|
|
442
|
-
const base = {
|
|
443
|
-
fit: transform.fit,
|
|
444
|
-
x: transform.x,
|
|
445
|
-
y: transform.y,
|
|
446
|
-
...(transform.width !== undefined && transform.height !== undefined
|
|
447
|
-
? { width: transform.width, height: transform.height }
|
|
448
|
-
: {}),
|
|
449
|
-
scale: transform.scale,
|
|
450
|
-
rotation: transform.rotation,
|
|
451
|
-
opacity: transform.opacity,
|
|
452
|
-
blend: clip.blend ?? "normal",
|
|
453
|
-
};
|
|
454
837
|
if (source.kind === "composition") {
|
|
455
|
-
if (
|
|
456
|
-
const
|
|
838
|
+
if (layer.capture === "still") {
|
|
839
|
+
const timing = startTiming("timeline.layer.static", { layer: layerIndex, segment: segment.index });
|
|
840
|
+
const captured = await withCaptureSlot(options.captureLimiter, options.signal, () => captureStaticCompositionLayer({
|
|
457
841
|
project,
|
|
458
842
|
timeline,
|
|
459
843
|
clip,
|
|
@@ -463,18 +847,20 @@ async function processTimelineSegment(options) {
|
|
|
463
847
|
infoCache,
|
|
464
848
|
staticCache: options.staticCache,
|
|
465
849
|
...(options.signal ? { signal: options.signal } : {}),
|
|
466
|
-
});
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
path: captured.path,
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
...base,
|
|
474
|
-
});
|
|
475
|
-
continue;
|
|
850
|
+
}));
|
|
851
|
+
timing.finish();
|
|
852
|
+
return {
|
|
853
|
+
layerIndex,
|
|
854
|
+
compositeLayer: sceneLayerToCompositeLayer(layer, { path: captured.path, input: "image" }),
|
|
855
|
+
diagnostics: captured.diagnostics,
|
|
856
|
+
};
|
|
476
857
|
}
|
|
477
|
-
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({
|
|
478
864
|
project,
|
|
479
865
|
timeline,
|
|
480
866
|
clip,
|
|
@@ -484,31 +870,29 @@ async function processTimelineSegment(options) {
|
|
|
484
870
|
serverUrl,
|
|
485
871
|
segmentDir,
|
|
486
872
|
infoCache,
|
|
873
|
+
...(options.sessionPool !== undefined ? { sessionPool: options.sessionPool } : {}),
|
|
487
874
|
...(options.signal ? { signal: options.signal } : {}),
|
|
488
|
-
});
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
path: captured.path,
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
...base,
|
|
496
|
-
});
|
|
497
|
-
continue;
|
|
875
|
+
}));
|
|
876
|
+
timing.finish();
|
|
877
|
+
return {
|
|
878
|
+
layerIndex,
|
|
879
|
+
compositeLayer: sceneLayerToCompositeLayer(layer, { path: captured.path, input: "sequence" }),
|
|
880
|
+
diagnostics: captured.diagnostics,
|
|
881
|
+
};
|
|
498
882
|
}
|
|
499
883
|
if (source.kind === "video" || source.kind === "image") {
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
}
|
|
509
|
-
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
|
+
};
|
|
510
893
|
}
|
|
511
894
|
if (source.kind === "timeline") {
|
|
895
|
+
const timing = startTiming("timeline.layer.nested", { layer: layerIndex, segment: segment.index });
|
|
512
896
|
const nested = await renderNestedTimelineRange({
|
|
513
897
|
project,
|
|
514
898
|
timeline: source.timeline,
|
|
@@ -518,23 +902,39 @@ async function processTimelineSegment(options) {
|
|
|
518
902
|
scratchRoot,
|
|
519
903
|
infoCache,
|
|
520
904
|
staticCache: options.staticCache,
|
|
905
|
+
...(options.captureLimiter !== undefined ? { captureLimiter: options.captureLimiter } : {}),
|
|
906
|
+
...(options.sessionPool !== undefined ? { sessionPool: options.sessionPool } : {}),
|
|
521
907
|
depth: depth + 1,
|
|
522
908
|
...(options.signal ? { signal: options.signal } : {}),
|
|
523
909
|
});
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
path: nested.path,
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
...base,
|
|
531
|
-
});
|
|
532
|
-
continue;
|
|
910
|
+
timing.finish();
|
|
911
|
+
return {
|
|
912
|
+
layerIndex,
|
|
913
|
+
compositeLayer: sceneLayerToCompositeLayer(layer, { path: nested.path, input: "sequence" }),
|
|
914
|
+
diagnostics: nested.diagnostics,
|
|
915
|
+
};
|
|
533
916
|
}
|
|
534
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);
|
|
535
926
|
}
|
|
536
|
-
|
|
537
|
-
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
|
+
});
|
|
538
938
|
await compositeTimelineSegment({
|
|
539
939
|
outputPath: segmentPath,
|
|
540
940
|
width: timeline.format.width,
|
|
@@ -544,13 +944,63 @@ async function processTimelineSegment(options) {
|
|
|
544
944
|
background,
|
|
545
945
|
layers,
|
|
546
946
|
codec,
|
|
947
|
+
frameOffset: segment.start,
|
|
547
948
|
...(options.container !== undefined ? { container: options.container } : {}),
|
|
548
949
|
...(options.encoder !== undefined ? { encoder: options.encoder } : {}),
|
|
549
950
|
...(options.signal ? { signal: options.signal } : {}),
|
|
550
951
|
});
|
|
952
|
+
compositeTiming.finish();
|
|
953
|
+
const cleanupTiming = startTiming("timeline.segment.cleanup", { segment: segment.index });
|
|
551
954
|
await rm(segmentDir, { recursive: true, force: true });
|
|
955
|
+
cleanupTiming.finish();
|
|
956
|
+
segmentTiming.finish();
|
|
552
957
|
return { path: segmentPath, diagnostics };
|
|
553
958
|
}
|
|
959
|
+
function checkKeyframeEvaluation(timeline, segments) {
|
|
960
|
+
for (const segment of segments) {
|
|
961
|
+
for (const layer of segment.layers) {
|
|
962
|
+
const keyframes = layer.keyframes;
|
|
963
|
+
if (!keyframes) {
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
for (let frame = segment.start; frame < segment.end; frame += 1) {
|
|
967
|
+
const values = evaluatePresentation(layer.transform, keyframes, frame - layer.clip.start);
|
|
968
|
+
for (const channel of KEYFRAME_CHANNELS) {
|
|
969
|
+
if (!Number.isFinite(values[channel])) {
|
|
970
|
+
throw new FrameyardError("TIMELINE_CLIP_INVALID", `Clip "${layer.clip.id}" keyframes.${channel} produced a non-finite value at timeline frame ${frame}.`, {
|
|
971
|
+
details: {
|
|
972
|
+
timeline: timeline.id,
|
|
973
|
+
clip: layer.clip.id,
|
|
974
|
+
track: layer.clip.track,
|
|
975
|
+
channel,
|
|
976
|
+
frame: frame - layer.clip.start,
|
|
977
|
+
timelineFrame: frame,
|
|
978
|
+
},
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
function checkTransitionEvaluation(timeline) {
|
|
987
|
+
for (const transition of timeline.transitions) {
|
|
988
|
+
for (let frame = transition.start; frame < transition.end; frame += 1) {
|
|
989
|
+
const progress = transitionProgress(transition, frame);
|
|
990
|
+
if (!Number.isFinite(progress)) {
|
|
991
|
+
throw new FrameyardError("TIMELINE_TRANSITION_INVALID", `Transition "${transition.from}" → "${transition.to}" produced a non-finite progress at timeline frame ${frame}.`, {
|
|
992
|
+
details: {
|
|
993
|
+
timeline: timeline.id,
|
|
994
|
+
track: transition.track,
|
|
995
|
+
from: transition.from,
|
|
996
|
+
to: transition.to,
|
|
997
|
+
timelineFrame: frame,
|
|
998
|
+
},
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
554
1004
|
async function checkResolvedTimelineRange(options) {
|
|
555
1005
|
if (options.context.depth > 32) {
|
|
556
1006
|
throw new FrameyardError("TIMELINE_INVALID", "Nested timeline depth exceeds 32.");
|
|
@@ -558,6 +1008,8 @@ async function checkResolvedTimelineRange(options) {
|
|
|
558
1008
|
const { timeline, startFrame, endFrame, context } = options;
|
|
559
1009
|
const plan = planTimelineRender(timeline, { startFrame, endFrame });
|
|
560
1010
|
const diagnostics = [];
|
|
1011
|
+
checkKeyframeEvaluation(timeline, plan.segments);
|
|
1012
|
+
checkTransitionEvaluation(timeline);
|
|
561
1013
|
const targets = new Map();
|
|
562
1014
|
for (const segment of plan.segments) {
|
|
563
1015
|
for (const layer of segment.layers) {
|
|
@@ -664,7 +1116,7 @@ export async function checkTimeline(options) {
|
|
|
664
1116
|
const startedAt = performance.now();
|
|
665
1117
|
const projectTimeline = options.project.timelines.get(options.timelineId);
|
|
666
1118
|
if (!projectTimeline) {
|
|
667
|
-
throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${options.timelineId}" is not listed in
|
|
1119
|
+
throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${options.timelineId}" is not listed in fy.config.js.`);
|
|
668
1120
|
}
|
|
669
1121
|
const resolved = await resolveProjectTimeline({
|
|
670
1122
|
project: options.project,
|
|
@@ -740,6 +1192,7 @@ export async function checkTimeline(options) {
|
|
|
740
1192
|
framesChecked: timeline.durationInFrames,
|
|
741
1193
|
tracks: timeline.tracks.length,
|
|
742
1194
|
clips: timeline.clips.length,
|
|
1195
|
+
transitions: timeline.transitions.length,
|
|
743
1196
|
audio: { clips: audioInspection.clips.length, details: audioInspection.clips },
|
|
744
1197
|
transcripts: {
|
|
745
1198
|
clips: transcripts.clips.length,
|
|
@@ -754,36 +1207,12 @@ async function renderResolvedTimelineFrame(options) {
|
|
|
754
1207
|
throw new FrameyardError("TIMELINE_INVALID", "Nested timeline depth exceeds 32.");
|
|
755
1208
|
}
|
|
756
1209
|
const { timeline, frame, context } = options;
|
|
757
|
-
const
|
|
758
|
-
const segment = plan.segments[0];
|
|
759
|
-
if (!segment) {
|
|
760
|
-
throw new FrameyardError("FRAME_OUT_OF_RANGE", `Frame ${frame} does not belong to a timeline segment.`);
|
|
761
|
-
}
|
|
1210
|
+
const scene = projectScene(timeline, frame, frame + 1);
|
|
762
1211
|
const diagnostics = [];
|
|
763
1212
|
const layers = [];
|
|
764
|
-
for (const [layerIndex, layer] of
|
|
1213
|
+
for (const [layerIndex, layer] of scene.layers.entries()) {
|
|
765
1214
|
const clip = layer.clip;
|
|
766
1215
|
const source = clip.source;
|
|
767
|
-
const transform = clip.transform ?? {
|
|
768
|
-
fit: "contain",
|
|
769
|
-
x: 0,
|
|
770
|
-
y: 0,
|
|
771
|
-
scale: 1,
|
|
772
|
-
rotation: 0,
|
|
773
|
-
opacity: 1,
|
|
774
|
-
};
|
|
775
|
-
const base = {
|
|
776
|
-
fit: transform.fit,
|
|
777
|
-
x: transform.x,
|
|
778
|
-
y: transform.y,
|
|
779
|
-
...(transform.width !== undefined && transform.height !== undefined
|
|
780
|
-
? { width: transform.width, height: transform.height }
|
|
781
|
-
: {}),
|
|
782
|
-
scale: transform.scale,
|
|
783
|
-
rotation: transform.rotation,
|
|
784
|
-
opacity: transform.opacity,
|
|
785
|
-
blend: clip.blend ?? "normal",
|
|
786
|
-
};
|
|
787
1216
|
if (source.kind === "composition") {
|
|
788
1217
|
const validation = await validateHtmlComposition({
|
|
789
1218
|
projectRoot: context.project.root,
|
|
@@ -814,25 +1243,15 @@ async function renderResolvedTimelineFrame(options) {
|
|
|
814
1243
|
finally {
|
|
815
1244
|
await session.close();
|
|
816
1245
|
}
|
|
817
|
-
layers.push({
|
|
818
|
-
kind: "image",
|
|
819
|
-
path: framePath,
|
|
820
|
-
sourceWidth: source.metadata.width,
|
|
821
|
-
sourceHeight: source.metadata.height,
|
|
822
|
-
...base,
|
|
823
|
-
});
|
|
1246
|
+
layers.push(sceneLayerToCompositeLayer(layer, { path: framePath, input: "image" }));
|
|
824
1247
|
continue;
|
|
825
1248
|
}
|
|
826
1249
|
if (source.kind === "video" || source.kind === "image") {
|
|
827
|
-
|
|
828
|
-
layers.push({
|
|
829
|
-
kind: source.kind,
|
|
1250
|
+
layers.push(sceneLayerToCompositeLayer(layer, {
|
|
830
1251
|
path: source.absolutePath,
|
|
831
|
-
|
|
832
|
-
sourceHeight: size.height,
|
|
833
|
-
...base,
|
|
1252
|
+
input: source.kind,
|
|
834
1253
|
...(source.kind === "video" ? { sourceStartFrame: layer.sourceStart } : {}),
|
|
835
|
-
});
|
|
1254
|
+
}));
|
|
836
1255
|
continue;
|
|
837
1256
|
}
|
|
838
1257
|
if (source.kind === "timeline") {
|
|
@@ -845,13 +1264,7 @@ async function renderResolvedTimelineFrame(options) {
|
|
|
845
1264
|
context: { ...context, depth: context.depth + 1 },
|
|
846
1265
|
});
|
|
847
1266
|
diagnostics.push(...nestedDiagnostics);
|
|
848
|
-
layers.push({
|
|
849
|
-
kind: "image",
|
|
850
|
-
path: nestedPath,
|
|
851
|
-
sourceWidth: source.timeline.format.width,
|
|
852
|
-
sourceHeight: source.timeline.format.height,
|
|
853
|
-
...base,
|
|
854
|
-
});
|
|
1267
|
+
layers.push(sceneLayerToCompositeLayer(layer, { path: nestedPath, input: "image" }));
|
|
855
1268
|
continue;
|
|
856
1269
|
}
|
|
857
1270
|
throw new FrameyardError("TIMELINE_INVALID", `Unsupported visual source for clip "${clip.id}".`);
|
|
@@ -863,6 +1276,7 @@ async function renderResolvedTimelineFrame(options) {
|
|
|
863
1276
|
fps: timeline.format.fps,
|
|
864
1277
|
background: options.background,
|
|
865
1278
|
layers,
|
|
1279
|
+
frameOffset: frame,
|
|
866
1280
|
...(context.signal ? { signal: context.signal } : {}),
|
|
867
1281
|
});
|
|
868
1282
|
return diagnostics;
|
|
@@ -895,7 +1309,7 @@ export async function captureTimelineStills(options) {
|
|
|
895
1309
|
const startedAt = performance.now();
|
|
896
1310
|
const projectTimeline = options.project.timelines.get(options.timelineId);
|
|
897
1311
|
if (!projectTimeline) {
|
|
898
|
-
throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${options.timelineId}" is not listed in
|
|
1312
|
+
throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${options.timelineId}" is not listed in fy.config.js.`);
|
|
899
1313
|
}
|
|
900
1314
|
const resolved = await resolveProjectTimeline({
|
|
901
1315
|
project: options.project,
|
|
@@ -1000,20 +1414,25 @@ export async function renderTimeline(options) {
|
|
|
1000
1414
|
const requestedWorkers = validateWorkers(options.workers);
|
|
1001
1415
|
const overwrite = options.overwrite ?? false;
|
|
1002
1416
|
const startedAt = performance.now();
|
|
1417
|
+
const renderTiming = startTiming("timeline.render", { timeline: options.timelineId });
|
|
1003
1418
|
await ensureOutputAvailable(options.outPath, overwrite);
|
|
1004
1419
|
await assertFfmpegCapabilities(options.signal, codec);
|
|
1005
1420
|
const encoder = await resolveVideoEncoder(codec, options.encoder, options.signal);
|
|
1006
1421
|
const workers = clampTimelineWorkers(requestedWorkers, encoder);
|
|
1422
|
+
const captureLimiter = createSemaphore(captureConcurrency(workers));
|
|
1423
|
+
const sessionPool = new CompositionSessionPool({ maxIdle: Math.max(2, workers) });
|
|
1007
1424
|
const projectTimeline = options.project.timelines.get(options.timelineId);
|
|
1008
1425
|
if (!projectTimeline) {
|
|
1009
|
-
throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${options.timelineId}" is not listed in
|
|
1426
|
+
throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${options.timelineId}" is not listed in fy.config.js.`);
|
|
1010
1427
|
}
|
|
1428
|
+
const resolveTiming = startTiming("timeline.resolve", { timeline: options.timelineId });
|
|
1011
1429
|
const resolved = await resolveProjectTimeline({
|
|
1012
1430
|
project: options.project,
|
|
1013
1431
|
timeline: projectTimeline,
|
|
1014
1432
|
...(options.signal ? { signal: options.signal } : {}),
|
|
1015
1433
|
...(options.propsOverrides ? { propsOverrides: options.propsOverrides } : {}),
|
|
1016
1434
|
});
|
|
1435
|
+
resolveTiming.finish({ clips: resolved.timeline?.clips.length ?? 0, diagnostics: resolved.diagnostics.length });
|
|
1017
1436
|
if (!resolved.timeline) {
|
|
1018
1437
|
const first = resolved.diagnostics.find((diagnostic) => diagnostic.severity === "error");
|
|
1019
1438
|
throw new FrameyardError(first?.code ?? "TIMELINE_INVALID", first?.message ?? "Timeline resolution failed.", {
|
|
@@ -1025,7 +1444,9 @@ export async function renderTimeline(options) {
|
|
|
1025
1444
|
const scratchBase = options.scratchDir ?? tmpdir();
|
|
1026
1445
|
await mkdir(scratchBase, { recursive: true });
|
|
1027
1446
|
const scratchRoot = await mkdtemp(path.join(scratchBase, "frameyard-timeline-"));
|
|
1447
|
+
const serverTiming = startTiming("timeline.server", { project: options.project.root });
|
|
1028
1448
|
const server = await startPreviewServer({ projectRoot: options.project.root, port: 0 });
|
|
1449
|
+
serverTiming.finish({ url: server.url });
|
|
1029
1450
|
const infoCache = new Map();
|
|
1030
1451
|
const staticCache = new Map();
|
|
1031
1452
|
const diagnostics = [...resolved.diagnostics];
|
|
@@ -1042,6 +1463,7 @@ export async function renderTimeline(options) {
|
|
|
1042
1463
|
});
|
|
1043
1464
|
let nextSegment = 0;
|
|
1044
1465
|
const audio = buildAudioClips(timeline);
|
|
1466
|
+
const audioTiming = startTiming("timeline.audio_inspect", { clips: audio.clips.length });
|
|
1045
1467
|
const audioInspection = audio.clips.length
|
|
1046
1468
|
? await inspectTimelineAudio({
|
|
1047
1469
|
projectRoot: options.project.root,
|
|
@@ -1051,11 +1473,13 @@ export async function renderTimeline(options) {
|
|
|
1051
1473
|
...(options.signal ? { signal: options.signal } : {}),
|
|
1052
1474
|
})
|
|
1053
1475
|
: { clips: [], assets: [], warnings: [] };
|
|
1476
|
+
audioTiming.finish({ assets: audioInspection.assets.length });
|
|
1054
1477
|
diagnostics.push(...audioInspection.warnings.map((message) => ({
|
|
1055
1478
|
severity: "warning",
|
|
1056
1479
|
code: "AUDIO_WARNING",
|
|
1057
1480
|
message,
|
|
1058
1481
|
})));
|
|
1482
|
+
const transcriptTiming = startTiming("timeline.transcript_inspect", { clips: audio.transcripts.length });
|
|
1059
1483
|
const transcripts = audio.transcripts.length
|
|
1060
1484
|
? await inspectTranscriptClips({
|
|
1061
1485
|
projectRoot: options.project.root,
|
|
@@ -1063,6 +1487,7 @@ export async function renderTimeline(options) {
|
|
|
1063
1487
|
...(options.signal ? { signal: options.signal } : {}),
|
|
1064
1488
|
})
|
|
1065
1489
|
: { clips: [], assets: [], warnings: [] };
|
|
1490
|
+
transcriptTiming.finish({ assets: transcripts.assets.length });
|
|
1066
1491
|
diagnostics.push(...transcripts.warnings.map((message) => ({
|
|
1067
1492
|
severity: "warning",
|
|
1068
1493
|
code: "TRANSCRIPT_WARNING",
|
|
@@ -1088,6 +1513,8 @@ export async function renderTimeline(options) {
|
|
|
1088
1513
|
scratchRoot,
|
|
1089
1514
|
infoCache,
|
|
1090
1515
|
staticCache,
|
|
1516
|
+
captureLimiter,
|
|
1517
|
+
sessionPool,
|
|
1091
1518
|
codec,
|
|
1092
1519
|
container,
|
|
1093
1520
|
encoder,
|
|
@@ -1103,7 +1530,14 @@ export async function renderTimeline(options) {
|
|
|
1103
1530
|
});
|
|
1104
1531
|
}
|
|
1105
1532
|
};
|
|
1533
|
+
const segmentsTiming = startTiming("timeline.segments", {
|
|
1534
|
+
segments: plan.segments.length,
|
|
1535
|
+
workers,
|
|
1536
|
+
codec,
|
|
1537
|
+
encoder,
|
|
1538
|
+
});
|
|
1106
1539
|
await Promise.all(Array.from({ length: workers }, () => worker()));
|
|
1540
|
+
segmentsTiming.finish();
|
|
1107
1541
|
const segmentPaths = segmentResults.map((result) => {
|
|
1108
1542
|
if (!result) {
|
|
1109
1543
|
throw new FrameyardError("INTERNAL", "A timeline segment was not rendered.");
|
|
@@ -1111,6 +1545,7 @@ export async function renderTimeline(options) {
|
|
|
1111
1545
|
diagnostics.push(...result.diagnostics);
|
|
1112
1546
|
return result.path;
|
|
1113
1547
|
});
|
|
1548
|
+
const concatTiming = startTiming("timeline.concat", { segments: segmentPaths.length });
|
|
1114
1549
|
await concatTimelineSegments({
|
|
1115
1550
|
segmentPaths,
|
|
1116
1551
|
listPath: path.join(scratchRoot, "segments.txt"),
|
|
@@ -1119,9 +1554,11 @@ export async function renderTimeline(options) {
|
|
|
1119
1554
|
container,
|
|
1120
1555
|
...(options.signal ? { signal: options.signal } : {}),
|
|
1121
1556
|
});
|
|
1557
|
+
concatTiming.finish();
|
|
1122
1558
|
let finalPath = tempVideo;
|
|
1123
1559
|
if (audioInspection.assets.length > 0) {
|
|
1124
1560
|
const audioPath = path.join(scratchRoot, "audio.wav");
|
|
1561
|
+
const mixTiming = startTiming("timeline.audio_mix", { assets: audioInspection.assets.length });
|
|
1125
1562
|
await mixAudioTimeline({
|
|
1126
1563
|
clips: audioInspection.assets.map((asset) => ({
|
|
1127
1564
|
path: asset.path,
|
|
@@ -1129,12 +1566,15 @@ export async function renderTimeline(options) {
|
|
|
1129
1566
|
trimStart: asset.clip.trimStart,
|
|
1130
1567
|
...(asset.clip.trimDuration !== undefined ? { trimDuration: asset.clip.trimDuration } : {}),
|
|
1131
1568
|
volume: asset.clip.volume,
|
|
1569
|
+
...(asset.clip.envelopes !== undefined ? { volumeEnvelopes: asset.clip.envelopes } : {}),
|
|
1132
1570
|
})),
|
|
1133
1571
|
fps: timeline.format.fps,
|
|
1134
1572
|
durationSeconds: total / timeline.format.fps,
|
|
1135
1573
|
outputPath: audioPath,
|
|
1136
1574
|
...(options.signal ? { signal: options.signal } : {}),
|
|
1137
1575
|
});
|
|
1576
|
+
mixTiming.finish();
|
|
1577
|
+
const muxTiming = startTiming("timeline.mux", { codec, container });
|
|
1138
1578
|
await muxVideoWithAudio({
|
|
1139
1579
|
videoPath: tempVideo,
|
|
1140
1580
|
audioPath,
|
|
@@ -1143,13 +1583,24 @@ export async function renderTimeline(options) {
|
|
|
1143
1583
|
container,
|
|
1144
1584
|
...(options.signal ? { signal: options.signal } : {}),
|
|
1145
1585
|
});
|
|
1586
|
+
muxTiming.finish();
|
|
1146
1587
|
finalPath = tempFinal;
|
|
1147
1588
|
}
|
|
1148
1589
|
if (!overwrite && (await pathExists(options.outPath))) {
|
|
1149
1590
|
throw new FrameyardError("OUTPUT_EXISTS", `Output already exists: ${options.outPath}. Pass --overwrite to replace it.`);
|
|
1150
1591
|
}
|
|
1592
|
+
const publishTiming = startTiming("timeline.publish", { out: options.outPath });
|
|
1151
1593
|
await publishFile(finalPath, options.outPath, overwrite);
|
|
1594
|
+
publishTiming.finish();
|
|
1152
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
|
+
});
|
|
1153
1604
|
return {
|
|
1154
1605
|
timeline: options.timelineId,
|
|
1155
1606
|
entry: timeline.entry,
|
|
@@ -1162,6 +1613,7 @@ export async function renderTimeline(options) {
|
|
|
1162
1613
|
framesRendered: total,
|
|
1163
1614
|
segments: plan.segments.length,
|
|
1164
1615
|
workers,
|
|
1616
|
+
transitions: timeline.transitions.length,
|
|
1165
1617
|
codec,
|
|
1166
1618
|
container,
|
|
1167
1619
|
encoder,
|
|
@@ -1175,6 +1627,7 @@ export async function renderTimeline(options) {
|
|
|
1175
1627
|
finally {
|
|
1176
1628
|
await rm(tempVideo, { force: true });
|
|
1177
1629
|
await rm(tempFinal, { force: true });
|
|
1630
|
+
await sessionPool.close();
|
|
1178
1631
|
try {
|
|
1179
1632
|
await server.close();
|
|
1180
1633
|
}
|