@prestyj/cli 5.28.1 → 5.29.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/assets/motion/bin/contact-sheet.mjs +9 -3
- package/assets/motion/bin/cues.mjs +337 -0
- package/assets/motion/bin/library.mjs +53 -10
- package/assets/motion/bin/motion-blur.mjs +943 -0
- package/assets/motion/bin/motion-check.mjs +354 -3
- package/assets/motion/bin/music-fit.mjs +436 -0
- package/assets/motion/bin/pdf-extract.mjs +22 -10
- package/assets/motion/bin/reference-study.mjs +346 -0
- package/assets/motion/bin/score-synth.mjs +1052 -93
- package/assets/motion/library/README.md +50 -14
- package/assets/motion/library/kit/moves.js +1981 -0
- package/assets/motion/library/library.json +232 -0
- package/assets/motion/library/pieces/camera-rig/meta.json +13 -0
- package/assets/motion/library/pieces/camera-rig/piece.html +153 -0
- package/assets/motion/library/pieces/camera-rig/preview.jpg +0 -0
- package/assets/motion/library/pieces/chain-knock/meta.json +13 -0
- package/assets/motion/library/pieces/chain-knock/piece.html +195 -0
- package/assets/motion/library/pieces/chain-knock/preview.jpg +0 -0
- package/assets/motion/library/pieces/gather-to-logo/meta.json +13 -0
- package/assets/motion/library/pieces/gather-to-logo/piece.html +159 -0
- package/assets/motion/library/pieces/gather-to-logo/preview.jpg +0 -0
- package/assets/motion/library/pieces/morph-carry/meta.json +13 -0
- package/assets/motion/library/pieces/morph-carry/piece.html +173 -0
- package/assets/motion/library/pieces/morph-carry/preview.jpg +0 -0
- package/assets/motion/library/pieces/one-shape-journey/meta.json +13 -0
- package/assets/motion/library/pieces/one-shape-journey/piece.html +195 -0
- package/assets/motion/library/pieces/one-shape-journey/preview.jpg +0 -0
- package/assets/motion/library/pieces/open-from-subject/meta.json +13 -0
- package/assets/motion/library/pieces/open-from-subject/piece.html +168 -0
- package/assets/motion/library/pieces/open-from-subject/preview.jpg +0 -0
- package/assets/motion/library/pieces/request-to-result/meta.json +13 -0
- package/assets/motion/library/pieces/request-to-result/piece.html +212 -0
- package/assets/motion/library/pieces/request-to-result/preview.jpg +0 -0
- package/assets/motion/library/pieces/scale-dive/meta.json +13 -0
- package/assets/motion/library/pieces/scale-dive/piece.html +321 -0
- package/assets/motion/library/pieces/scale-dive/preview.jpg +0 -0
- package/assets/motion/library/pieces/screen-replica-steps/meta.json +13 -0
- package/assets/motion/library/pieces/screen-replica-steps/piece.html +366 -0
- package/assets/motion/library/pieces/screen-replica-steps/preview.jpg +0 -0
- package/assets/motion/library/pieces/zoom-into-card/meta.json +13 -0
- package/assets/motion/library/pieces/zoom-into-card/piece.html +179 -0
- package/assets/motion/library/pieces/zoom-into-card/preview.jpg +0 -0
- package/assets/motion/library/sheets/diagram.jpg +0 -0
- package/assets/motion/library/sheets/frame.jpg +0 -0
- package/assets/motion/library/sheets/transition.jpg +0 -0
- package/assets/motion/library/sheets/ui.jpg +0 -0
- package/assets/motion/references/build-sheet.md +206 -0
- package/assets/motion/references/runtime/determinism-rules.md +1 -1
- package/assets/motion/references/runtime/gsap-easing-and-stagger.md +29 -29
- package/assets/motion/references/runtime/inputs-and-assets.md +7 -12
- package/assets/motion/references/runtime/lint-validate-inspect.md +3 -3
- package/assets/motion/references/runtime/minimal-composition.md +1 -1
- package/assets/motion/references/runtime/preview-render.md +3 -3
- package/assets/motion/skills/app-walkthrough/SKILL.md +66 -0
- package/assets/motion/skills/before-after/SKILL.md +53 -0
- package/assets/motion/skills/brand-kit/SKILL.md +3 -3
- package/assets/motion/skills/dev-tool-video/SKILL.md +57 -0
- package/assets/motion/skills/launch-video/SKILL.md +62 -0
- package/assets/motion/skills/match-reference/SKILL.md +58 -0
- package/assets/motion/skills/motion/SKILL.md +78 -85
- package/assets/motion/skills/source-ingest/SKILL.md +17 -6
- package/assets/motion/skills/website-video/SKILL.md +59 -0
- package/assets/skills/bulletproof/SKILL.md +36 -11
- package/assets/skills/bulletproof/references/agent-surface.md +19 -9
- package/assets/skills/bulletproof/references/audit-protocol.md +20 -5
- package/assets/skills/bulletproof/references/platform-playbooks.md +5 -4
- package/assets/skills/bulletproof/references/provenance.md +26 -1
- package/assets/skills/bulletproof/references/secure-defaults.md +6 -5
- package/assets/skills/bulletproof/references/supply-chain.md +21 -17
- package/assets/skills/bulletproof/references/threat-landscape.md +28 -26
- package/assets/skills/bulletproof/references/verification.md +2 -0
- package/assets/skills/clarify/SKILL.md +25 -16
- package/assets/skills/code-review/SKILL.md +71 -13
- package/assets/skills/code-review/references/agent-diffs.md +27 -0
- package/assets/skills/code-review/references/tests.md +19 -0
- package/assets/skills/compliance-guard/SKILL.md +20 -5
- package/assets/skills/compliance-guard/references/artifacts.md +1 -1
- package/assets/skills/compliance-guard/references/eu-uk.md +16 -16
- package/assets/skills/compliance-guard/references/lawsuit-vectors.md +5 -5
- package/assets/skills/compliance-guard/references/provenance.md +41 -2
- package/assets/skills/compliance-guard/references/sector-gates.md +3 -3
- package/assets/skills/compliance-guard/references/security-baseline.md +2 -2
- package/assets/skills/compliance-guard/references/trigger-map.md +5 -5
- package/assets/skills/compliance-guard/references/us.md +27 -21
- package/assets/skills/durable/SKILL.md +87 -79
- package/assets/skills/durable/references/agent-db-safety.md +69 -0
- package/assets/skills/durable/references/backups-and-runtime.md +19 -12
- package/assets/skills/durable/references/migrations-and-schema.md +13 -6
- package/assets/skills/evidence-led-ui/SKILL.md +69 -127
- package/assets/skills/evidence-led-ui/references/anti-defaults.md +107 -208
- package/assets/skills/evidence-led-ui/references/direction.md +124 -0
- package/assets/skills/evidence-led-ui/references/production-contract.md +8 -0
- package/assets/skills/evidence-led-ui/references/provenance.md +24 -1
- package/assets/skills/lean/SKILL.md +90 -71
- package/assets/skills/lean/references/memory-and-processes.md +3 -2
- package/assets/skills/lean/references/playbooks.md +37 -12
- package/assets/skills/refactoring/SKILL.md +24 -3
- package/assets/skills/refactoring/references/agent-pitfalls.md +4 -1
- package/assets/skills/refactoring/references/legacy.md +21 -0
- package/assets/skills/root-cause/SKILL.md +20 -10
- package/assets/skills/shared-language/SKILL.md +16 -14
- package/assets/skills/tdd/SKILL.md +27 -15
- package/dist/app-sidecar.js +203 -47
- package/dist/app-sidecar.js.map +1 -1
- package/dist/cli.js +17 -26
- package/dist/cli.js.map +1 -1
- package/dist/core/acceptance-checks.d.ts +48 -0
- package/dist/core/acceptance-checks.js +144 -0
- package/dist/core/acceptance-checks.js.map +1 -0
- package/dist/core/agent-session.d.ts +106 -72
- package/dist/core/agent-session.js +539 -399
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/agents.d.ts +6 -5
- package/dist/core/agents.js.map +1 -1
- package/dist/core/ask-user.d.ts +90 -8
- package/dist/core/ask-user.js +124 -13
- package/dist/core/ask-user.js.map +1 -1
- package/dist/core/bundled-agents.js +1 -3
- package/dist/core/bundled-agents.js.map +1 -1
- package/dist/core/cache-diagnostics.d.ts +68 -0
- package/dist/core/cache-diagnostics.js +196 -0
- package/dist/core/cache-diagnostics.js.map +1 -0
- package/dist/core/cache-expiry.d.ts +87 -0
- package/dist/core/cache-expiry.js +111 -0
- package/dist/core/cache-expiry.js.map +1 -0
- package/dist/core/compaction/compactor.js +78 -48
- package/dist/core/compaction/compactor.js.map +1 -1
- package/dist/core/compaction/plan-step-policy.d.ts +46 -0
- package/dist/core/compaction/plan-step-policy.js +57 -0
- package/dist/core/compaction/plan-step-policy.js.map +1 -0
- package/dist/core/destructive-git-guard.d.ts +90 -0
- package/dist/core/destructive-git-guard.js +871 -0
- package/dist/core/destructive-git-guard.js.map +1 -0
- package/dist/core/event-bus.d.ts +3 -0
- package/dist/core/event-bus.js +5 -0
- package/dist/core/event-bus.js.map +1 -1
- package/dist/core/injection-detect.d.ts +38 -0
- package/dist/core/injection-detect.js +232 -0
- package/dist/core/injection-detect.js.map +1 -0
- package/dist/core/keep-awake.d.ts +88 -0
- package/dist/core/keep-awake.js +251 -0
- package/dist/core/keep-awake.js.map +1 -0
- package/dist/core/mcp/client.d.ts +72 -0
- package/dist/core/mcp/client.js +264 -41
- package/dist/core/mcp/client.js.map +1 -1
- package/dist/core/mcp/content.js +6 -2
- package/dist/core/mcp/content.js.map +1 -1
- package/dist/core/mcp/store.d.ts +6 -1
- package/dist/core/mcp/store.js +12 -1
- package/dist/core/mcp/store.js.map +1 -1
- package/dist/core/mcp/types.d.ts +18 -0
- package/dist/core/model-unavailable.d.ts +14 -0
- package/dist/core/model-unavailable.js +23 -0
- package/dist/core/model-unavailable.js.map +1 -0
- package/dist/core/node-debugger.d.ts +148 -0
- package/dist/core/node-debugger.js +642 -0
- package/dist/core/node-debugger.js.map +1 -0
- package/dist/core/package-threats.d.ts +18 -0
- package/dist/core/package-threats.js +168 -0
- package/dist/core/package-threats.js.map +1 -0
- package/dist/core/persistent-shell.d.ts +58 -6
- package/dist/core/persistent-shell.js +331 -49
- package/dist/core/persistent-shell.js.map +1 -1
- package/dist/core/process-manager.d.ts +14 -0
- package/dist/core/process-manager.js +61 -0
- package/dist/core/process-manager.js.map +1 -1
- package/dist/core/progress/git-xp.js +8 -14
- package/dist/core/progress/git-xp.js.map +1 -1
- package/dist/core/session-history.d.ts +12 -0
- package/dist/core/session-history.js +27 -0
- package/dist/core/session-history.js.map +1 -1
- package/dist/core/session-manager.d.ts +13 -1
- package/dist/core/session-manager.js +38 -18
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/session-summary-index.d.ts +37 -0
- package/dist/core/session-summary-index.js +172 -0
- package/dist/core/session-summary-index.js.map +1 -0
- package/dist/core/settings-manager.d.ts +2 -0
- package/dist/core/settings-manager.js +10 -0
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/shell-threats-popular-packages.d.ts +11 -0
- package/dist/core/shell-threats-popular-packages.js +675 -0
- package/dist/core/shell-threats-popular-packages.js.map +1 -0
- package/dist/core/shell-threats.d.ts +8 -0
- package/dist/core/shell-threats.js +186 -0
- package/dist/core/shell-threats.js.map +1 -0
- package/dist/core/skills.js +3 -1
- package/dist/core/skills.js.map +1 -1
- package/dist/core/stream-rules.d.ts +30 -0
- package/dist/core/stream-rules.js +151 -0
- package/dist/core/stream-rules.js.map +1 -0
- package/dist/core/subagent-manager.d.ts +20 -5
- package/dist/core/subagent-manager.js +22 -8
- package/dist/core/subagent-manager.js.map +1 -1
- package/dist/core/subagent-receipt.d.ts +54 -0
- package/dist/core/subagent-receipt.js +276 -0
- package/dist/core/subagent-receipt.js.map +1 -0
- package/dist/core/subagent-turn-record.d.ts +2 -0
- package/dist/core/subagent-turn-record.js.map +1 -1
- package/dist/core/test-impact.d.ts +73 -0
- package/dist/core/test-impact.js +467 -0
- package/dist/core/test-impact.js.map +1 -0
- package/dist/core/thinking-level.d.ts +1 -1
- package/dist/core/thinking-level.js +1 -1
- package/dist/core/thinking-level.js.map +1 -1
- package/dist/core/verification-gate.d.ts +2 -0
- package/dist/core/verification-gate.js +4 -0
- package/dist/core/verification-gate.js.map +1 -1
- package/dist/core/verification-snapshot.js +3 -5
- package/dist/core/verification-snapshot.js.map +1 -1
- package/dist/core/workspace-guard.d.ts +18 -7
- package/dist/core/workspace-guard.js +227 -60
- package/dist/core/workspace-guard.js.map +1 -1
- package/dist/interactive.js +2 -1
- package/dist/interactive.js.map +1 -1
- package/dist/modes/subagent-worker-mode.js +34 -6
- package/dist/modes/subagent-worker-mode.js.map +1 -1
- package/dist/motion-agent/motion-agent.d.ts +6 -2
- package/dist/motion-agent/motion-agent.js +5 -7
- package/dist/motion-agent/motion-agent.js.map +1 -1
- package/dist/motion-agent/motion-prompt.d.ts +1 -1
- package/dist/motion-agent/motion-prompt.js +14 -19
- package/dist/motion-agent/motion-prompt.js.map +1 -1
- package/dist/motion-agent/motion-review.d.ts +10 -3
- package/dist/motion-agent/motion-review.js +15 -7
- package/dist/motion-agent/motion-review.js.map +1 -1
- package/dist/motion-agent/motion-studio-context.js +1 -1
- package/dist/motion-agent/motion-studio-context.js.map +1 -1
- package/dist/system-prompt.js +3 -1
- package/dist/system-prompt.js.map +1 -1
- package/dist/test-support/keep-alive.d.ts +14 -0
- package/dist/test-support/keep-alive.js +17 -0
- package/dist/test-support/keep-alive.js.map +1 -0
- package/dist/tools/ask-user.js +3 -3
- package/dist/tools/ask-user.js.map +1 -1
- package/dist/tools/bash-read-evidence.d.ts +10 -0
- package/dist/tools/bash-read-evidence.js +133 -0
- package/dist/tools/bash-read-evidence.js.map +1 -0
- package/dist/tools/bash.d.ts +10 -1
- package/dist/tools/bash.js +115 -7
- package/dist/tools/bash.js.map +1 -1
- package/dist/tools/debug.d.ts +54 -0
- package/dist/tools/debug.js +233 -0
- package/dist/tools/debug.js.map +1 -0
- package/dist/tools/edit.js +12 -4
- package/dist/tools/edit.js.map +1 -1
- package/dist/tools/goals.d.ts +1 -1
- package/dist/tools/index.d.ts +19 -2
- package/dist/tools/index.js +47 -7
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/prompt-hints.js +2 -0
- package/dist/tools/prompt-hints.js.map +1 -1
- package/dist/tools/read-tracker.d.ts +5 -0
- package/dist/tools/read-tracker.js +19 -9
- package/dist/tools/read-tracker.js.map +1 -1
- package/dist/tools/read.js +3 -2
- package/dist/tools/read.js.map +1 -1
- package/dist/tools/skill.js +5 -0
- package/dist/tools/skill.js.map +1 -1
- package/dist/tools/subagent-control.js +44 -8
- package/dist/tools/subagent-control.js.map +1 -1
- package/dist/tools/subagent-shared.d.ts +29 -8
- package/dist/tools/subagent-shared.js +45 -14
- package/dist/tools/subagent-shared.js.map +1 -1
- package/dist/tools/subagent.d.ts +8 -2
- package/dist/tools/subagent.js +28 -10
- package/dist/tools/subagent.js.map +1 -1
- package/dist/tools/task-output.js +3 -2
- package/dist/tools/task-output.js.map +1 -1
- package/dist/tools/task-send.d.ts +1 -1
- package/dist/tools/task-send.js +15 -1
- package/dist/tools/task-send.js.map +1 -1
- package/dist/tools/tool-tiers.d.ts +2 -2
- package/dist/tools/tool-tiers.js +3 -2
- package/dist/tools/tool-tiers.js.map +1 -1
- package/dist/tools/truncate.d.ts +21 -0
- package/dist/tools/truncate.js +187 -0
- package/dist/tools/truncate.js.map +1 -1
- package/dist/tools/ui-adopt.js +2 -0
- package/dist/tools/ui-adopt.js.map +1 -1
- package/dist/ui/App.d.ts +0 -4
- package/dist/ui/App.js +5 -28
- package/dist/ui/App.js.map +1 -1
- package/dist/ui/components/ActivityIndicator.js +1 -0
- package/dist/ui/components/ActivityIndicator.js.map +1 -1
- package/dist/ui/hooks/useAgentLoop.d.ts +1 -8
- package/dist/ui/hooks/useAgentLoop.js +1 -119
- package/dist/ui/hooks/useAgentLoop.js.map +1 -1
- package/dist/ui/render.d.ts +0 -4
- package/dist/ui/render.js +0 -2
- package/dist/ui/render.js.map +1 -1
- package/dist/utils/git.d.ts +77 -0
- package/dist/utils/git.js +285 -21
- package/dist/utils/git.js.map +1 -1
- package/dist/utils/github-ci.js +2 -1
- package/dist/utils/github-ci.js.map +1 -1
- package/dist/utils/github.js +11 -9
- package/dist/utils/github.js.map +1 -1
- package/dist/utils/image.d.ts +14 -0
- package/dist/utils/image.js +16 -0
- package/dist/utils/image.js.map +1 -1
- package/dist/utils/process.d.ts +20 -0
- package/dist/utils/process.js +98 -0
- package/dist/utils/process.js.map +1 -1
- package/package.json +5 -5
- package/assets/motion/references/motion-language.md +0 -128
- package/assets/motion/skills/video-qa/SKILL.md +0 -89
- package/dist/core/ideal-review-subagent.d.ts +0 -56
- package/dist/core/ideal-review-subagent.js +0 -112
- package/dist/core/ideal-review-subagent.js.map +0 -1
- package/dist/core/ideal-review.d.ts +0 -82
- package/dist/core/ideal-review.js +0 -242
- package/dist/core/ideal-review.js.map +0 -1
- package/dist/motion-agent/motion-check-tool.d.ts +0 -35
- package/dist/motion-agent/motion-check-tool.js +0 -514
- package/dist/motion-agent/motion-check-tool.js.map +0 -1
|
@@ -0,0 +1,943 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// EZ Motion's motion-blur render step. Renders the composition several times per output
|
|
3
|
+
// frame with the bundled HyperFrames CLI, then averages each frame's sub-frame samples in
|
|
4
|
+
// linear light (never across a hard cut) and encodes with the bundled FFmpeg. A still frame
|
|
5
|
+
// averages to itself, so only moving frames blur. `--draft` measures a plain render's
|
|
6
|
+
// fastest move and renders more sub-frames when it is too fast for the default count, and
|
|
7
|
+
// finds its still spans: those render once at the output rate (`--frames` range renders)
|
|
8
|
+
// instead of at the sub-frame rate, and their frames are copied, not blended; the audio then
|
|
9
|
+
// comes from the draft, a render of the same composition. `--no-skip-still` renders
|
|
10
|
+
// everything at the sub-frame rate as without a draft; a composition HyperFrames cannot
|
|
11
|
+
// range-render (HDR, shader transitions) falls back to that automatically.
|
|
12
|
+
//
|
|
13
|
+
// Usage: node motion-blur.mjs <project-dir> <output.mp4> [--fps <n>] [--shutter <degrees>]
|
|
14
|
+
// [--quality draft|looks|standard|delivery|high] [--composition <file>]
|
|
15
|
+
// [--resolution <preset>] [--workers <n|auto>] [--samples <n> | --draft <video>]
|
|
16
|
+
// [--no-skip-still]
|
|
17
|
+
// Prints one JSON line: { ok, output, fps, samples, renderFps, shutter, elapsedMs, ... },
|
|
18
|
+
// with `spans`, `renders` and `audio` when still spans were skipped and `note` when not.
|
|
19
|
+
//
|
|
20
|
+
// The output is stamped (comment tag "ez-motion-blur samples=<n>") so the flow measure
|
|
21
|
+
// knows it was made with this step. It never overwrites an existing file.
|
|
22
|
+
//
|
|
23
|
+
// The finished file is then screened for harmful flashing (flash-check.mjs, under a second):
|
|
24
|
+
// the line carries `flashing`, and when it fails, `ok` is false, the exit code is 1 and the
|
|
25
|
+
// file is kept so the next version can be compared.
|
|
26
|
+
import { execFile, spawn } from "node:child_process";
|
|
27
|
+
import { constants } from "node:fs";
|
|
28
|
+
import { copyFile, mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
|
29
|
+
import { availableParallelism, tmpdir } from "node:os";
|
|
30
|
+
import { dirname, extname, join, resolve } from "node:path";
|
|
31
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
32
|
+
import { promisify } from "node:util";
|
|
33
|
+
import { analyzeFlashes } from "./flash-check.mjs";
|
|
34
|
+
import { mediaBinaries } from "./media-binaries.mjs";
|
|
35
|
+
|
|
36
|
+
const run = promisify(execFile);
|
|
37
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
|
|
39
|
+
/** HyperFrames renders at most this many frames per second; it bounds the sub-frame samples. */
|
|
40
|
+
export const MAX_RENDER_FPS = 240;
|
|
41
|
+
/**
|
|
42
|
+
* Sub-frames rendered per output frame when nothing calls for more. 4 at 30 fps renders
|
|
43
|
+
* 120 fps, about 1.5× a plain render, and is smooth for EZ's usual moves.
|
|
44
|
+
*/
|
|
45
|
+
export const DEFAULT_SAMPLES = 4;
|
|
46
|
+
/**
|
|
47
|
+
* Largest gap, in pixels, between neighbouring copies of the fastest move inside one blurred
|
|
48
|
+
* frame. Rendered edges are anti-aliased over 1-2 px and the H.264 encode softens another
|
|
49
|
+
* couple, so copies up to about 8 px apart still read as one continuous streak; wider
|
|
50
|
+
* spacing shows as separate stepped ghosts. 8 px keeps the default 4 samples for moves up
|
|
51
|
+
* to 32 px per frame (a 1080p screen width in two seconds) and only renders more, slower,
|
|
52
|
+
* when a move is faster than that.
|
|
53
|
+
*/
|
|
54
|
+
export const MAX_COPY_GAP_PX = 8;
|
|
55
|
+
/** 270° averages 3 of the 4 sub-frames: a smooth streak, a little shorter than a full frame. */
|
|
56
|
+
export const DEFAULT_SHUTTER = 270;
|
|
57
|
+
/** Render workers when the caller names none: HyperFrames' own default leaves most cores idle. */
|
|
58
|
+
export function defaultWorkers(cores = availableParallelism()) {
|
|
59
|
+
return Math.max(2, Math.min(12, cores - 4));
|
|
60
|
+
}
|
|
61
|
+
/** Encoder settings per HyperFrames quality name; the blend is the final encode. */
|
|
62
|
+
const QUALITY = {
|
|
63
|
+
draft: { crf: "23", preset: "veryfast" },
|
|
64
|
+
looks: { crf: "16", preset: "medium" },
|
|
65
|
+
standard: { crf: "16", preset: "medium" },
|
|
66
|
+
delivery: { crf: "12", preset: "slow" },
|
|
67
|
+
high: { crf: "12", preset: "slow" },
|
|
68
|
+
};
|
|
69
|
+
const PASSTHROUGH = ["--composition", "--resolution", "--workers"];
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* How many sub-frames to render per output frame and how many of them to average. The
|
|
73
|
+
* shutter is the share of each frame interval the averaged samples span: 180° (the film
|
|
74
|
+
* default) averages the first half, 270° three quarters, 360° all of it.
|
|
75
|
+
*
|
|
76
|
+
* `options.samples` names the sub-frame count outright. Otherwise `options.peak` (the
|
|
77
|
+
* fastest move in pixels per output frame, e.g. from a draft) raises the count from
|
|
78
|
+
* DEFAULT_SAMPLES until neighbouring copies sit at most MAX_COPY_GAP_PX apart, capped by
|
|
79
|
+
* the render rate (MAX_RENDER_FPS / fps).
|
|
80
|
+
*
|
|
81
|
+
* @param {number} fps
|
|
82
|
+
* @param {number} [shutter]
|
|
83
|
+
* @param {{ samples?: number, peak?: number }} [options]
|
|
84
|
+
*/
|
|
85
|
+
export function blurPlan(fps, shutter = DEFAULT_SHUTTER, options = {}) {
|
|
86
|
+
if (!Number.isInteger(fps) || fps < 1 || fps > MAX_RENDER_FPS)
|
|
87
|
+
throw new Error("--fps must be a whole number of frames per second");
|
|
88
|
+
if (!Number.isFinite(shutter) || shutter <= 0 || shutter > 360)
|
|
89
|
+
throw new Error("--shutter must be more than 0 and at most 360 degrees");
|
|
90
|
+
const cap = Math.floor(MAX_RENDER_FPS / fps);
|
|
91
|
+
let samples;
|
|
92
|
+
if (options.samples !== undefined) {
|
|
93
|
+
if (!Number.isInteger(options.samples) || options.samples < 1)
|
|
94
|
+
throw new Error("--samples must be a whole number");
|
|
95
|
+
if (options.samples > cap)
|
|
96
|
+
throw new Error(
|
|
97
|
+
`At ${fps} fps at most ${cap} samples fit the ${MAX_RENDER_FPS} fps render limit`,
|
|
98
|
+
);
|
|
99
|
+
samples = options.samples;
|
|
100
|
+
} else {
|
|
101
|
+
let wanted = DEFAULT_SAMPLES;
|
|
102
|
+
if (options.peak !== undefined) {
|
|
103
|
+
if (!Number.isFinite(options.peak) || options.peak < 0)
|
|
104
|
+
throw new Error("The measured peak must be a speed of at least 0 px per frame");
|
|
105
|
+
// Copies of a move of `peak` px per frame sit peak/samples apart.
|
|
106
|
+
wanted = Math.max(DEFAULT_SAMPLES, Math.ceil(options.peak / MAX_COPY_GAP_PX));
|
|
107
|
+
}
|
|
108
|
+
samples = Math.min(wanted, cap);
|
|
109
|
+
}
|
|
110
|
+
const blended = Math.min(samples, Math.round((samples * shutter) / 360));
|
|
111
|
+
// Fewer than three sub-frames per frame (above 80 fps) ghosts instead of blurring.
|
|
112
|
+
if (samples < 3 || blended < 2)
|
|
113
|
+
throw new Error(
|
|
114
|
+
`At ${fps} fps there is room for ${samples} sample${samples === 1 ? "" : "s"} per frame, ` +
|
|
115
|
+
"too few to blur; lower the frame rate or widen --shutter.",
|
|
116
|
+
);
|
|
117
|
+
return { fps, shutter, samples, renderFps: fps * samples, blended };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 8-bit sRGB code → linear light in 0..65535 (exact sRGB transfer function). */
|
|
121
|
+
export const SRGB_TO_LINEAR = (() => {
|
|
122
|
+
const table = new Uint16Array(256);
|
|
123
|
+
for (let code = 0; code < 256; code++) {
|
|
124
|
+
const value = code / 255;
|
|
125
|
+
const linear = value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
126
|
+
table[code] = Math.round(linear * 65535);
|
|
127
|
+
}
|
|
128
|
+
return table;
|
|
129
|
+
})();
|
|
130
|
+
/** Linear light in 0..65535 → nearest 8-bit sRGB code. */
|
|
131
|
+
export const LINEAR_TO_SRGB = (() => {
|
|
132
|
+
const table = new Uint8Array(65536);
|
|
133
|
+
for (let level = 0; level < 65536; level++) {
|
|
134
|
+
const linear = level / 65535;
|
|
135
|
+
const value = linear <= 0.0031308 ? linear * 12.92 : 1.055 * linear ** (1 / 2.4) - 0.055;
|
|
136
|
+
table[level] = Math.min(255, Math.max(0, Math.round(value * 255)));
|
|
137
|
+
}
|
|
138
|
+
return table;
|
|
139
|
+
})();
|
|
140
|
+
|
|
141
|
+
/** Sampling stride (pixels each way) of the small luma picture the cut test compares. */
|
|
142
|
+
const CUT_STRIDE = 8;
|
|
143
|
+
/** A pixel "changed" across a step when its luma moves more than this many levels. */
|
|
144
|
+
const CUT_LEVEL = 24;
|
|
145
|
+
/** A cut changes at least this share of the frame in one step... */
|
|
146
|
+
const CUT_CHANGE = 0.3;
|
|
147
|
+
/** ...by several times the largest mean difference of the group's other steps. */
|
|
148
|
+
const CUT_SPIKE = 3;
|
|
149
|
+
/** Other steps count as at least this mean difference (levels), so noise is no baseline. */
|
|
150
|
+
const CUT_QUIET = 1;
|
|
151
|
+
|
|
152
|
+
/** Small luma picture of an RGB24 frame for the cut test (every CUT_STRIDE-th pixel). */
|
|
153
|
+
export function thumbnail(frame, width, height) {
|
|
154
|
+
const columns = Math.ceil(width / CUT_STRIDE);
|
|
155
|
+
const rows = Math.ceil(height / CUT_STRIDE);
|
|
156
|
+
const thumb = new Uint8Array(columns * rows);
|
|
157
|
+
let index = 0;
|
|
158
|
+
for (let y = 0; y < height; y += CUT_STRIDE) {
|
|
159
|
+
let offset = y * width * 3;
|
|
160
|
+
for (let x = 0; x < width; x += CUT_STRIDE, offset += CUT_STRIDE * 3)
|
|
161
|
+
thumb[index++] = (frame[offset] + 2 * frame[offset + 1] + frame[offset + 2] + 2) >> 2;
|
|
162
|
+
}
|
|
163
|
+
return thumb;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function stepDifference(a, b) {
|
|
167
|
+
let total = 0;
|
|
168
|
+
let changed = 0;
|
|
169
|
+
for (let index = 0; index < a.length; index++) {
|
|
170
|
+
const difference = Math.abs(a[index] - b[index]);
|
|
171
|
+
total += difference;
|
|
172
|
+
if (difference > CUT_LEVEL) changed++;
|
|
173
|
+
}
|
|
174
|
+
return { mean: total / a.length, changed: changed / a.length };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* How many of a group's first `blended` sub-frames to average: all of them, unless a hard cut
|
|
179
|
+
* falls between two of them, in which case only those before the cut (the side holding the
|
|
180
|
+
* output frame's own time, the first sub-frame). `thumbs` are the whole group's thumbnails;
|
|
181
|
+
* `before` is the previous group's last thumbnail, or null.
|
|
182
|
+
*/
|
|
183
|
+
export function framesBeforeCut(thumbs, blended, before = null) {
|
|
184
|
+
const steps = [];
|
|
185
|
+
if (before) steps.push(stepDifference(before, thumbs[0]));
|
|
186
|
+
const first = steps.length;
|
|
187
|
+
for (let index = 1; index < thumbs.length; index++)
|
|
188
|
+
steps.push(stepDifference(thumbs[index - 1], thumbs[index]));
|
|
189
|
+
for (let index = 1; index < blended; index++) {
|
|
190
|
+
const step = steps[first + index - 1];
|
|
191
|
+
let others = CUT_QUIET;
|
|
192
|
+
for (let other = 0; other < steps.length; other++)
|
|
193
|
+
if (other !== first + index - 1) others = Math.max(others, steps[other].mean);
|
|
194
|
+
if (step.changed >= CUT_CHANGE && step.mean >= CUT_SPIKE * others) return index;
|
|
195
|
+
}
|
|
196
|
+
return blended;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Averages RGB24 sub-frames in linear light into `out`. Runs of identical sub-frames are
|
|
201
|
+
* found by byte comparison; when every frame is the same, `out` is a byte copy.
|
|
202
|
+
*/
|
|
203
|
+
export function blendFrames(frames, out) {
|
|
204
|
+
const count = frames.length;
|
|
205
|
+
const first = frames[0];
|
|
206
|
+
if (count === 1 || frames.every((frame) => frame === first || first.equals(frame))) {
|
|
207
|
+
first.copy(out);
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
const half = count >> 1;
|
|
211
|
+
const toLinear = SRGB_TO_LINEAR;
|
|
212
|
+
const toSrgb = LINEAR_TO_SRGB;
|
|
213
|
+
if (count === 2) {
|
|
214
|
+
const [a, b] = frames;
|
|
215
|
+
for (let i = 0; i < out.length; i++) {
|
|
216
|
+
const x = a[i];
|
|
217
|
+
const y = b[i];
|
|
218
|
+
out[i] = x === y ? x : toSrgb[(toLinear[x] + toLinear[y] + 1) >> 1];
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
if (count === 3) {
|
|
223
|
+
const [a, b, c] = frames;
|
|
224
|
+
for (let i = 0; i < out.length; i++) {
|
|
225
|
+
const x = a[i];
|
|
226
|
+
const y = b[i];
|
|
227
|
+
const z = c[i];
|
|
228
|
+
out[i] =
|
|
229
|
+
x === y && y === z ? x : toSrgb[((toLinear[x] + toLinear[y] + toLinear[z] + 1) / 3) | 0];
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
for (let i = 0; i < out.length; i++) {
|
|
234
|
+
const x = first[i];
|
|
235
|
+
let same = true;
|
|
236
|
+
let sum = toLinear[x];
|
|
237
|
+
for (let f = 1; f < count; f++) {
|
|
238
|
+
const y = frames[f][i];
|
|
239
|
+
if (y !== x) same = false;
|
|
240
|
+
sum += toLinear[y];
|
|
241
|
+
}
|
|
242
|
+
out[i] = same ? x : toSrgb[((sum + half) / count) | 0];
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** FFmpeg colour matrix and range names matching the source's tags, for RGB round trips. */
|
|
248
|
+
function colorConversion(color) {
|
|
249
|
+
const matrix =
|
|
250
|
+
{
|
|
251
|
+
bt709: "bt709",
|
|
252
|
+
smpte170m: "bt601",
|
|
253
|
+
bt470bg: "bt601",
|
|
254
|
+
fcc: "fcc",
|
|
255
|
+
smpte240m: "smpte240m",
|
|
256
|
+
bt2020nc: "bt2020",
|
|
257
|
+
bt2020c: "bt2020",
|
|
258
|
+
}[color.color_space] ?? "bt601";
|
|
259
|
+
const range = color.color_range === "pc" ? "full" : "limited";
|
|
260
|
+
return { matrix, range };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Feed loudness (EBU R128 integrated -14 LUFS) where Reels, TikTok and Shorts play, then a
|
|
265
|
+
* 4x-oversampled limiter at -2 dBFS: loudnorm alone let a scored mix reach +2 dB true peak.
|
|
266
|
+
* Encoded at 320k: FFmpeg's AAC at 192k overshot the limited peaks by 2 dB.
|
|
267
|
+
*/
|
|
268
|
+
export const LOUDNESS =
|
|
269
|
+
"loudnorm=I=-14:TP=-1.5:LRA=11,aresample=192000," +
|
|
270
|
+
"alimiter=limit=0.79:level=false:attack=1:release=50,aresample=48000";
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* FFmpeg arguments that encode piped RGB24 blended frames, with the audio of `input` (none
|
|
274
|
+
* when `input` is null) brought to feed loudness, and the source's colour tags.
|
|
275
|
+
*/
|
|
276
|
+
export function encodeArgs(input, output, plan, size, quality = "looks", color = {}) {
|
|
277
|
+
const encode = QUALITY[quality];
|
|
278
|
+
if (!encode) throw new Error(`Unknown --quality ${quality}`);
|
|
279
|
+
const { matrix, range } = colorConversion(color);
|
|
280
|
+
const tags = [
|
|
281
|
+
["-colorspace", color.color_space],
|
|
282
|
+
["-color_primaries", color.color_primaries],
|
|
283
|
+
["-color_trc", color.color_transfer],
|
|
284
|
+
["-color_range", color.color_range],
|
|
285
|
+
].flatMap(([flag, value]) =>
|
|
286
|
+
typeof value === "string" && /^[a-z0-9-]+$/.test(value) && value !== "unknown"
|
|
287
|
+
? [flag, value]
|
|
288
|
+
: [],
|
|
289
|
+
);
|
|
290
|
+
return [
|
|
291
|
+
"-hide_banner",
|
|
292
|
+
"-nostdin",
|
|
293
|
+
"-loglevel",
|
|
294
|
+
"error",
|
|
295
|
+
"-protocol_whitelist",
|
|
296
|
+
"file,pipe",
|
|
297
|
+
"-f",
|
|
298
|
+
"rawvideo",
|
|
299
|
+
"-pix_fmt",
|
|
300
|
+
"rgb24",
|
|
301
|
+
"-s",
|
|
302
|
+
`${size.width}x${size.height}`,
|
|
303
|
+
"-r",
|
|
304
|
+
String(plan.fps),
|
|
305
|
+
"-i",
|
|
306
|
+
"pipe:0",
|
|
307
|
+
...(input ? ["-i", input] : []),
|
|
308
|
+
"-map",
|
|
309
|
+
"0:v:0",
|
|
310
|
+
...(input ? ["-map", "1:a?"] : []),
|
|
311
|
+
"-vf",
|
|
312
|
+
`scale=out_color_matrix=${matrix}:out_range=${range}:flags=accurate_rnd,format=yuv420p`,
|
|
313
|
+
"-c:v",
|
|
314
|
+
"libx264",
|
|
315
|
+
"-crf",
|
|
316
|
+
encode.crf,
|
|
317
|
+
"-preset",
|
|
318
|
+
encode.preset,
|
|
319
|
+
"-pix_fmt",
|
|
320
|
+
"yuv420p",
|
|
321
|
+
...tags,
|
|
322
|
+
"-af",
|
|
323
|
+
LOUDNESS,
|
|
324
|
+
"-c:a",
|
|
325
|
+
"aac",
|
|
326
|
+
"-b:a",
|
|
327
|
+
"320k",
|
|
328
|
+
"-ar",
|
|
329
|
+
"48000",
|
|
330
|
+
"-shortest",
|
|
331
|
+
"-movflags",
|
|
332
|
+
"+faststart",
|
|
333
|
+
"-metadata",
|
|
334
|
+
`comment=ez-motion-blur samples=${plan.blended}`,
|
|
335
|
+
"-y",
|
|
336
|
+
output,
|
|
337
|
+
];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function spawnTool(binary, args, signal, stdin) {
|
|
341
|
+
const child = spawn(binary, args, {
|
|
342
|
+
stdio: [stdin ? "pipe" : "ignore", stdin ? "ignore" : "pipe", "pipe"],
|
|
343
|
+
windowsHide: true,
|
|
344
|
+
signal,
|
|
345
|
+
});
|
|
346
|
+
let errors = "";
|
|
347
|
+
child.stderr.on("data", (chunk) => {
|
|
348
|
+
errors = (errors + chunk).slice(-4000);
|
|
349
|
+
});
|
|
350
|
+
const exited = new Promise((resolveExit, reject) => {
|
|
351
|
+
child.once("error", reject);
|
|
352
|
+
child.once("close", (code) => resolveExit({ code, errors: () => errors.trim() }));
|
|
353
|
+
});
|
|
354
|
+
return { child, exited };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Frame size, rate and colour tags of a video's first video stream. */
|
|
358
|
+
async function probeVideo(file, signal) {
|
|
359
|
+
const { ffprobe } = await mediaBinaries();
|
|
360
|
+
const { stdout } = await run(
|
|
361
|
+
ffprobe,
|
|
362
|
+
[
|
|
363
|
+
"-v",
|
|
364
|
+
"error",
|
|
365
|
+
"-select_streams",
|
|
366
|
+
"v:0",
|
|
367
|
+
"-show_entries",
|
|
368
|
+
"stream=width,height,avg_frame_rate,color_space,color_primaries,color_transfer,color_range",
|
|
369
|
+
"-of",
|
|
370
|
+
"json",
|
|
371
|
+
file,
|
|
372
|
+
],
|
|
373
|
+
{ encoding: "utf8", timeout: 30_000, maxBuffer: 64 * 1024, windowsHide: true, signal },
|
|
374
|
+
);
|
|
375
|
+
const stream = JSON.parse(stdout)?.streams?.[0] ?? {};
|
|
376
|
+
const [num, den] = String(stream.avg_frame_rate ?? "")
|
|
377
|
+
.split("/")
|
|
378
|
+
.map(Number);
|
|
379
|
+
return { ...stream, rate: num / den };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Averages a high-rate render into the blurred output (written fresh; the caller places it).
|
|
384
|
+
* Decodes to RGB24, blends each output frame's first `blended` sub-frames in linear light,
|
|
385
|
+
* never across a hard cut, and encodes the result with the render's audio.
|
|
386
|
+
*/
|
|
387
|
+
export async function blendVideo(input, output, plan, quality, signal) {
|
|
388
|
+
return assembleSpans([{ file: input, still: false }], input, output, plan, quality, signal);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Encodes the blurred output from renders of its spans, in order. A moving segment is a
|
|
393
|
+
* render at `plan.renderFps` whose sub-frames are blended per output frame as in
|
|
394
|
+
* blendVideo; a still segment is a render at `plan.fps` whose frames are copied as they
|
|
395
|
+
* are (a still frame blends to itself). `frames` is the segment's output-frame count (checked
|
|
396
|
+
* when given); `skip` drops that many leading output frames of the file first. `audio` is the
|
|
397
|
+
* file whose audio is muxed in, or null for none.
|
|
398
|
+
*
|
|
399
|
+
* @param {{ file: string, still: boolean, frames?: number, skip?: number }[]} segments
|
|
400
|
+
*/
|
|
401
|
+
export async function assembleSpans(segments, audio, output, plan, quality, signal) {
|
|
402
|
+
const { ffmpeg } = await mediaBinaries();
|
|
403
|
+
if (!QUALITY[quality ?? "looks"]) throw new Error(`Unknown --quality ${quality}`);
|
|
404
|
+
if (!segments.length) throw new Error("Nothing to assemble");
|
|
405
|
+
const streams = [];
|
|
406
|
+
for (const segment of segments) {
|
|
407
|
+
const stream = await probeVideo(segment.file, signal);
|
|
408
|
+
const expected = segment.still ? plan.fps : plan.renderFps;
|
|
409
|
+
if (Math.abs(stream.rate - expected) > 0.01)
|
|
410
|
+
throw new Error(`Expected a ${expected} fps render, got ${stream.avg_frame_rate}`);
|
|
411
|
+
if (!Number.isInteger(stream.width) || !Number.isInteger(stream.height))
|
|
412
|
+
throw new Error("The render has no readable frame size");
|
|
413
|
+
if (
|
|
414
|
+
streams.length &&
|
|
415
|
+
(stream.width !== streams[0].width || stream.height !== streams[0].height)
|
|
416
|
+
)
|
|
417
|
+
throw new Error("The span renders differ in frame size");
|
|
418
|
+
streams.push(stream);
|
|
419
|
+
}
|
|
420
|
+
const { width, height } = streams[0];
|
|
421
|
+
const frameBytes = width * height * 3;
|
|
422
|
+
const encode = spawnTool(
|
|
423
|
+
ffmpeg,
|
|
424
|
+
encodeArgs(audio, output, plan, { width, height }, quality ?? "looks", streams[0]),
|
|
425
|
+
signal,
|
|
426
|
+
true,
|
|
427
|
+
);
|
|
428
|
+
const sink = encode.child.stdin;
|
|
429
|
+
// A failed encoder closes its input; the exit code reports why.
|
|
430
|
+
sink.on("error", () => {});
|
|
431
|
+
const write = (buffer) =>
|
|
432
|
+
new Promise((resolveWrite, reject) => {
|
|
433
|
+
if (sink.write(buffer)) return resolveWrite();
|
|
434
|
+
const onDrain = () => {
|
|
435
|
+
cleanup();
|
|
436
|
+
resolveWrite();
|
|
437
|
+
};
|
|
438
|
+
const onClose = () => {
|
|
439
|
+
cleanup();
|
|
440
|
+
reject(new Error("The encoder stopped early"));
|
|
441
|
+
};
|
|
442
|
+
const cleanup = () => {
|
|
443
|
+
sink.off("drain", onDrain);
|
|
444
|
+
sink.off("close", onClose);
|
|
445
|
+
};
|
|
446
|
+
sink.on("drain", onDrain);
|
|
447
|
+
sink.on("close", onClose);
|
|
448
|
+
});
|
|
449
|
+
const { samples, blended } = plan;
|
|
450
|
+
// The previous output frame's last (sub-)frame thumbnail, for the cut test across groups.
|
|
451
|
+
let before = null;
|
|
452
|
+
try {
|
|
453
|
+
for (const [index, segment] of segments.entries()) {
|
|
454
|
+
const { matrix, range } = colorConversion(streams[index]);
|
|
455
|
+
const per = segment.still ? 1 : samples;
|
|
456
|
+
const filters = [];
|
|
457
|
+
if (segment.skip || segment.frames !== undefined) {
|
|
458
|
+
const start = (segment.skip ?? 0) * per;
|
|
459
|
+
filters.push(
|
|
460
|
+
`trim=start_frame=${start}` +
|
|
461
|
+
(segment.frames === undefined ? "" : `:end_frame=${start + segment.frames * per}`),
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
filters.push(`scale=in_color_matrix=${matrix}:in_range=${range}:flags=accurate_rnd`);
|
|
465
|
+
filters.push("format=rgb24");
|
|
466
|
+
const decode = spawnTool(
|
|
467
|
+
ffmpeg,
|
|
468
|
+
[
|
|
469
|
+
"-hide_banner",
|
|
470
|
+
"-nostdin",
|
|
471
|
+
"-loglevel",
|
|
472
|
+
"error",
|
|
473
|
+
"-protocol_whitelist",
|
|
474
|
+
"file,pipe",
|
|
475
|
+
"-i",
|
|
476
|
+
segment.file,
|
|
477
|
+
"-map",
|
|
478
|
+
"0:v:0",
|
|
479
|
+
"-an",
|
|
480
|
+
"-vf",
|
|
481
|
+
filters.join(","),
|
|
482
|
+
"-f",
|
|
483
|
+
"rawvideo",
|
|
484
|
+
"-",
|
|
485
|
+
],
|
|
486
|
+
signal,
|
|
487
|
+
false,
|
|
488
|
+
);
|
|
489
|
+
const group = Array.from({ length: per }, () => Buffer.allocUnsafe(frameBytes));
|
|
490
|
+
const thumbs = [];
|
|
491
|
+
let filled = 0;
|
|
492
|
+
let offset = 0;
|
|
493
|
+
let written = 0;
|
|
494
|
+
const flush = async (count) => {
|
|
495
|
+
if (segment.still) {
|
|
496
|
+
await write(Buffer.from(group[0]));
|
|
497
|
+
before = thumbnail(group[0], width, height);
|
|
498
|
+
written++;
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (count < blended) return;
|
|
502
|
+
const used = framesBeforeCut(thumbs.slice(0, count), blended, before);
|
|
503
|
+
await write(blendFrames(group.slice(0, used), Buffer.allocUnsafe(frameBytes)));
|
|
504
|
+
before = thumbs[count - 1];
|
|
505
|
+
thumbs.length = 0;
|
|
506
|
+
written++;
|
|
507
|
+
};
|
|
508
|
+
for await (const chunk of decode.child.stdout) {
|
|
509
|
+
let read = 0;
|
|
510
|
+
while (read < chunk.length) {
|
|
511
|
+
const take = Math.min(frameBytes - offset, chunk.length - read);
|
|
512
|
+
chunk.copy(group[filled], offset, read, read + take);
|
|
513
|
+
read += take;
|
|
514
|
+
offset += take;
|
|
515
|
+
if (offset === frameBytes) {
|
|
516
|
+
if (!segment.still) thumbs.push(thumbnail(group[filled], width, height));
|
|
517
|
+
offset = 0;
|
|
518
|
+
filled++;
|
|
519
|
+
if (filled === per) {
|
|
520
|
+
await flush(filled);
|
|
521
|
+
filled = 0;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const decoded = await decode.exited;
|
|
527
|
+
if (decoded.code !== 0 || offset !== 0)
|
|
528
|
+
throw new Error(
|
|
529
|
+
`Decoding the render failed: ${decoded.errors() || `exit ${decoded.code}`}`,
|
|
530
|
+
);
|
|
531
|
+
if (filled) await flush(filled);
|
|
532
|
+
thumbs.length = 0;
|
|
533
|
+
if (segment.frames !== undefined && written !== segment.frames)
|
|
534
|
+
throw new Error(`Expected ${segment.frames} frames from ${segment.file}, got ${written}`);
|
|
535
|
+
}
|
|
536
|
+
} finally {
|
|
537
|
+
sink.end();
|
|
538
|
+
}
|
|
539
|
+
const encoded = await encode.exited;
|
|
540
|
+
if (encoded.code !== 0)
|
|
541
|
+
throw new Error(`Encoding the blur failed: ${encoded.errors() || `exit ${encoded.code}`}`);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** A still run must keep this many output frames from each end of the hold it sits in. */
|
|
545
|
+
export const STILL_MARGIN = 2;
|
|
546
|
+
/** Still runs shorter than this (after the margins) render with the moving frames. */
|
|
547
|
+
export const MIN_STILL_SECONDS = 0.5;
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Ordered output-frame spans `{ start, end, still }` (end exclusive) covering `frames`
|
|
551
|
+
* output frames. `holds[i]` is true when output frame i is unchanged through frame i + 1,
|
|
552
|
+
* so its blur (sub-frames between i and i + 1) is the frame itself. A run of holds becomes a
|
|
553
|
+
* still span shrunk by STILL_MARGIN at both ends, kept only when at least MIN_STILL_SECONDS
|
|
554
|
+
* long; everything else is moving. Without holds, one moving span covers everything.
|
|
555
|
+
*
|
|
556
|
+
* @param {ArrayLike<boolean | number> | null | undefined} holds
|
|
557
|
+
* @param {number} fps
|
|
558
|
+
* @param {number} frames
|
|
559
|
+
*/
|
|
560
|
+
export function planSpans(holds, fps, frames) {
|
|
561
|
+
if (!Number.isInteger(frames) || frames < 1) throw new Error("Spans need a frame count");
|
|
562
|
+
if (!holds) return [{ start: 0, end: frames, still: false }];
|
|
563
|
+
const minimum = Math.ceil(MIN_STILL_SECONDS * fps);
|
|
564
|
+
const spans = [];
|
|
565
|
+
const add = (start, end, still) => {
|
|
566
|
+
if (end <= start) return;
|
|
567
|
+
const last = spans[spans.length - 1];
|
|
568
|
+
if (last && last.still === still && last.end === start) last.end = end;
|
|
569
|
+
else spans.push({ start, end, still });
|
|
570
|
+
};
|
|
571
|
+
let cursor = 0;
|
|
572
|
+
let index = 0;
|
|
573
|
+
while (index < frames) {
|
|
574
|
+
if (!holds[index]) {
|
|
575
|
+
index++;
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
let runEnd = index;
|
|
579
|
+
while (runEnd < frames && holds[runEnd]) runEnd++;
|
|
580
|
+
const start = index + STILL_MARGIN;
|
|
581
|
+
const end = runEnd - STILL_MARGIN;
|
|
582
|
+
if (end - start >= minimum) {
|
|
583
|
+
add(cursor, start, false);
|
|
584
|
+
add(start, end, true);
|
|
585
|
+
cursor = end;
|
|
586
|
+
}
|
|
587
|
+
index = runEnd;
|
|
588
|
+
}
|
|
589
|
+
add(cursor, frames, false);
|
|
590
|
+
return spans;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* What a draft video says about the blur: `peak`, its fastest move in pixels per output frame
|
|
595
|
+
* at the draft's size, from motion-check's flow meter (0 when nothing moves fast enough to
|
|
596
|
+
* report); `holds`, per draft frame, whether it is unchanged through the next frame (by the
|
|
597
|
+
* flow meter's still-step test, at its small grey grid); its frame count, rate and whether it
|
|
598
|
+
* carries audio.
|
|
599
|
+
*/
|
|
600
|
+
export async function measureDraft(draft, fps, signal) {
|
|
601
|
+
const { measureFlow, STILL_LEVEL, STILL_STEP_SHARE } = await import("./motion-check.mjs");
|
|
602
|
+
const { ffprobe } = await mediaBinaries();
|
|
603
|
+
const { stdout } = await run(
|
|
604
|
+
ffprobe,
|
|
605
|
+
["-v", "error", "-show_entries", "stream=codec_type,width,height", "-of", "json", draft],
|
|
606
|
+
{ encoding: "utf8", timeout: 30_000, maxBuffer: 64 * 1024, windowsHide: true, signal },
|
|
607
|
+
);
|
|
608
|
+
const streams = JSON.parse(stdout)?.streams ?? [];
|
|
609
|
+
const stream = streams.find((entry) => entry.codec_type === "video") ?? {};
|
|
610
|
+
const side = Math.max(stream.width ?? 0, stream.height ?? 0);
|
|
611
|
+
if (!side) throw new Error("The --draft video has no readable frame size");
|
|
612
|
+
const holds = [];
|
|
613
|
+
let previous = null;
|
|
614
|
+
const flow = await measureFlow(draft, signal, (frame) => {
|
|
615
|
+
if (previous) {
|
|
616
|
+
let visible = 0;
|
|
617
|
+
for (let index = 0; index < frame.length; index++)
|
|
618
|
+
if (Math.abs(frame[index] - previous[index]) > STILL_LEVEL) visible++;
|
|
619
|
+
holds.push(visible / frame.length <= STILL_STEP_SHARE);
|
|
620
|
+
}
|
|
621
|
+
previous = Uint8Array.from(frame);
|
|
622
|
+
});
|
|
623
|
+
// The last frame has no next frame to hold through.
|
|
624
|
+
if (previous) holds.push(false);
|
|
625
|
+
// `peak` is a share of the long side per draft frame.
|
|
626
|
+
const peak = Math.max(0, ...flow.fast.map((span) => span.peak));
|
|
627
|
+
return {
|
|
628
|
+
peak: (peak * side * flow.fps) / fps,
|
|
629
|
+
holds,
|
|
630
|
+
frames: holds.length,
|
|
631
|
+
fps: flow.fps,
|
|
632
|
+
hasAudio: streams.some((entry) => entry.codec_type === "audio"),
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** The fastest move in a draft video, in pixels per output frame (see measureDraft). */
|
|
637
|
+
export async function draftPeak(draft, fps, signal) {
|
|
638
|
+
return (await measureDraft(draft, fps, signal)).peak;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** The composition's own frame rate when it declares one, else HyperFrames' default 30. */
|
|
642
|
+
async function compositionFps(project, composition) {
|
|
643
|
+
try {
|
|
644
|
+
const html = await readFile(join(project, composition ?? "index.html"), "utf8");
|
|
645
|
+
const declared = /\bdata-fps\s*=\s*["']?(\d{1,3})\b/.exec(html);
|
|
646
|
+
if (declared) return Number(declared[1]);
|
|
647
|
+
} catch {
|
|
648
|
+
// An unreadable composition fails in the render itself, with HyperFrames' own message.
|
|
649
|
+
}
|
|
650
|
+
return 30;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function parseArgs(argv) {
|
|
654
|
+
const [project, output, ...rest] = argv;
|
|
655
|
+
if (!project || !output || project.startsWith("--") || output.startsWith("--"))
|
|
656
|
+
throw new Error(
|
|
657
|
+
"usage: motion-blur.mjs <project-dir> <output.mp4> [--fps <n>] [--shutter <degrees>] " +
|
|
658
|
+
"[--quality <name>] [--composition <file>] [--resolution <preset>] [--workers <n|auto>] " +
|
|
659
|
+
"[--samples <n> | --draft <video>] [--no-skip-still]",
|
|
660
|
+
);
|
|
661
|
+
const options = { project: resolve(project), output: resolve(output), passthrough: [] };
|
|
662
|
+
for (let index = 0; index < rest.length; index += 2) {
|
|
663
|
+
const flag = rest[index];
|
|
664
|
+
if (flag === "--no-skip-still") {
|
|
665
|
+
options.noSkipStill = true;
|
|
666
|
+
index--;
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
const value = rest[index + 1];
|
|
670
|
+
if (value === undefined || value.startsWith("--")) throw new Error(`${flag} needs a value`);
|
|
671
|
+
if (flag === "--fps") options.fps = Number(value);
|
|
672
|
+
else if (flag === "--shutter") options.shutter = Number(value);
|
|
673
|
+
else if (flag === "--samples") options.samples = Number(value);
|
|
674
|
+
else if (flag === "--draft") options.draft = resolve(value);
|
|
675
|
+
else if (flag === "--quality") {
|
|
676
|
+
if (!QUALITY[value]) throw new Error(`Unknown --quality ${value}`);
|
|
677
|
+
options.quality = value;
|
|
678
|
+
} else if (PASSTHROUGH.includes(flag)) {
|
|
679
|
+
if (flag === "--composition") options.composition = value;
|
|
680
|
+
options.passthrough.push(flag, value);
|
|
681
|
+
} else throw new Error(`Unsupported option ${flag}; the blur step owns --output and --format`);
|
|
682
|
+
}
|
|
683
|
+
if (options.samples !== undefined && options.draft)
|
|
684
|
+
throw new Error("Give --samples or --draft, not both");
|
|
685
|
+
if (extname(options.output).toLowerCase() !== ".mp4")
|
|
686
|
+
throw new Error("The blur step writes MP4 only");
|
|
687
|
+
return options;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Fixed cost, in seconds, of one HyperFrames render call before it captures a frame (browser
|
|
692
|
+
* start, page load, encoder): a 2-frame range of a 1080×1920 composition took about 2.5 s.
|
|
693
|
+
*/
|
|
694
|
+
export const RENDER_CALL_SECONDS = 2.5;
|
|
695
|
+
|
|
696
|
+
/** HyperFrames refused a `--frames` range render (HDR/shader capture path, past the end...). */
|
|
697
|
+
class FramesRefused extends Error {}
|
|
698
|
+
const FRAMES_REFUSED =
|
|
699
|
+
/--frames|Render artifact is truncated|Unsupported frames|Invalid frames|frameRange/;
|
|
700
|
+
|
|
701
|
+
/** One HyperFrames render of the project to `output` at `fps`, optionally of a frame range. */
|
|
702
|
+
async function renderComposition(options, quality, fps, output, frames, signal) {
|
|
703
|
+
const started = performance.now();
|
|
704
|
+
const render = spawn(
|
|
705
|
+
process.execPath,
|
|
706
|
+
[
|
|
707
|
+
join(here, "hyperframes.mjs"),
|
|
708
|
+
"render",
|
|
709
|
+
"--fps",
|
|
710
|
+
String(fps),
|
|
711
|
+
...(frames ? ["--frames", `${frames[0]}-${frames[1]}`] : []),
|
|
712
|
+
"--format",
|
|
713
|
+
"mp4",
|
|
714
|
+
"--quality",
|
|
715
|
+
quality,
|
|
716
|
+
"--quiet",
|
|
717
|
+
"--output",
|
|
718
|
+
output,
|
|
719
|
+
...(options.passthrough.includes("--workers") ? [] : ["--workers", String(defaultWorkers())]),
|
|
720
|
+
...options.passthrough,
|
|
721
|
+
],
|
|
722
|
+
{
|
|
723
|
+
cwd: options.project,
|
|
724
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
725
|
+
windowsHide: true,
|
|
726
|
+
signal,
|
|
727
|
+
},
|
|
728
|
+
);
|
|
729
|
+
let errors = "";
|
|
730
|
+
render.stderr.on("data", (chunk) => {
|
|
731
|
+
errors = (errors + chunk).slice(-4000);
|
|
732
|
+
});
|
|
733
|
+
const code = await new Promise((resolveExit, reject) => {
|
|
734
|
+
render.once("error", reject);
|
|
735
|
+
render.once("close", resolveExit);
|
|
736
|
+
});
|
|
737
|
+
if (code !== 0) {
|
|
738
|
+
const message = `Render failed: ${errors.trim() || `exit ${code}`}`;
|
|
739
|
+
const refusal = errors.split("\n").find((line) => FRAMES_REFUSED.test(line));
|
|
740
|
+
if (frames && refusal) throw new FramesRefused(refusal.trim());
|
|
741
|
+
throw new Error(message);
|
|
742
|
+
}
|
|
743
|
+
return (performance.now() - started) / 1000;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Renders only what each span needs: moving spans at the sub-frame rate, still spans at the
|
|
748
|
+
* output rate (one range render per still span, or one whole render at the output rate when
|
|
749
|
+
* that is estimated cheaper). Returns the assembly segments and a timing log.
|
|
750
|
+
*/
|
|
751
|
+
async function renderSpans(options, quality, plan, spans, work, signal) {
|
|
752
|
+
const segments = [];
|
|
753
|
+
const renders = [];
|
|
754
|
+
let captureSeconds = 0;
|
|
755
|
+
let captured = 0;
|
|
756
|
+
for (const [index, span] of spans.entries()) {
|
|
757
|
+
if (span.still) continue;
|
|
758
|
+
const file = join(work, `span-${index}.mp4`);
|
|
759
|
+
const range = [span.start * plan.samples, span.end * plan.samples];
|
|
760
|
+
const seconds = await renderComposition(options, quality, plan.renderFps, file, range, signal);
|
|
761
|
+
renders.push({ fps: plan.renderFps, frames: range, seconds: Math.round(seconds * 10) / 10 });
|
|
762
|
+
captureSeconds += Math.max(0, seconds - RENDER_CALL_SECONDS);
|
|
763
|
+
captured += range[1] - range[0];
|
|
764
|
+
segments[index] = { file, still: false, frames: span.end - span.start };
|
|
765
|
+
}
|
|
766
|
+
const still = spans.map((span, index) => ({ span, index })).filter(({ span }) => span.still);
|
|
767
|
+
const stillFrames = still.reduce((sum, { span }) => sum + span.end - span.start, 0);
|
|
768
|
+
const total = spans[spans.length - 1].end;
|
|
769
|
+
// Seconds per captured frame, from the moving renders (one sub-frame costs about as much
|
|
770
|
+
// as one output frame).
|
|
771
|
+
const perFrame = captured ? captureSeconds / captured : 0.02;
|
|
772
|
+
const separate = still.length * RENDER_CALL_SECONDS + stillFrames * perFrame;
|
|
773
|
+
const whole = RENDER_CALL_SECONDS + total * perFrame;
|
|
774
|
+
const strategy = separate <= whole ? "ranges" : "whole";
|
|
775
|
+
if (strategy === "whole") {
|
|
776
|
+
const file = join(work, "still.mp4");
|
|
777
|
+
const seconds = await renderComposition(options, quality, plan.fps, file, null, signal);
|
|
778
|
+
renders.push({ fps: plan.fps, frames: [0, total], seconds: Math.round(seconds * 10) / 10 });
|
|
779
|
+
for (const { span, index } of still)
|
|
780
|
+
segments[index] = { file, still: true, skip: span.start, frames: span.end - span.start };
|
|
781
|
+
} else {
|
|
782
|
+
for (const { span, index } of still) {
|
|
783
|
+
const file = join(work, `span-${index}.mp4`);
|
|
784
|
+
const seconds = await renderComposition(
|
|
785
|
+
options,
|
|
786
|
+
quality,
|
|
787
|
+
plan.fps,
|
|
788
|
+
file,
|
|
789
|
+
[span.start, span.end],
|
|
790
|
+
signal,
|
|
791
|
+
);
|
|
792
|
+
renders.push({
|
|
793
|
+
fps: plan.fps,
|
|
794
|
+
frames: [span.start, span.end],
|
|
795
|
+
seconds: Math.round(seconds * 10) / 10,
|
|
796
|
+
});
|
|
797
|
+
segments[index] = { file, still: true, frames: span.end - span.start };
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
return { segments, renders, stillStrategy: strategy };
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Screen a finished video for harmful flashing (WCAG 2.3.1 general and red thresholds: no
|
|
805
|
+
* more than three flashes in any second). `safe` is false with the seconds that flash too much.
|
|
806
|
+
*/
|
|
807
|
+
export async function checkFlashing(file, signal) {
|
|
808
|
+
const result = await analyzeFlashes(file, signal);
|
|
809
|
+
const failures = [...result.general.failures, ...result.red.failures]
|
|
810
|
+
.sort((a, b) => a.start - b.start)
|
|
811
|
+
.slice(0, 10);
|
|
812
|
+
return {
|
|
813
|
+
safe: result.ok,
|
|
814
|
+
maxFlashesPerSecond: Math.max(
|
|
815
|
+
result.general.maxFlashesPerSecond,
|
|
816
|
+
result.red.maxFlashesPerSecond,
|
|
817
|
+
),
|
|
818
|
+
...(failures.length ? { failures } : {}),
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async function main() {
|
|
823
|
+
const started = performance.now();
|
|
824
|
+
const options = parseArgs(process.argv.slice(2));
|
|
825
|
+
if (!(await stat(options.project)).isDirectory()) throw new Error("Expected a project folder");
|
|
826
|
+
if (await stat(options.output).catch(() => null))
|
|
827
|
+
throw new Error(`${options.output} already exists; render a new version instead`);
|
|
828
|
+
const fps = options.fps ?? (await compositionFps(options.project, options.composition));
|
|
829
|
+
const shutter = options.shutter ?? DEFAULT_SHUTTER;
|
|
830
|
+
// Refuse impossible rates and shutters before measuring a draft.
|
|
831
|
+
blurPlan(fps, shutter, options.samples === undefined ? {} : { samples: options.samples });
|
|
832
|
+
const quality = options.quality ?? "looks";
|
|
833
|
+
const controller = new AbortController();
|
|
834
|
+
const abort = () => controller.abort();
|
|
835
|
+
process.once("SIGTERM", abort);
|
|
836
|
+
process.once("SIGINT", abort);
|
|
837
|
+
const draft = options.draft
|
|
838
|
+
? await measureDraft(options.draft, fps, controller.signal)
|
|
839
|
+
: undefined;
|
|
840
|
+
const peak = draft?.peak;
|
|
841
|
+
const plan = blurPlan(fps, shutter, { samples: options.samples, peak });
|
|
842
|
+
// Still spans come from the draft's frames, so it must be at the output rate.
|
|
843
|
+
let note;
|
|
844
|
+
let spans = null;
|
|
845
|
+
if (draft && options.noSkipStill)
|
|
846
|
+
note = "--no-skip-still: rendered everything at the sub-frame rate";
|
|
847
|
+
else if (draft && Math.abs(draft.fps - fps) > 0.01)
|
|
848
|
+
note = `The draft is ${draft.fps} fps, not ${fps}; rendered everything at the sub-frame rate`;
|
|
849
|
+
else if (draft) {
|
|
850
|
+
spans = planSpans(draft.holds, fps, draft.frames);
|
|
851
|
+
if (!spans.some((span) => span.still)) {
|
|
852
|
+
note = "No still span long enough to skip";
|
|
853
|
+
spans = null;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
const work = await mkdtemp(join(tmpdir(), "ez-motion-blur-"));
|
|
857
|
+
try {
|
|
858
|
+
const renderStarted = performance.now();
|
|
859
|
+
const blended = join(work, "blurred.mp4");
|
|
860
|
+
let spanned = null;
|
|
861
|
+
if (spans) {
|
|
862
|
+
try {
|
|
863
|
+
spanned = await renderSpans(options, quality, plan, spans, work, controller.signal);
|
|
864
|
+
} catch (error) {
|
|
865
|
+
if (!(error instanceof FramesRefused)) throw error;
|
|
866
|
+
note = `HyperFrames refused --frames, rendered everything instead: ${error.message.slice(0, 300)}`;
|
|
867
|
+
spans = null;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
let renderSeconds;
|
|
871
|
+
let blendStarted;
|
|
872
|
+
if (spanned) {
|
|
873
|
+
renderSeconds = (performance.now() - renderStarted) / 1000;
|
|
874
|
+
blendStarted = performance.now();
|
|
875
|
+
// Range renders carry no audio; the draft is a render of the same composition, so its
|
|
876
|
+
// audio track is the composition's.
|
|
877
|
+
await assembleSpans(
|
|
878
|
+
spanned.segments,
|
|
879
|
+
draft.hasAudio ? options.draft : null,
|
|
880
|
+
blended,
|
|
881
|
+
plan,
|
|
882
|
+
quality,
|
|
883
|
+
controller.signal,
|
|
884
|
+
);
|
|
885
|
+
} else {
|
|
886
|
+
const source = join(work, "samples.mp4");
|
|
887
|
+
await renderComposition(options, quality, plan.renderFps, source, null, controller.signal);
|
|
888
|
+
renderSeconds = (performance.now() - renderStarted) / 1000;
|
|
889
|
+
blendStarted = performance.now();
|
|
890
|
+
await blendVideo(source, blended, plan, quality, controller.signal);
|
|
891
|
+
}
|
|
892
|
+
// Exclusive copy: never replaces an export that appeared meanwhile.
|
|
893
|
+
await copyFile(blended, options.output, constants.COPYFILE_EXCL);
|
|
894
|
+
const flashing = await checkFlashing(options.output, controller.signal);
|
|
895
|
+
if (!flashing.safe) {
|
|
896
|
+
process.exitCode = 1;
|
|
897
|
+
const when = (flashing.failures ?? []).map((f) => `${f.start}–${f.end}s`).join(", ");
|
|
898
|
+
process.stderr.write(
|
|
899
|
+
`Harmful flashing: up to ${flashing.maxFlashesPerSecond} flashes a second (at most 3 are safe) at ${when}. Slow or soften those moments and render a new version; do not deliver this one.\n`,
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
process.stdout.write(
|
|
903
|
+
`${JSON.stringify({
|
|
904
|
+
ok: flashing.safe,
|
|
905
|
+
output: options.output,
|
|
906
|
+
flashing,
|
|
907
|
+
...plan,
|
|
908
|
+
...(peak === undefined ? {} : { draftPeakPx: Math.round(peak * 10) / 10 }),
|
|
909
|
+
quality,
|
|
910
|
+
...(spanned
|
|
911
|
+
? {
|
|
912
|
+
spans,
|
|
913
|
+
stillFrames: spans
|
|
914
|
+
.filter((span) => span.still)
|
|
915
|
+
.reduce((sum, span) => sum + span.end - span.start, 0),
|
|
916
|
+
stillStrategy: spanned.stillStrategy,
|
|
917
|
+
renders: spanned.renders,
|
|
918
|
+
audio: draft.hasAudio ? "draft" : "none",
|
|
919
|
+
}
|
|
920
|
+
: {}),
|
|
921
|
+
...(note ? { note } : {}),
|
|
922
|
+
renderSeconds: Math.round(renderSeconds * 10) / 10,
|
|
923
|
+
blendSeconds: Math.round((performance.now() - blendStarted) / 100) / 10,
|
|
924
|
+
elapsedMs: Math.round(performance.now() - started),
|
|
925
|
+
})}\n`,
|
|
926
|
+
);
|
|
927
|
+
} finally {
|
|
928
|
+
process.removeListener("SIGTERM", abort);
|
|
929
|
+
process.removeListener("SIGINT", abort);
|
|
930
|
+
await rm(work, { recursive: true, force: true });
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
935
|
+
try {
|
|
936
|
+
await main();
|
|
937
|
+
} catch (error) {
|
|
938
|
+
process.stderr.write(
|
|
939
|
+
`Motion blur failed: ${error instanceof Error ? error.message : "invalid input"}\n`,
|
|
940
|
+
);
|
|
941
|
+
process.exitCode = 1;
|
|
942
|
+
}
|
|
943
|
+
}
|