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
package/dist/renderer/ffmpeg.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { writeFile } from "node:fs/promises";
|
|
3
3
|
import { FrameyardError } from "../errors.js";
|
|
4
|
+
import { evaluatePresentation } from "../timeline/keyframes.js";
|
|
5
|
+
import { transitionProgress } from "../timeline/transitions.js";
|
|
6
|
+
import { keyframeChannelExpression, transitionGainExpression, transitionProgressExpression, } from "./keyframe-expressions.js";
|
|
4
7
|
const STDERR_LIMIT = 16 * 1024;
|
|
5
8
|
function captureTail(child) {
|
|
6
9
|
let tail = "";
|
|
@@ -302,8 +305,7 @@ export async function probeMedia(filePath, options = {}) {
|
|
|
302
305
|
throw new FrameyardError("FFPROBE_FAILED", `Could not parse ffprobe output for ${filePath}.`, { cause: error });
|
|
303
306
|
}
|
|
304
307
|
}
|
|
305
|
-
|
|
306
|
-
export async function normalizeAudioForTranscription(options) {
|
|
308
|
+
async function normalizeAudio(options) {
|
|
307
309
|
await runCaptured("ffmpeg", [
|
|
308
310
|
"-hide_banner",
|
|
309
311
|
"-loglevel",
|
|
@@ -318,12 +320,20 @@ export async function normalizeAudioForTranscription(options) {
|
|
|
318
320
|
"-ac",
|
|
319
321
|
"1",
|
|
320
322
|
"-ar",
|
|
321
|
-
|
|
323
|
+
String(options.sampleRate),
|
|
322
324
|
"-c:a",
|
|
323
325
|
"pcm_s16le",
|
|
324
326
|
options.outputPath,
|
|
325
327
|
], { signal: options.signal });
|
|
326
328
|
}
|
|
329
|
+
/** Normalizes any decodable source to the 16 kHz mono PCM WAV that whisper.cpp expects. */
|
|
330
|
+
export async function normalizeAudioForTranscription(options) {
|
|
331
|
+
await normalizeAudio({ ...options, sampleRate: 16_000 });
|
|
332
|
+
}
|
|
333
|
+
/** Normalizes generated speech to the 24 kHz mono PCM WAV that Frameyard publishes. */
|
|
334
|
+
export async function normalizeSpeechAudio(options) {
|
|
335
|
+
await normalizeAudio({ ...options, sampleRate: 24_000 });
|
|
336
|
+
}
|
|
327
337
|
export async function mixAudioTimeline(options) {
|
|
328
338
|
if (options.clips.length === 0) {
|
|
329
339
|
throw new FrameyardError("AUDIO_INVALID", "Cannot mix an empty audio timeline.");
|
|
@@ -340,10 +350,22 @@ export async function mixAudioTimeline(options) {
|
|
|
340
350
|
trim.push(`duration=${formatNumber(clip.trimDuration)}`);
|
|
341
351
|
}
|
|
342
352
|
const delayMs = Math.round((clip.startFrame * 1000) / options.fps);
|
|
353
|
+
const gain = clip.volumeEnvelopes?.length
|
|
354
|
+
? clip.volumeEnvelopes
|
|
355
|
+
.map((envelope) => transitionGainExpression({
|
|
356
|
+
progressExpression: `((${formatNumber(clip.startFrame)}+t*${formatNumber(options.fps)}-${formatNumber(envelope.startFrame)})/${formatNumber(envelope.endFrame - envelope.startFrame)})`,
|
|
357
|
+
...(envelope.easing !== undefined ? { easing: envelope.easing } : {}),
|
|
358
|
+
role: envelope.role,
|
|
359
|
+
}))
|
|
360
|
+
.join("*")
|
|
361
|
+
: undefined;
|
|
362
|
+
const volume = gain === undefined
|
|
363
|
+
? `volume=${formatNumber(clip.volume)}`
|
|
364
|
+
: `volume='${formatNumber(clip.volume)}*(${gain})':eval=frame`;
|
|
343
365
|
const chain = [
|
|
344
366
|
`atrim=${trim.join(":")}`,
|
|
345
367
|
"asetpts=PTS-STARTPTS",
|
|
346
|
-
|
|
368
|
+
volume,
|
|
347
369
|
"aresample=48000",
|
|
348
370
|
"aformat=sample_fmts=fltp:channel_layouts=stereo",
|
|
349
371
|
"apad",
|
|
@@ -363,40 +385,14 @@ export async function mixAudioTimeline(options) {
|
|
|
363
385
|
args.push("-filter_complex", filters.join(";"), "-map", "[audio]", "-t", formatNumber(options.durationSeconds), "-ar", "48000", "-ac", "2", "-c:a", "pcm_s16le", options.outputPath);
|
|
364
386
|
await runCaptured("ffmpeg", args, { signal: options.signal });
|
|
365
387
|
}
|
|
366
|
-
|
|
388
|
+
/** Drives an FFmpeg `image2pipe` encoder from PNG buffers. */
|
|
389
|
+
async function startPipeEncoder(options) {
|
|
367
390
|
if (options.signal?.aborted) {
|
|
368
391
|
throw abortError();
|
|
369
392
|
}
|
|
370
|
-
const args = [
|
|
371
|
-
"-hide_banner",
|
|
372
|
-
"-loglevel",
|
|
373
|
-
"error",
|
|
374
|
-
"-nostdin",
|
|
375
|
-
"-y",
|
|
376
|
-
"-f",
|
|
377
|
-
"image2pipe",
|
|
378
|
-
"-framerate",
|
|
379
|
-
formatNumber(options.fps),
|
|
380
|
-
"-i",
|
|
381
|
-
"pipe:0",
|
|
382
|
-
];
|
|
383
|
-
if (options.audioPath) {
|
|
384
|
-
args.push("-i", options.audioPath);
|
|
385
|
-
}
|
|
386
|
-
args.push("-map", "0:v:0");
|
|
387
|
-
if (options.audioPath) {
|
|
388
|
-
args.push("-map", "1:a:0");
|
|
389
|
-
}
|
|
390
|
-
const codec = options.codec ?? "h264";
|
|
391
|
-
const container = options.container ?? "mp4";
|
|
392
|
-
args.push(...videoEncoderArgs(options.encoder ?? "libx264"), ...videoCodecTagArgs(codec, container), "-r", formatNumber(options.fps), "-frames:v", String(options.frames), "-t", formatNumber(options.durationSeconds));
|
|
393
|
-
if (options.audioPath) {
|
|
394
|
-
args.push(...audioEncoderArgs(codec));
|
|
395
|
-
}
|
|
396
|
-
args.push(...containerMuxFlags(container), ...containerMuxerArgs(container), options.outputPath);
|
|
397
393
|
let child;
|
|
398
394
|
try {
|
|
399
|
-
child = spawn("ffmpeg", args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
395
|
+
child = spawn("ffmpeg", options.args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
400
396
|
}
|
|
401
397
|
catch (error) {
|
|
402
398
|
throw new FrameyardError("FFMPEG_FAILED", "Could not start FFmpeg.", { cause: error });
|
|
@@ -492,6 +488,332 @@ export async function startVideoEncoder(options) {
|
|
|
492
488
|
abort,
|
|
493
489
|
};
|
|
494
490
|
}
|
|
491
|
+
export async function startVideoEncoder(options) {
|
|
492
|
+
if (options.signal?.aborted) {
|
|
493
|
+
throw abortError();
|
|
494
|
+
}
|
|
495
|
+
const args = [
|
|
496
|
+
"-hide_banner",
|
|
497
|
+
"-loglevel",
|
|
498
|
+
"error",
|
|
499
|
+
"-nostdin",
|
|
500
|
+
"-y",
|
|
501
|
+
"-f",
|
|
502
|
+
"image2pipe",
|
|
503
|
+
"-framerate",
|
|
504
|
+
formatNumber(options.fps),
|
|
505
|
+
"-i",
|
|
506
|
+
"pipe:0",
|
|
507
|
+
];
|
|
508
|
+
if (options.audioPath) {
|
|
509
|
+
args.push("-i", options.audioPath);
|
|
510
|
+
}
|
|
511
|
+
args.push("-map", "0:v:0");
|
|
512
|
+
if (options.audioPath) {
|
|
513
|
+
args.push("-map", "1:a:0");
|
|
514
|
+
}
|
|
515
|
+
const codec = options.codec ?? "h264";
|
|
516
|
+
const container = options.container ?? "mp4";
|
|
517
|
+
args.push(...videoEncoderArgs(options.encoder ?? "libx264"), ...videoCodecTagArgs(codec, container), "-r", formatNumber(options.fps), "-frames:v", String(options.frames), "-t", formatNumber(options.durationSeconds));
|
|
518
|
+
if (options.audioPath) {
|
|
519
|
+
args.push(...audioEncoderArgs(codec));
|
|
520
|
+
}
|
|
521
|
+
args.push(...containerMuxFlags(container), ...containerMuxerArgs(container), options.outputPath);
|
|
522
|
+
return startPipeEncoder({ args, frames: options.frames, ...(options.signal ? { signal: options.signal } : {}) });
|
|
523
|
+
}
|
|
524
|
+
function ffv1Threads(options) {
|
|
525
|
+
if (options.threads !== undefined) {
|
|
526
|
+
return options.threads;
|
|
527
|
+
}
|
|
528
|
+
const raw = process.env.FRAMEYARD_FFV1_THREADS;
|
|
529
|
+
if (raw === undefined) {
|
|
530
|
+
return 2;
|
|
531
|
+
}
|
|
532
|
+
const value = Number(raw);
|
|
533
|
+
return Number.isInteger(value) && value >= 0 ? value : 2;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Encodes PNG frames streamed over stdin into a lossless alpha-preserving FFV1
|
|
537
|
+
* file. The encoder starts before capture so encoding overlaps the next frame.
|
|
538
|
+
*/
|
|
539
|
+
export async function startFfv1Encoder(options) {
|
|
540
|
+
const threads = ffv1Threads(options);
|
|
541
|
+
const args = [
|
|
542
|
+
"-hide_banner",
|
|
543
|
+
"-loglevel",
|
|
544
|
+
"error",
|
|
545
|
+
"-nostdin",
|
|
546
|
+
"-y",
|
|
547
|
+
"-f",
|
|
548
|
+
"image2pipe",
|
|
549
|
+
"-framerate",
|
|
550
|
+
formatNumber(options.fps),
|
|
551
|
+
"-i",
|
|
552
|
+
"pipe:0",
|
|
553
|
+
"-c:v",
|
|
554
|
+
"ffv1",
|
|
555
|
+
"-level",
|
|
556
|
+
"3",
|
|
557
|
+
"-pix_fmt",
|
|
558
|
+
"bgra",
|
|
559
|
+
...(threads > 0 ? ["-threads", String(threads)] : []),
|
|
560
|
+
options.outputPath,
|
|
561
|
+
];
|
|
562
|
+
return startPipeEncoder({ args, frames: options.frames, ...(options.signal ? { signal: options.signal } : {}) });
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Encodes a full-frame composition capture directly into a delivery segment.
|
|
566
|
+
* The captured PNGs keep their alpha; a solid background is composited first so
|
|
567
|
+
* the H.264/HEVC/VP9 segment has no transparency.
|
|
568
|
+
*/
|
|
569
|
+
export async function startDirectSegmentEncoder(options) {
|
|
570
|
+
if (options.background === "transparent") {
|
|
571
|
+
throw new FrameyardError("INTERNAL", "A direct delivery segment requires an opaque background.");
|
|
572
|
+
}
|
|
573
|
+
const encoder = options.encoder ?? encoderNameFor(options.codec, "software");
|
|
574
|
+
const durationSeconds = options.frames / options.fps;
|
|
575
|
+
const args = [
|
|
576
|
+
"-hide_banner",
|
|
577
|
+
"-loglevel",
|
|
578
|
+
"error",
|
|
579
|
+
"-nostdin",
|
|
580
|
+
"-y",
|
|
581
|
+
"-f",
|
|
582
|
+
"image2pipe",
|
|
583
|
+
"-framerate",
|
|
584
|
+
formatNumber(options.fps),
|
|
585
|
+
"-i",
|
|
586
|
+
"pipe:0",
|
|
587
|
+
"-f",
|
|
588
|
+
"lavfi",
|
|
589
|
+
"-i",
|
|
590
|
+
`color=c=${ffmpegBackgroundColor(options.background)}:s=${options.width}x${options.height}:r=${formatNumber(options.fps)}:d=${formatNumber(durationSeconds)}`,
|
|
591
|
+
"-filter_complex",
|
|
592
|
+
"[1:v][0:v]overlay=0:0:format=auto,format=yuv420p[out]",
|
|
593
|
+
"-map",
|
|
594
|
+
"[out]",
|
|
595
|
+
...videoEncoderArgs(encoder),
|
|
596
|
+
...videoCodecTagArgs(options.codec, options.container),
|
|
597
|
+
"-r",
|
|
598
|
+
formatNumber(options.fps),
|
|
599
|
+
"-frames:v",
|
|
600
|
+
String(options.frames),
|
|
601
|
+
"-t",
|
|
602
|
+
formatNumber(durationSeconds),
|
|
603
|
+
"-an",
|
|
604
|
+
...containerMuxFlags(options.container),
|
|
605
|
+
...containerMuxerArgs(options.container),
|
|
606
|
+
options.outputPath,
|
|
607
|
+
];
|
|
608
|
+
return startPipeEncoder({ args, frames: options.frames, ...(options.signal ? { signal: options.signal } : {}) });
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Encodes two full-frame composition captures directly into one delivery
|
|
612
|
+
* segment. Layer 0 is the base, layer 1 is the overlay, and both keep alpha.
|
|
613
|
+
*/
|
|
614
|
+
export async function startDualPipeSegmentEncoder(options) {
|
|
615
|
+
if (options.background === "transparent") {
|
|
616
|
+
throw new FrameyardError("INTERNAL", "A dual-pipe delivery segment requires an opaque background.");
|
|
617
|
+
}
|
|
618
|
+
const encoder = options.encoder ?? encoderNameFor(options.codec, "software");
|
|
619
|
+
const durationSeconds = options.frames / options.fps;
|
|
620
|
+
const overlay = options.overlay;
|
|
621
|
+
const overlayFilters = [];
|
|
622
|
+
if (overlay) {
|
|
623
|
+
const fullFrame = overlay.width === undefined &&
|
|
624
|
+
overlay.height === undefined &&
|
|
625
|
+
overlay.sourceWidth === options.width &&
|
|
626
|
+
overlay.sourceHeight === options.height;
|
|
627
|
+
if (!fullFrame) {
|
|
628
|
+
overlayFilters.push(fitFilter(overlay, options.width, options.height));
|
|
629
|
+
}
|
|
630
|
+
overlayFilters.push("format=rgba");
|
|
631
|
+
}
|
|
632
|
+
const overlayGraph = overlay
|
|
633
|
+
? `[1:v]${overlayFilters.join(",")}[ov];[base][ov]overlay=${placementFilter(overlay, 0)}:format=auto,format=yuv420p[out]`
|
|
634
|
+
: "[base][1:v]overlay=0:0:format=auto,format=yuv420p[out]";
|
|
635
|
+
const args = [
|
|
636
|
+
"-hide_banner",
|
|
637
|
+
"-loglevel",
|
|
638
|
+
"error",
|
|
639
|
+
"-nostdin",
|
|
640
|
+
"-y",
|
|
641
|
+
"-f",
|
|
642
|
+
"image2pipe",
|
|
643
|
+
"-framerate",
|
|
644
|
+
formatNumber(options.fps),
|
|
645
|
+
"-i",
|
|
646
|
+
"pipe:0",
|
|
647
|
+
"-f",
|
|
648
|
+
"image2pipe",
|
|
649
|
+
"-framerate",
|
|
650
|
+
formatNumber(options.fps),
|
|
651
|
+
"-i",
|
|
652
|
+
"pipe:3",
|
|
653
|
+
"-f",
|
|
654
|
+
"lavfi",
|
|
655
|
+
"-i",
|
|
656
|
+
`color=c=${ffmpegBackgroundColor(options.background)}:s=${options.width}x${options.height}:r=${formatNumber(options.fps)}:d=${formatNumber(durationSeconds)}`,
|
|
657
|
+
"-filter_complex",
|
|
658
|
+
`[2:v][0:v]overlay=0:0:format=auto[base];${overlayGraph}`,
|
|
659
|
+
"-map",
|
|
660
|
+
"[out]",
|
|
661
|
+
...videoEncoderArgs(encoder),
|
|
662
|
+
...videoCodecTagArgs(options.codec, options.container),
|
|
663
|
+
"-r",
|
|
664
|
+
formatNumber(options.fps),
|
|
665
|
+
"-frames:v",
|
|
666
|
+
String(options.frames),
|
|
667
|
+
"-t",
|
|
668
|
+
formatNumber(durationSeconds),
|
|
669
|
+
"-an",
|
|
670
|
+
...containerMuxFlags(options.container),
|
|
671
|
+
...containerMuxerArgs(options.container),
|
|
672
|
+
options.outputPath,
|
|
673
|
+
];
|
|
674
|
+
let child;
|
|
675
|
+
try {
|
|
676
|
+
child = spawn("ffmpeg", args, { stdio: ["pipe", "ignore", "pipe", "pipe"] });
|
|
677
|
+
}
|
|
678
|
+
catch (error) {
|
|
679
|
+
throw new FrameyardError("FFMPEG_FAILED", "Could not start FFmpeg.", { cause: error });
|
|
680
|
+
}
|
|
681
|
+
const stderrTail = captureTail(child);
|
|
682
|
+
await new Promise((resolve, reject) => {
|
|
683
|
+
child.once("spawn", resolve);
|
|
684
|
+
child.once("error", (error) => {
|
|
685
|
+
if (error.code === "ENOENT") {
|
|
686
|
+
reject(new FrameyardError("FFMPEG_FAILED", "FFmpeg was not found on PATH. Install FFmpeg and ensure it is available.", { cause: error }));
|
|
687
|
+
}
|
|
688
|
+
else {
|
|
689
|
+
reject(new FrameyardError("FFMPEG_FAILED", `FFmpeg could not start: ${error.message}`, { cause: error }));
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
});
|
|
693
|
+
const input0 = child.stdio[0];
|
|
694
|
+
const input1 = child.stdio[3];
|
|
695
|
+
const closed = new Promise((resolve) => {
|
|
696
|
+
child.once("close", (code, signal) => resolve({ code, signal }));
|
|
697
|
+
});
|
|
698
|
+
let finished = false;
|
|
699
|
+
const framesWritten = [0, 0];
|
|
700
|
+
const onAbort = () => {
|
|
701
|
+
input0.destroy();
|
|
702
|
+
input1.destroy();
|
|
703
|
+
child.kill("SIGKILL");
|
|
704
|
+
};
|
|
705
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
706
|
+
async function abort() {
|
|
707
|
+
if (finished) {
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
finished = true;
|
|
711
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
712
|
+
input0.destroy();
|
|
713
|
+
input1.destroy();
|
|
714
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
715
|
+
child.kill("SIGKILL");
|
|
716
|
+
}
|
|
717
|
+
await closed.catch(() => undefined);
|
|
718
|
+
}
|
|
719
|
+
async function write(layer, png) {
|
|
720
|
+
if (finished) {
|
|
721
|
+
throw new FrameyardError("FFMPEG_FAILED", "Cannot write a frame after the encoder finished.");
|
|
722
|
+
}
|
|
723
|
+
if (options.signal?.aborted) {
|
|
724
|
+
throw abortError();
|
|
725
|
+
}
|
|
726
|
+
const stream = layer === 0 ? input0 : input1;
|
|
727
|
+
await new Promise((resolve, reject) => {
|
|
728
|
+
const onError = (error) => {
|
|
729
|
+
stream.off("error", onError);
|
|
730
|
+
if (options.signal?.aborted) {
|
|
731
|
+
reject(abortError());
|
|
732
|
+
}
|
|
733
|
+
else {
|
|
734
|
+
reject(new FrameyardError("FFMPEG_FAILED", `FFmpeg stopped while accepting layer ${layer} frames: ${error.message}`, {
|
|
735
|
+
cause: error,
|
|
736
|
+
}));
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
stream.once("error", onError);
|
|
740
|
+
stream.write(png, (error) => {
|
|
741
|
+
stream.off("error", onError);
|
|
742
|
+
if (error) {
|
|
743
|
+
reject(new FrameyardError("FFMPEG_FAILED", `Could not write a layer ${layer} frame to FFmpeg: ${error.message}`, {
|
|
744
|
+
cause: error,
|
|
745
|
+
}));
|
|
746
|
+
}
|
|
747
|
+
else {
|
|
748
|
+
framesWritten[layer] += 1;
|
|
749
|
+
resolve();
|
|
750
|
+
}
|
|
751
|
+
});
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
return {
|
|
755
|
+
writeFrame: write,
|
|
756
|
+
async finish() {
|
|
757
|
+
if (finished) {
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
finished = true;
|
|
761
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
762
|
+
input0.end();
|
|
763
|
+
input1.end();
|
|
764
|
+
const { code, signal } = await closed;
|
|
765
|
+
if (options.signal?.aborted) {
|
|
766
|
+
throw abortError();
|
|
767
|
+
}
|
|
768
|
+
if (code !== 0) {
|
|
769
|
+
const detail = stderrTail();
|
|
770
|
+
throw new FrameyardError("FFMPEG_FAILED", `FFmpeg encoding failed${signal ? ` (${signal})` : code === null ? "" : ` (exit ${code})`}${detail ? `:\n${detail}` : "."}`);
|
|
771
|
+
}
|
|
772
|
+
if (framesWritten[0] !== options.frames || framesWritten[1] !== options.frames) {
|
|
773
|
+
throw new FrameyardError("FFMPEG_FAILED", `FFmpeg encoded ${framesWritten[0]}/${framesWritten[1]} frames but ${options.frames} were required per layer.`);
|
|
774
|
+
}
|
|
775
|
+
},
|
|
776
|
+
abort,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Builds FFmpeg's narrow layer input from a projected scene layer. The mapper
|
|
781
|
+
* owns the scene-to-filter boundary; capture chooses the artifact and its input
|
|
782
|
+
* mode, while the scene owns presentation, keyframes, and transition roles.
|
|
783
|
+
*/
|
|
784
|
+
export function sceneLayerToCompositeLayer(layer, artifact) {
|
|
785
|
+
const transform = layer.transform;
|
|
786
|
+
const transition = layer.transition;
|
|
787
|
+
return {
|
|
788
|
+
input: artifact.input,
|
|
789
|
+
path: artifact.path,
|
|
790
|
+
sourceWidth: layer.sourceWidth,
|
|
791
|
+
sourceHeight: layer.sourceHeight,
|
|
792
|
+
...(transform.width !== undefined && transform.height !== undefined
|
|
793
|
+
? { width: transform.width, height: transform.height }
|
|
794
|
+
: {}),
|
|
795
|
+
fit: transform.fit,
|
|
796
|
+
x: transform.x,
|
|
797
|
+
y: transform.y,
|
|
798
|
+
scale: transform.scale,
|
|
799
|
+
rotation: transform.rotation,
|
|
800
|
+
opacity: transform.opacity,
|
|
801
|
+
blend: layer.blend,
|
|
802
|
+
...(artifact.sourceStartFrame !== undefined ? { sourceStartFrame: artifact.sourceStartFrame } : {}),
|
|
803
|
+
...(layer.keyframes !== undefined ? { keyframes: layer.keyframes } : {}),
|
|
804
|
+
clipStart: layer.clip.start,
|
|
805
|
+
...(transition !== undefined
|
|
806
|
+
? {
|
|
807
|
+
transition: {
|
|
808
|
+
role: transition.role,
|
|
809
|
+
start: transition.window.start,
|
|
810
|
+
end: transition.window.end,
|
|
811
|
+
...(transition.window.easing !== undefined ? { easing: transition.window.easing } : {}),
|
|
812
|
+
},
|
|
813
|
+
}
|
|
814
|
+
: {}),
|
|
815
|
+
};
|
|
816
|
+
}
|
|
495
817
|
function ffmpegBackgroundColor(value) {
|
|
496
818
|
if (value === "transparent") {
|
|
497
819
|
return "black@0.0";
|
|
@@ -533,25 +855,165 @@ function fitFilter(layer, stageWidth, stageHeight) {
|
|
|
533
855
|
const fittedHeight = Math.max(1, Math.round(sourceHeight * scale));
|
|
534
856
|
return `scale=${fittedWidth}:${fittedHeight},pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:color=black@0`;
|
|
535
857
|
}
|
|
536
|
-
function
|
|
537
|
-
|
|
858
|
+
function layerTransform(layer) {
|
|
859
|
+
return {
|
|
860
|
+
fit: layer.fit,
|
|
861
|
+
x: layer.x,
|
|
862
|
+
y: layer.y,
|
|
863
|
+
...(layer.width !== undefined && layer.height !== undefined ? { width: layer.width, height: layer.height } : {}),
|
|
864
|
+
scale: layer.scale,
|
|
865
|
+
rotation: layer.rotation,
|
|
866
|
+
opacity: layer.opacity,
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
function hasKeyframe(layer, channel) {
|
|
870
|
+
return layer.keyframes?.[channel] !== undefined;
|
|
871
|
+
}
|
|
872
|
+
/** The incoming layer's animated alpha at an absolute timeline frame. */
|
|
873
|
+
function transitionAlphaAt(layer, frame) {
|
|
874
|
+
if (layer.transition?.role !== "in") {
|
|
875
|
+
return 1;
|
|
876
|
+
}
|
|
877
|
+
return transitionProgress(layer.transition, frame);
|
|
878
|
+
}
|
|
879
|
+
/** False when a keyed or transitioning layer is invisible for every frame of the range. */
|
|
880
|
+
function isLayerVisibleInRange(layer, frameOffset, frames) {
|
|
881
|
+
const animated = hasKeyframe(layer, "opacity") || hasKeyframe(layer, "scale");
|
|
882
|
+
if (!animated && layer.transition?.role !== "in") {
|
|
883
|
+
return layer.opacity > 0 && layer.scale > 0;
|
|
884
|
+
}
|
|
885
|
+
const transform = layerTransform(layer);
|
|
886
|
+
const clipStart = layer.clipStart ?? 0;
|
|
887
|
+
for (let index = 0; index < frames; index += 1) {
|
|
888
|
+
const frame = frameOffset + index;
|
|
889
|
+
const values = evaluatePresentation(transform, layer.keyframes, frame - clipStart);
|
|
890
|
+
if (values.opacity * transitionAlphaAt(layer, frame) > 0 && values.scale > 0) {
|
|
891
|
+
return true;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return false;
|
|
895
|
+
}
|
|
896
|
+
function baseLayerFilters(layer, stageWidth, stageHeight, fps) {
|
|
897
|
+
const fullFrame = layer.width === undefined &&
|
|
898
|
+
layer.height === undefined &&
|
|
899
|
+
layer.sourceWidth === stageWidth &&
|
|
900
|
+
layer.sourceHeight === stageHeight;
|
|
901
|
+
return [
|
|
538
902
|
// Matroska and WebM timestamps can be quantized to milliseconds. Normalize
|
|
539
903
|
// every timed layer to the project frame rate before overlay framesync so
|
|
540
904
|
// it does not duplicate or drop frames. Still images are already static.
|
|
541
|
-
...(layer.
|
|
542
|
-
|
|
905
|
+
...(layer.input === "image" ? [] : [`fps=${formatNumber(fps)}`]),
|
|
906
|
+
// A source that already matches the stage has no fit work to do.
|
|
907
|
+
...(fullFrame ? [] : [fitFilter(layer, stageWidth, stageHeight)]),
|
|
543
908
|
"format=rgba",
|
|
544
909
|
];
|
|
545
|
-
|
|
546
|
-
|
|
910
|
+
}
|
|
911
|
+
/** Dimensions entering the rotate filter for this layer. */
|
|
912
|
+
function rotationInputSize(layer, stageWidth, stageHeight) {
|
|
913
|
+
const width = layer.width ?? stageWidth;
|
|
914
|
+
const height = layer.height ?? stageHeight;
|
|
915
|
+
if (layer.keyframes?.scale !== undefined) {
|
|
916
|
+
// Animated scale is applied after rotation, keeping the rotate input fixed.
|
|
917
|
+
return { width, height };
|
|
918
|
+
}
|
|
919
|
+
return {
|
|
920
|
+
width: Math.max(1, Math.ceil(width * layer.scale)),
|
|
921
|
+
height: Math.max(1, Math.ceil(height * layer.scale)),
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
function rotatedBounds(width, height, degrees) {
|
|
925
|
+
const radians = (degrees * Math.PI) / 180;
|
|
926
|
+
const cosine = Math.abs(Math.cos(radians));
|
|
927
|
+
const sine = Math.abs(Math.sin(radians));
|
|
928
|
+
// Two extra pixels absorb filter rounding and anti-aliased edge pixels.
|
|
929
|
+
return {
|
|
930
|
+
width: Math.max(1, Math.ceil(width * cosine + height * sine) + 2),
|
|
931
|
+
height: Math.max(1, Math.ceil(width * sine + height * cosine) + 2),
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* FFmpeg's rotate filter clips to a fixed output size. A keyed rotation uses a
|
|
936
|
+
* square diagonal bound so every segment uses the same size and corners stay
|
|
937
|
+
* visible at any angle. Static rotation uses its exact bounding box.
|
|
938
|
+
*/
|
|
939
|
+
function rotationOutputSize(layer, stageWidth, stageHeight) {
|
|
940
|
+
const input = rotationInputSize(layer, stageWidth, stageHeight);
|
|
941
|
+
if (layer.keyframes?.rotation) {
|
|
942
|
+
const diagonal = Math.max(1, Math.ceil(Math.hypot(input.width, input.height)) + 2);
|
|
943
|
+
return { width: diagonal, height: diagonal };
|
|
547
944
|
}
|
|
548
|
-
|
|
549
|
-
|
|
945
|
+
return rotatedBounds(input.width, input.height, layer.rotation);
|
|
946
|
+
}
|
|
947
|
+
function needsOpacityMask(layer) {
|
|
948
|
+
return hasKeyframe(layer, "opacity") || hasKeyframe(layer, "scale") || layer.transition?.role === "in";
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Presentation filters after the optional opacity mask. Animated scale is
|
|
952
|
+
* applied after rotation because uniform scale and rotation commute; rotation
|
|
953
|
+
* then keeps a fixed input size for the rotate filter.
|
|
954
|
+
*/
|
|
955
|
+
function presentationLayerFilters(layer, stageWidth, stageHeight, frameOffset) {
|
|
956
|
+
const filters = [];
|
|
957
|
+
const clipStart = layer.clipStart ?? 0;
|
|
958
|
+
const frameOptions = { frameVariable: "n", frameOffset, clipStart };
|
|
959
|
+
const scaleKeys = layer.keyframes?.scale;
|
|
960
|
+
const rotationKeys = layer.keyframes?.rotation;
|
|
961
|
+
const opacityKeys = layer.keyframes?.opacity;
|
|
962
|
+
const addRotation = () => {
|
|
963
|
+
if (rotationKeys || layer.rotation !== 0) {
|
|
964
|
+
const output = rotationOutputSize(layer, stageWidth, stageHeight);
|
|
965
|
+
const outputSize = `ow=${output.width}:oh=${output.height}`;
|
|
966
|
+
if (rotationKeys) {
|
|
967
|
+
const angle = keyframeChannelExpression("rotation", rotationKeys, frameOptions);
|
|
968
|
+
filters.push(`rotate=angle='(${angle})*PI/180':fillcolor=black@0:${outputSize}`);
|
|
969
|
+
}
|
|
970
|
+
else {
|
|
971
|
+
filters.push(`rotate=angle=${formatNumber(layer.rotation)}*PI/180:fillcolor=black@0:${outputSize}`);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
};
|
|
975
|
+
if (scaleKeys) {
|
|
976
|
+
addRotation();
|
|
977
|
+
const scale = keyframeChannelExpression("scale", scaleKeys, frameOptions);
|
|
978
|
+
filters.push(`scale=w='max(1,iw*(${scale}))':h='max(1,ih*(${scale}))':eval=frame`);
|
|
550
979
|
}
|
|
551
|
-
|
|
980
|
+
else {
|
|
981
|
+
if (layer.scale !== 1) {
|
|
982
|
+
filters.push(`scale=iw*${formatNumber(layer.scale)}:ih*${formatNumber(layer.scale)}`);
|
|
983
|
+
}
|
|
984
|
+
addRotation();
|
|
985
|
+
}
|
|
986
|
+
// A transitioning layer carries its opacity in the animated mask instead.
|
|
987
|
+
if (!opacityKeys && !scaleKeys && layer.transition?.role !== "in" && layer.opacity < 1) {
|
|
552
988
|
filters.push(`colorchannelmixer=aa=${formatNumber(layer.opacity)}`);
|
|
553
989
|
}
|
|
554
|
-
return filters
|
|
990
|
+
return filters;
|
|
991
|
+
}
|
|
992
|
+
/** Builds the animated alpha mask at the fitted box size. */
|
|
993
|
+
function opacityMaskFilter(options) {
|
|
994
|
+
const { layer, stageWidth, stageHeight, frameOffset, inputIndex, outputLabel } = options;
|
|
995
|
+
const clipStart = layer.clipStart ?? 0;
|
|
996
|
+
const frameOptions = { frameVariable: "N", frameOffset, clipStart };
|
|
997
|
+
const opacityKeys = layer.keyframes?.opacity;
|
|
998
|
+
const scaleKeys = layer.keyframes?.scale;
|
|
999
|
+
const opacity = opacityKeys
|
|
1000
|
+
? keyframeChannelExpression("opacity", opacityKeys, frameOptions)
|
|
1001
|
+
: formatNumber(layer.opacity);
|
|
1002
|
+
const scale = scaleKeys ? keyframeChannelExpression("scale", scaleKeys, frameOptions) : undefined;
|
|
1003
|
+
const visible = scale !== undefined ? `if(gt(${scale},0),1,0)` : "1";
|
|
1004
|
+
const transition = layer.transition?.role === "in"
|
|
1005
|
+
? transitionProgressExpression({
|
|
1006
|
+
frameVariable: "N",
|
|
1007
|
+
frameOffset,
|
|
1008
|
+
start: layer.transition.start,
|
|
1009
|
+
end: layer.transition.end,
|
|
1010
|
+
...(layer.transition.easing !== undefined ? { easing: layer.transition.easing } : {}),
|
|
1011
|
+
})
|
|
1012
|
+
: undefined;
|
|
1013
|
+
const alpha = `min(1,max(0,(${opacity})*(${visible})${transition !== undefined ? `*(${transition})` : ""}))`;
|
|
1014
|
+
const width = layer.width ?? stageWidth;
|
|
1015
|
+
const height = layer.height ?? stageHeight;
|
|
1016
|
+
return `[${inputIndex}:v]format=rgba,geq=r=0:g=0:b=0:a='255*${alpha}',scale=${width}:${height}:flags=neighbor,format=rgba[${outputLabel}]`;
|
|
555
1017
|
}
|
|
556
1018
|
/** W3C soft-light in 0-255 space; FFmpeg's native mode uses a different formula. */
|
|
557
1019
|
const SOFT_LIGHT_EXPRESSION = "if(lt(B,128), A-(1-2*B/255)*A*(1-A/255), A+(2*B/255-1)*(if(lt(A,64),((16*A/255-12)*A/255+4)*A/255,sqrt(A/255))*255-A))";
|
|
@@ -580,8 +1042,17 @@ function blendSpec(mode) {
|
|
|
580
1042
|
sourceFirst: mode === "color-dodge" || mode === "color-burn",
|
|
581
1043
|
};
|
|
582
1044
|
}
|
|
583
|
-
function placementFilter(layer) {
|
|
584
|
-
|
|
1045
|
+
function placementFilter(layer, frameOffset) {
|
|
1046
|
+
const clipStart = layer.clipStart ?? 0;
|
|
1047
|
+
const frameOptions = { frameVariable: "n", frameOffset, clipStart };
|
|
1048
|
+
const xKeys = layer.keyframes?.x;
|
|
1049
|
+
const yKeys = layer.keyframes?.y;
|
|
1050
|
+
const x = xKeys ? keyframeChannelExpression("x", xKeys, frameOptions) : formatNumber(layer.x);
|
|
1051
|
+
const y = yKeys ? keyframeChannelExpression("y", yKeys, frameOptions) : formatNumber(layer.y);
|
|
1052
|
+
if (!xKeys && !yKeys) {
|
|
1053
|
+
return `x=(main_w-overlay_w)/2+${x}:y=(main_h-overlay_h)/2+${y}`;
|
|
1054
|
+
}
|
|
1055
|
+
return `x='(main_w-overlay_w)/2+(${x})':y='(main_h-overlay_h)/2+(${y})':eval=frame`;
|
|
585
1056
|
}
|
|
586
1057
|
/**
|
|
587
1058
|
* Builds the FFmpeg filter graph for one timeline composite. Blend layers are
|
|
@@ -605,7 +1076,7 @@ function buildCompositeGraph(options) {
|
|
|
605
1076
|
let current = "base0";
|
|
606
1077
|
let inputIndex = 0;
|
|
607
1078
|
let includedIndex = 0;
|
|
608
|
-
const blendedLayers = options.layers.filter((layer) => layer.blend !== "normal" && layer.
|
|
1079
|
+
const blendedLayers = options.layers.filter((layer) => layer.blend !== "normal" && isLayerVisibleInRange(layer, options.frameOffset, options.frames));
|
|
609
1080
|
const canvasLabels = [];
|
|
610
1081
|
if (blendedLayers.length > 0) {
|
|
611
1082
|
args.push("-f", "lavfi", "-i", `color=c=black@0.0:s=${options.width}x${options.height}:r=${formatNumber(options.fps)}:d=${formatNumber(durationSeconds)}`);
|
|
@@ -620,10 +1091,19 @@ function buildCompositeGraph(options) {
|
|
|
620
1091
|
}
|
|
621
1092
|
let canvasIndex = 0;
|
|
622
1093
|
for (const layer of options.layers) {
|
|
623
|
-
if (layer.
|
|
1094
|
+
if (!isLayerVisibleInRange(layer, options.frameOffset, options.frames)) {
|
|
624
1095
|
continue;
|
|
625
1096
|
}
|
|
626
|
-
|
|
1097
|
+
const masked = needsOpacityMask(layer);
|
|
1098
|
+
let maskInput = 0;
|
|
1099
|
+
if (masked) {
|
|
1100
|
+
args.push("-f", "lavfi", "-i",
|
|
1101
|
+
// FFmpeg 6 rejects a 1x1 yuv420p source (chroma planes round to zero).
|
|
1102
|
+
`color=c=black@0.0:s=2x2:r=${formatNumber(options.fps)}:d=${formatNumber(durationSeconds)}`);
|
|
1103
|
+
inputIndex += 1;
|
|
1104
|
+
maskInput = inputIndex;
|
|
1105
|
+
}
|
|
1106
|
+
if (layer.input === "video") {
|
|
627
1107
|
const startSeconds = (layer.sourceStartFrame ?? 0) / options.fps;
|
|
628
1108
|
if (options.segment) {
|
|
629
1109
|
args.push("-ss", formatNumber(startSeconds), "-t", formatNumber(durationSeconds), "-i", layer.path, "-an");
|
|
@@ -632,7 +1112,7 @@ function buildCompositeGraph(options) {
|
|
|
632
1112
|
args.push("-ss", formatNumber(startSeconds), "-i", layer.path, "-an");
|
|
633
1113
|
}
|
|
634
1114
|
}
|
|
635
|
-
else if (layer.
|
|
1115
|
+
else if (layer.input === "image") {
|
|
636
1116
|
if (options.segment) {
|
|
637
1117
|
args.push("-loop", "1", "-framerate", formatNumber(options.fps), "-t", formatNumber(durationSeconds), "-i", layer.path, "-an");
|
|
638
1118
|
}
|
|
@@ -645,15 +1125,32 @@ function buildCompositeGraph(options) {
|
|
|
645
1125
|
}
|
|
646
1126
|
inputIndex += 1;
|
|
647
1127
|
includedIndex += 1;
|
|
648
|
-
|
|
1128
|
+
const layerInput = inputIndex;
|
|
1129
|
+
const base = baseLayerFilters(layer, options.width, options.height, options.fps);
|
|
1130
|
+
const presentation = presentationLayerFilters(layer, options.width, options.height, options.frameOffset);
|
|
1131
|
+
if (masked) {
|
|
1132
|
+
filters.push(`[${layerInput}:v]${base.join(",")}[v${includedIndex}]`);
|
|
1133
|
+
filters.push(opacityMaskFilter({
|
|
1134
|
+
layer,
|
|
1135
|
+
stageWidth: options.width,
|
|
1136
|
+
stageHeight: options.height,
|
|
1137
|
+
frameOffset: options.frameOffset,
|
|
1138
|
+
inputIndex: maskInput,
|
|
1139
|
+
outputLabel: `km${includedIndex}`,
|
|
1140
|
+
}));
|
|
1141
|
+
filters.push(`[v${includedIndex}][km${includedIndex}]blend=c0_mode=normal:c1_mode=normal:c2_mode=normal:c3_mode=multiply${presentation.length > 0 ? `,${presentation.join(",")}` : ""}[l${includedIndex}]`);
|
|
1142
|
+
}
|
|
1143
|
+
else {
|
|
1144
|
+
filters.push(`[${layerInput}:v]${[...base, ...presentation].join(",")}[l${includedIndex}]`);
|
|
1145
|
+
}
|
|
649
1146
|
if (layer.blend === "normal") {
|
|
650
|
-
filters.push(`[${current}][l${includedIndex}]overlay=${placementFilter(layer)}:format=auto[b${includedIndex}]`);
|
|
1147
|
+
filters.push(`[${current}][l${includedIndex}]overlay=${placementFilter(layer, options.frameOffset)}:format=auto[b${includedIndex}]`);
|
|
651
1148
|
current = `b${includedIndex}`;
|
|
652
1149
|
continue;
|
|
653
1150
|
}
|
|
654
1151
|
const canvas = canvasLabels[canvasIndex];
|
|
655
1152
|
canvasIndex += 1;
|
|
656
|
-
filters.push(`[${canvas}][l${includedIndex}]overlay=${placementFilter(layer)}:format=auto,format=rgba,split=2[pb${includedIndex}][pm${includedIndex}]`);
|
|
1153
|
+
filters.push(`[${canvas}][l${includedIndex}]overlay=${placementFilter(layer, options.frameOffset)}:format=auto,format=rgba,split=2[pb${includedIndex}][pm${includedIndex}]`);
|
|
657
1154
|
filters.push(`[${current}]format=rgba,split=3[bc${includedIndex}][bm${includedIndex}][bo${includedIndex}]`);
|
|
658
1155
|
filters.push(`[bm${includedIndex}]alphaextract,format=rgba[mask${includedIndex}]`);
|
|
659
1156
|
const spec = blendSpec(layer.blend);
|
|
@@ -666,24 +1163,38 @@ function buildCompositeGraph(options) {
|
|
|
666
1163
|
}
|
|
667
1164
|
return { args, filters, output: current };
|
|
668
1165
|
}
|
|
669
|
-
/**
|
|
670
|
-
export async function
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
1166
|
+
/** Re-encodes one full-frame video range directly into a delivery segment. */
|
|
1167
|
+
export async function encodeVideoSegmentDirect(options) {
|
|
1168
|
+
const encoder = options.encoder ?? encoderNameFor(options.codec, "software");
|
|
1169
|
+
const startSeconds = options.sourceStartFrame / options.fps;
|
|
1170
|
+
const durationSeconds = options.frames / options.fps;
|
|
1171
|
+
const args = [
|
|
1172
|
+
"-hide_banner",
|
|
1173
|
+
"-loglevel",
|
|
674
1174
|
"error",
|
|
675
|
-
"-
|
|
676
|
-
|
|
1175
|
+
"-nostdin",
|
|
1176
|
+
"-y",
|
|
1177
|
+
...(startSeconds > 0 ? ["-ss", formatNumber(startSeconds)] : []),
|
|
677
1178
|
"-i",
|
|
678
|
-
options.
|
|
679
|
-
"-
|
|
680
|
-
"
|
|
681
|
-
"-
|
|
682
|
-
"
|
|
683
|
-
|
|
684
|
-
|
|
1179
|
+
options.inputPath,
|
|
1180
|
+
"-map",
|
|
1181
|
+
"0:v:0",
|
|
1182
|
+
"-an",
|
|
1183
|
+
"-vf",
|
|
1184
|
+
`fps=${formatNumber(options.fps)},format=rgba,format=yuv420p`,
|
|
1185
|
+
...videoEncoderArgs(encoder),
|
|
1186
|
+
...videoCodecTagArgs(options.codec, options.container),
|
|
1187
|
+
"-r",
|
|
1188
|
+
formatNumber(options.fps),
|
|
1189
|
+
"-frames:v",
|
|
1190
|
+
String(options.frames),
|
|
1191
|
+
"-t",
|
|
1192
|
+
formatNumber(durationSeconds),
|
|
1193
|
+
...containerMuxFlags(options.container),
|
|
1194
|
+
...containerMuxerArgs(options.container),
|
|
685
1195
|
options.outputPath,
|
|
686
|
-
]
|
|
1196
|
+
];
|
|
1197
|
+
await runCaptured("ffmpeg", args, { signal: options.signal });
|
|
687
1198
|
}
|
|
688
1199
|
/** Composites one timeline segment into H.264 or an alpha-preserving FFV1 file. */
|
|
689
1200
|
export async function compositeTimelineSegment(options) {
|
|
@@ -692,6 +1203,7 @@ export async function compositeTimelineSegment(options) {
|
|
|
692
1203
|
height: options.height,
|
|
693
1204
|
fps: options.fps,
|
|
694
1205
|
frames: options.frames,
|
|
1206
|
+
frameOffset: options.frameOffset ?? 0,
|
|
695
1207
|
background: options.background,
|
|
696
1208
|
layers: options.layers,
|
|
697
1209
|
segment: true,
|
|
@@ -766,6 +1278,7 @@ export async function compositeTimelineFrame(options) {
|
|
|
766
1278
|
height: options.height,
|
|
767
1279
|
fps: options.fps,
|
|
768
1280
|
frames: 1,
|
|
1281
|
+
frameOffset: options.frameOffset ?? 0,
|
|
769
1282
|
background: options.background,
|
|
770
1283
|
layers: options.layers,
|
|
771
1284
|
segment: false,
|