commit e0a48018b7e0687370aaa8859beee233128c49d2
parent 7935625a2f39b4c272cd5df670551293de239882
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 20:29:57 -0400
report-to-video: the dip in the joined cut -- the segment before a dipped teaser fades its sound on its join; the whole finished frame (footage, deck, feed, posts, rail) eases to black over its last fade seconds and holds black through the dissolve and the black, by geq after every overlay, in each concat path; the teaser segment and its page take the cut's transition; a cut without a dip writes the graphs it did
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
1 file changed, 192 insertions(+), 67 deletions(-)
diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs
@@ -82,7 +82,8 @@ import { createCueSource, siteOriginFromManifest } from "./cues.mjs";
// the schedule down -- it never has a copy of the arithmetic.
import {
assertChrome, deckGeometry, deckOn, deckSchedule, endFadeOf, feedGeometry, feedOn, frameCount, hidesDeck, MUTE_FADE,
- muteSegmentSeconds, playWindow, postsGeometry, postWindows, resolveDeck, scheduleFrom, snapWindow, teaserHits, teaserSeconds, teaserTitle,
+ dipOf, muteSegmentSeconds, playWindow, postsGeometry, postWindows, resolveDeck, scheduleFrom, snapWindow, teaserHits, teaserSeconds,
+ teaserTitle,
validateCutEdits, validatePosts, validateTeasers,
} from "./deck.mjs";
// The per-platform yt-dlp args (Rumble's `--impersonate chrome`): the ONE table,
@@ -1105,7 +1106,11 @@ const ev6 = (v) => {
* - the tail: a low noise decay (low-passed at 260 Hz) under it.
* A swell is the boom's sine rising f0 → f1 under an envelope that peaks
* three quarters of the way through `dur` and settles, with a breath of the
- * low noise. The noise is a hash of the sample number, not `random()`, so it
+ * low noise. A riser (a dip's, under the black) is a sub whose pitch climbs
+ * f0 → f1 over `dur` and a noise swell -- its own layer, band-passed
+ * 400 Hz–6.5 kHz, present only when there is a riser -- both rising (the sub
+ * squared, the noise cubed) to their peak at the first hit and released over
+ * `decay`. The noise is a hash of the sample number, not `random()`, so it
* is the same whatever else is in the graph. The sum takes a short low-passed
* echo, then `level`, then a limiter at −6 dBFS (`limit`, no auto-level,
* latency compensated) so hits that overlap still sum cleanly; trimmed and
@@ -1121,10 +1126,21 @@ export function teaserAudioGraph(hits, { seconds, render, level = TEASER_AUDIO.l
const boom = [];
const punch = [];
const rumble = [];
+ const whoosh = [];
for (const h of hits) {
const a = ev6(h.at);
const u = `(t-${a})`;
const g = ev6(h.gain);
+ if (h.kind === "riser") {
+ const D = ev6(h.dur);
+ const v = `min(${u},${D})`;
+ const phase = `(${ev6(h.f0)}*${v}+${ev6((h.f1 - h.f0) / (2 * h.dur))}*${v}*${v}+${ev6(h.f1)}*(${u}-${v}))`;
+ const rel = `clip((${ev6(h.dur + h.decay)}-${u})/${ev6(h.decay)},0,1)`;
+ const span = ev6(h.dur + h.decay);
+ boom.push(`if(between(t,${a},${a}+${span}),${g}*0.5*pow(${v}/${D},2)*${rel}*sin(2*PI*${phase}),0)`);
+ whoosh.push(`if(between(t,${a},${a}+${span}),${g}*0.6*pow(${v}/${D},3)*${rel}*${noise},0)`);
+ continue;
+ }
if (h.kind === "swell") {
const D = h.dur;
const peak = ev6(D * 0.75);
@@ -1147,11 +1163,14 @@ export function teaserAudioGraph(hits, { seconds, render, level = TEASER_AUDIO.l
rumble.push(`if(between(t,${a},${a}+${ev6(Math.min(2.6, h.decay * 6))}),${g}*0.32*min(1,${u}/0.02)*exp(-${u}/${ev6(h.decay * 1.3)})*${noise},0)`);
}
const src = (terms) => `aevalsrc=exprs='${terms.length ? terms.join("+") : "0"}':s=${rate}:c=${layout}:d=${len}`;
+ // The riser's layer only when there is one: a teaser without a dip writes the graph it always did.
+ const w = whoosh.length ? [`${src(whoosh)},highpass=f=400,lowpass=f=6500[tw]`] : [];
return [
`${src(boom)}[tb]`,
`${src(punch)},highpass=f=180,lowpass=f=3200[tp]`,
`${src(rumble)},lowpass=f=260,lowpass=f=260[tr]`,
- `[tb][tp][tr]amix=inputs=3:normalize=0,aecho=1:1:90|190:0.18|0.09,lowpass=f=5000,` +
+ ...w,
+ `[tb][tp][tr]${w.length ? "[tw]" : ""}amix=inputs=${3 + w.length}:normalize=0,aecho=1:1:90|190:0.18|0.09,lowpass=f=5000,` +
`volume=${ev6(level)},alimiter=limit=${ev6(limit)}:level=0:latency=1:attack=2:release=80,${tail}`,
].join(";");
}
@@ -1189,22 +1208,25 @@ export const teaserSegmentKey = (framesKey, audioGraph, render) =>
/**
* Compose and render the teaser (cached by compose-chrome's key), then encode
* its segment unless the one on disk was made from the same frames and sound.
- * Dynamic import: compose-chrome imports this file, and its page module must
- * not reach umtool's bundle through the build (docs/quirks.md).
+ * `transition` is the cut's crossfade as this build plays it (0 under
+ * `--no-xfade`): a dip's lead is that dissolve and the black, in the page and
+ * in the sound alike. Dynamic import: compose-chrome imports this file, and
+ * its page module must not reach umtool's bundle through the build (docs/quirks.md).
*/
-export async function buildTeaserSegment(entry, { manifestPath, render, outDir, variant }) {
+export async function buildTeaserSegment(entry, { manifestPath, render, outDir, variant, transition = render.transition ?? 0.5 }) {
const { composeChrome } = await import("./compose-chrome.mjs");
const t0 = Date.now();
const r = await composeChrome({
manifestPath, outDir, variant, region: "teaser", segment: entry.id, doRender: true,
- fps: render.fps, workers: 4, quality: "high", format: "png-sequence",
+ fps: render.fps, workers: 4, quality: "high", format: "png-sequence", transition,
});
EMIT("chrome", {
phase: r.cached ? "cached" : "render", region: "teaser", segment: entry.id,
frames: r.frameCount, key: r.key, dir: r.frames, seconds: Number(((Date.now() - t0) / 1000).toFixed(1)),
});
- const seconds = teaserSeconds(entry);
- const audio = teaserAudioGraph(teaserHits(entry), { seconds, render });
+ const seconds = teaserSeconds(entry, transition);
+ const hits = teaserHits(entry, transition);
+ const audio = teaserAudioGraph(hits, { seconds, render });
const key = teaserSegmentKey(r.key, audio, render);
const seg = path.join(outDir, "segments", `${entry.id}.mp4`);
const recPath = teaserRecordPath(seg);
@@ -1214,7 +1236,7 @@ export async function buildTeaserSegment(entry, { manifestPath, render, outDir,
return seg;
}
await execFileP(FFMPEG, teaserEncodeArgs({ framesDir: r.frames, seconds, render, audio, outPath: seg }), { maxBuffer: 1 << 26 });
- await writeFile(recPath, JSON.stringify({ key, frames: r.key, hits: teaserHits(entry).length }) + "\n", "utf8");
+ await writeFile(recPath, JSON.stringify({ key, frames: r.key, hits: hits.length }) + "\n", "utf8");
return seg;
}
@@ -2515,9 +2537,10 @@ export const endFadeAudioFilter = (fade, render) => {
export function joinInputChain(i, join, render) {
if (!join) return { parts: [], v: `[${i}:v]`, a: `[${i}:a]` };
const parts = [];
- // Picture: hold, move, end fade. Sound: mute, hold, end fade -- the mute is
- // in the clip's own clock and the hold is silence anyway; the end fade is
- // last on both, over the segment's final seconds as the cut plays them.
+ // Picture: hold, move, end fade. Sound: mute, hold, end fade, dip -- the
+ // mute is in the clip's own clock and the hold is silence anyway; the end
+ // fade (the last segment) and the dip (one before a teaser that dips) are
+ // last, over the segment's final seconds as the cut plays them.
const vf = [
join.hold > 0 ? holdVideoFilter(join.hold) : null,
join.move ? moveFilter(join.move, render) : null,
@@ -2529,6 +2552,9 @@ export function joinInputChain(i, join, render) {
join.mute != null ? muteAudioFilter(join.mute) : null,
join.hold > 0 ? holdAudioFilter(join.hold) : null,
join.fade ? endFadeAudioFilter(join.fade, render) : null,
+ // A dip into the next segment: the sound's half, the end fade's arithmetic.
+ // Its picture is the whole frame's, made after the overlays (dipWindows).
+ join.dip ? endFadeAudioFilter(join.dip, render) : null,
].filter(Boolean);
const a = af.length ? `[j${i}a]` : `[${i}:a]`;
if (af.length) parts.push(`[${i}:a]${af.join(",")}${a}`);
@@ -2537,25 +2563,29 @@ export function joinInputChain(i, join, render) {
/**
* The joins with the cut's edits merged in: a `muteFrom` (`mutes`: segment
- * index → segment seconds) and the end fade on the LAST segment
- * (`fade`: `{ seconds, lastFrame }`). A join gains `mute` / `fade` only when it
- * has one, so a cut without either keeps exactly the joins (and the graph,
- * and the hard-cut record) it had; null when nothing is joined at all.
+ * index → segment seconds), the end fade on the LAST segment
+ * (`fade`: `{ seconds, lastFrame }`) and a dip on the segment BEFORE a teaser
+ * that dips (`dips`: segment index → `{ seconds, lastFrame, black }`). A join
+ * gains `mute` / `fade` / `dip` only when it has one, so a cut without any
+ * keeps exactly the joins (and the graph, and the hard-cut record) it had;
+ * null when nothing is joined at all.
*/
-export function withCutEdits(joins, n, { mutes = new Map(), fade = null } = {}) {
- if (!mutes.size && !fade) return joins;
+export function withCutEdits(joins, n, { mutes = new Map(), fade = null, dips = new Map() } = {}) {
+ if (!mutes.size && !fade && !dips.size) return joins;
const out = Array.from({ length: n }, (_, i) => joins?.[i] ?? null);
for (const [i, at] of mutes) out[i] = { hold: 0, move: null, ...(out[i] ?? {}), mute: at };
if (fade) out[n - 1] = { hold: 0, move: null, ...(out[n - 1] ?? {}), fade };
+ for (const [i, dip] of dips) out[i] = { hold: 0, move: null, ...(out[i] ?? {}), dip };
return out.some(Boolean) ? out : null;
}
/**
* Every join the cut makes: the deck's holds and moves (`segmentJoins` of its
* schedule; none without the deck), each clip's `muteFrom` mapped to its
- * segment's clock through the segment's cut record, and `render.endFade` on
- * the last segment. Reads the records and probes what it needs; null when
- * nothing is joined, so every concat then runs as it always did.
+ * segment's clock through the segment's cut record, `render.endFade` on
+ * the last segment, and each teaser's `dip` on the segment before it. Reads
+ * the records and probes what it needs; null when nothing is joined, so every
+ * concat then runs as it always did.
*/
export async function cutJoins({ schedule = null, entries, segments, render }) {
const base = schedule ? segmentJoins(schedule) : null;
@@ -2578,15 +2608,81 @@ export async function cutJoins({ schedule = null, entries, segments, render }) {
});
mutes.set(i, m.at);
}
+ // A segment's frames in the cut, its hold's clones included.
+ const cutFrames = async (i) => Math.round((await probeDuration(segments[i], render.fps)) * render.fps) +
+ Math.round((base?.[i]?.hold ?? 0) * render.fps);
const seconds = endFadeOf(render);
let fade = null;
if (seconds > 0 && segments.length) {
const last = segments.length - 1;
- const frames = Math.round((await probeDuration(segments[last], render.fps)) * render.fps) +
- Math.round((base?.[last]?.hold ?? 0) * render.fps);
- fade = { seconds, lastFrame: frames - 1 };
+ fade = { seconds, lastFrame: (await cutFrames(last)) - 1 };
}
- return withCutEdits(base, segments.length, { mutes, fade });
+ // A teaser's dip fades the segment before it, over its last `fade` seconds.
+ const dips = new Map();
+ for (let i = 1; i < entries.length && i < segments.length; i += 1) {
+ const dip = dipOf(entries[i]);
+ if (!dip) continue;
+ dips.set(i - 1, { seconds: dip.fade, lastFrame: (await cutFrames(i - 1)) - 1, black: dip.black });
+ }
+ return withCutEdits(base, segments.length, { mutes, fade, dips });
+}
+
+/**
+ * Where the cut dips to black, from its joins (`withCutEdits`' `dip`s) and its
+ * segments' lengths in the cut (`cutOffsets`' `durs`, holds included), in the
+ * cut's FRAMES: `s` the last frame untouched, `last` the dipped segment's last
+ * frame -- black, as the end fade's last is bg -- and `until` the first frame
+ * NOT held black: the dissolve's end plus `black`. Frame f in (s, last] is
+ * (f − s)/(last − s) of the way to black; (last, until) is black. Null with
+ * no dip, so every graph is the one it always was.
+ *
+ * The fade's frames are the end fade's (`endFadeFrames`: clamped to the
+ * segment), so the picture and the sound -- `endFadeAudioFilter` on the same
+ * join -- end together. The teaser after it starts its dissolve inside the
+ * fade, black (its lead, `teaserLead`), and stays black past `until`.
+ *
+ * @returns {Array<{ segment: number, s: number, last: number, until: number }> | null}
+ */
+export function dipWindows(joins, durs, D, fps) {
+ if (!joins?.some((j) => j?.dip)) return null;
+ const { starts } = scheduleFrom(durs, D);
+ const out = [];
+ joins.forEach((j, i) => {
+ if (!j?.dip) return;
+ const last = Math.round(starts[i] * fps) + j.dip.lastFrame;
+ const s = last - endFadeFrames(j.dip, fps);
+ out.push({ segment: i, s, last, until: last + 1 + Math.round(j.dip.black * fps) });
+ });
+ return out;
+}
+
+/**
+ * The dips as one filter chain on the FINISHED picture -- after every overlay
+ * (deck, feed, posts, rail), so the whole frame goes to black, not the footage
+ * under a lit panel. `geq` toward Y′CbCr black (16/128/128) in the stream's
+ * own yuv420p, as the end fade blends toward bg (not `fade`, whose coloured
+ * form converts to RGB), and only inside each window (`enable`): every other
+ * frame passes untouched. `shift` is the second the stream's own clock
+ * starts at in the cut's (a preview's window).
+ *
+ * @returns {string|null} a `geq,…` chain, or null with no dips
+ */
+export function dipVideoFilter(windows, fps, shift = 0) {
+ if (!windows?.length) return null;
+ return windows.map((w) => {
+ const st = exprNum(w.s / fps - shift);
+ const k = `clip((T-${st})/${exprNum((w.last - w.s) / fps)},0,1)`;
+ const plane = (p, c) => `'${p}(X,Y)+(${c}-${p}(X,Y))*${k}+0.5'`;
+ const a = exprNum((w.s + 0.5) / fps - shift);
+ const b = exprNum((w.until - 0.5) / fps - shift);
+ return `geq=lum=${plane("lum", 16)}:cb=${plane("cb", 128)}:cr=${plane("cr", 128)}:enable='between(t,${a},${b})'`;
+ }).join(",");
+}
+
+/** `inLabel` through the dips to `outLabel`, as graph parts: none when there are no dips. */
+export function dipParts(inLabel, windows, fps, { shift = 0, outLabel = "[vdip]" } = {}) {
+ const f = dipVideoFilter(windows, fps, shift);
+ return f ? { parts: [`${inLabel}${f}${outLabel}`], label: outLabel } : { parts: [], label: inLabel };
}
/**
@@ -2642,8 +2738,10 @@ export function xfadeGraph(durs, D, joins = null, render = null) {
* A hard-cut concat through the concat FILTER, for a cut whose joins need a
* filtergraph (the demuxer's stream copy cannot host one): every input's
* chain, then `concat`, one encode at the parameters every segment shares.
+ * `dips` (`dipWindows`) go on the joined picture when this file IS the cut --
+ * nothing is laid over it after; a base for an overlay pass leaves them to it.
*/
-export function hardCutFilterArgs(segments, joins, render, outPath) {
+export function hardCutFilterArgs(segments, joins, render, outPath, dips = null) {
const parts = [];
const pairs = segments.map((_, i) => {
const c = joinInputChain(i, joins?.[i] ?? null, render);
@@ -2651,11 +2749,13 @@ export function hardCutFilterArgs(segments, joins, render, outPath) {
return `${c.v}${c.a}`;
});
parts.push(`${pairs.join("")}concat=n=${segments.length}:v=1:a=1[vc][ac]`);
+ const dp = dipParts("[vc]", dips, render.fps);
+ parts.push(...dp.parts);
return [
"-nostdin", "-v", "error", "-y",
...segments.flatMap((s) => ["-i", s]),
"-filter_complex", parts.join(";"),
- "-map", "[vc]", "-map", "[ac]",
+ "-map", dp.label, "-map", "[ac]",
...encodeArgs(render),
outPath,
];
@@ -2666,10 +2766,19 @@ export function hardCutFilterArgs(segments, joins, render, outPath) {
// stays available for quick iteration. `joins` (the deck's holds and moves,
// `segmentJoins`) go on their inputs before the join; null leaves the graph
// exactly as it was.
-async function concatWithXfade(segments, render, outPath, railPlan, chrome = null, joins = null) {
+async function concatWithXfade(segments, render, outPath, railPlan, chrome = null, joins = null, { dip = true } = {}) {
const D = render.transition ?? 0.5;
const { durs } = await cutOffsets(segments, D, render.fps, joins);
+ await execFileP(FFMPEG, xfadeConcatArgs({ segments, durs, render, outPath, railPlan, chrome, joins, dip }), { maxBuffer: 1 << 26 });
+}
+/**
+ * concatWithXfade's ffmpeg argv, from the cut's segment lengths (`durs`,
+ * `cutOffsets`'): the crossfades, the chrome, the rail, then the dips over all
+ * of it. Pure, so a test can run the very graph the build does.
+ */
+export function xfadeConcatArgs({ segments, durs, render, outPath, railPlan = null, chrome = null, joins = null, dip = true }) {
+ const D = render.transition ?? 0.5;
const inputs = segments.flatMap((s) => ["-i", s]);
const { parts, vlab, alab } = xfadeGraph(durs, D, joins, render);
@@ -2699,22 +2808,22 @@ async function concatWithXfade(segments, render, outPath, railPlan, chrome = nul
if (hf) parts.push(hf.chain);
if (rc) parts.push(rc.chain);
- const tail = rc ? rc.outLabel : hf ? hf.outLabel : vlab;
+ // The dips last, over everything drawn: the whole frame goes to black.
+ // (`dip: false` -- a base the rail is laid over later, which dips then.)
+ const dp = dipParts(rc ? rc.outLabel : hf ? hf.outLabel : vlab, dip ? dipWindows(joins, durs, D, render.fps) : null, render.fps);
+ parts.push(...dp.parts);
+ const tail = dp.label;
- await execFileP(
- FFMPEG,
- [
- "-nostdin", "-v", "error", "-y",
- ...inputs,
- ...(rc ? rc.inputs : []),
- ...(hf ? hf.inputs : []),
- "-filter_complex", parts.join(";"),
- "-map", tail, "-map", alab,
- ...encodeArgs(render),
- outPath,
- ],
- { maxBuffer: 1 << 26 },
- );
+ return [
+ "-nostdin", "-v", "error", "-y",
+ ...inputs,
+ ...(rc ? rc.inputs : []),
+ ...(hf ? hf.inputs : []),
+ "-filter_complex", parts.join(";"),
+ "-map", tail, "-map", alab,
+ ...encodeArgs(render),
+ outPath,
+ ];
}
/**
@@ -2725,7 +2834,7 @@ async function concatWithXfade(segments, render, outPath, railPlan, chrome = nul
* concatHardCut is `-c copy` and a stream-copy mux cannot host a filtergraph
* at all.
*/
-async function applyRail(inPath, outPath, render, railPlan, preview) {
+async function applyRail(inPath, outPath, render, railPlan, preview, dips = null) {
const rc = railFilterChain(
render.rail, railPlan.assets, railPlan.times, render,
preview ? "[base]" : "[0:v]", 1, railPlan.total + 2,
@@ -2740,8 +2849,11 @@ async function applyRail(inPath, outPath, render, railPlan, preview) {
parts.push(`[0:v]setpts=PTS+${preview.start.toFixed(3)}/TB[base]`);
}
parts.push(rc.chain);
- const tail = preview ? "[vshift]" : rc.outLabel;
- if (preview) parts.push(`${rc.outLabel}setpts=PTS-STARTPTS[vshift]`);
+ // The dips over the rail, in the cut's clock (a preview's is shifted back to it above).
+ const dp = dipParts(rc.outLabel, dips, render.fps);
+ parts.push(...dp.parts);
+ const tail = preview ? "[vshift]" : dp.label;
+ if (preview) parts.push(`${dp.label}setpts=PTS-STARTPTS[vshift]`);
await execFileP(
FFMPEG,
@@ -2780,15 +2892,17 @@ async function applyRail(inPath, outPath, render, railPlan, preview) {
* band keeps its refusal of `transition: 0`, so nothing that reaches this
* function draws one.
*/
-export function applyChromeArgs(inPath, outPath, render, chromePlan, preview = null) {
+export function applyChromeArgs(inPath, outPath, render, chromePlan, preview = null, dips = null) {
const hf = chromeOverlayChain(render, chromePlan.regions, "[0:v]", 1, { final: true });
+ // The dips over the overlay: the whole frame, the panel with the footage.
+ const dp = dipParts(hf.outLabel, dips, render.fps, { shift: preview ? Number(preview.start) : 0 });
return [
"-nostdin", "-v", "error", "-y",
...(preview ? ["-ss", String(preview.start), "-t", String(preview.dur)] : []),
"-i", inPath,
...hf.inputs,
- "-filter_complex", hf.chain,
- "-map", hf.outLabel, "-map", "0:a",
+ "-filter_complex", [hf.chain, ...dp.parts].join(";"),
+ "-map", dp.label, "-map", "0:a",
...encodeArgsVideoOnly(render),
outPath,
];
@@ -2800,8 +2914,8 @@ export function applyChromeArgs(inPath, outPath, render, chromePlan, preview = n
* applyChrome, whose point stands: the CONCAT cannot host a filtergraph, the
* pass after it always could.
*/
-async function applyChrome(inPath, outPath, render, chromePlan, preview = null) {
- await execFileP(FFMPEG, applyChromeArgs(inPath, outPath, render, chromePlan, preview), {
+async function applyChrome(inPath, outPath, render, chromePlan, preview = null, dips = null) {
+ await execFileP(FFMPEG, applyChromeArgs(inPath, outPath, render, chromePlan, preview, dips), {
maxBuffer: 1 << 26,
});
}
@@ -2855,12 +2969,15 @@ export function previewFromSegmentsArgs({ segments, durs, starts, D, at, dur, re
parts.push(`${alab}atrim=start=${S}:duration=${T},asetpts=PTS-STARTPTS[aw]`);
const hf = chromeOverlayChain(render, chromePlan.regions, "[vw]", segs.length, { final: true });
parts.push(hf.chain);
+ // The cut's dips (its whole joins and lengths), in the window's clock.
+ const dp = dipParts(hf.outLabel, dipWindows(joins, durs, D, render.fps), render.fps, { shift: Number(at) });
+ parts.push(...dp.parts);
return [
"-nostdin", "-v", "error", "-y",
...segs.flatMap((sg) => ["-i", sg]),
...hf.inputs,
"-filter_complex", parts.join(";"),
- "-map", hf.outLabel, "-map", "[aw]",
+ "-map", dp.label, "-map", "[aw]",
...encodeArgs(render),
outPath,
];
@@ -3129,13 +3246,14 @@ export const concatListText = (segments) =>
export function concatRecordText(segments, joins = null) {
const list = concatListText(segments);
if (!joins) return list;
- // `mute` and `fade` only when a join has them: a record made before they
- // existed, of a cut without them, still matches.
+ // `mute`, `fade` and `dip` only when a join has them: a record made before
+ // they existed, of a cut without them, still matches.
const lines = segments.flatMap((s, i) => (joins[i]
? [`# join ${i} ${JSON.stringify({
hold: joins[i].hold, move: joins[i].move,
...(joins[i].mute != null ? { mute: joins[i].mute } : {}),
...(joins[i].fade ? { fade: joins[i].fade } : {}),
+ ...(joins[i].dip ? { dip: joins[i].dip } : {}),
})}`]
: []));
return list + lines.join("\n") + "\n";
@@ -3146,9 +3264,9 @@ export function concatRecordText(segments, joins = null) {
* with them (the deck's holds and moves) the concat filter over each input's
* chain, one encode -- the copy cannot host a filtergraph.
*/
-async function concatHardCut(segments, outDir, outPath, { record = false, joins = null, render = null } = {}) {
+async function concatHardCut(segments, outDir, outPath, { record = false, joins = null, render = null, dips = null } = {}) {
if (joins) {
- await execFileP(FFMPEG, hardCutFilterArgs(segments, joins, render, outPath), { maxBuffer: 1 << 26 });
+ await execFileP(FFMPEG, hardCutFilterArgs(segments, joins, render, outPath, dips), { maxBuffer: 1 << 26 });
if (record) await writeFile(`${outPath}.segments`, concatRecordText(segments, joins), "utf8");
return;
}
@@ -3331,6 +3449,12 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
const failures = [];
const D = opts.noXfade || (render.transition ?? 0.5) === 0 ? 0 : render.transition ?? 0.5;
+ // Where the cut dips to black (a teaser's `dip`), in its frames: for the
+ // passes that lay the picture's last layer over an already joined base.
+ // (The crossfade's own encode finds them from its joins.)
+ const cutDips = async (segs, joins) => (joins?.some((j) => j?.dip)
+ ? dipWindows(joins, (await cutOffsets(segs, D, render.fps, joins)).durs, D, render.fps)
+ : null);
// Retro-fit chapters onto an already-built file without re-encoding it. The
// per-clip segments are still on disk, which is all the offsets need.
@@ -3358,11 +3482,12 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
if (!(await exists(seg)))
throw new Error(`--rail-only needs ${seg}, which is missing — run a full build first`);
}
+ const joins = await cutJoins({ entries, segments: segs, render });
if (!(await exists(prerail))) {
EMIT("concat", { mode: D === 0 ? "hardcut" : "xfade", n: segs.length });
- const joins = await cutJoins({ entries, segments: segs, render });
+ // A base for the rail: its dips are laid with the rail, over it.
if (D === 0) await concatHardCut(segs, outDir, prerail, { joins, render });
- else await concatWithXfade(segs, render, prerail, null, null, joins);
+ else await concatWithXfade(segs, render, prerail, null, null, joins, { dip: false });
}
const railPlan = await buildRailPlan(manifest, render, entries, segs, D, outDir);
await assertConcatLength(prerail, railPlan.total, render.fps,
@@ -3370,7 +3495,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
const out = opts.preview
? path.join(outDir, `${manifest.slug}.preview.mp4`)
: finalPath;
- await applyRail(prerail, out, render, railPlan, opts.preview ?? null);
+ await applyRail(prerail, out, render, railPlan, opts.preview ?? null, await cutDips(segs, joins));
if (!opts.preview) {
await assertConcatLength(out, railPlan.total, render.fps, "rail build");
// applyRail re-encodes, so the chapters muxed onto the previous final are
@@ -3398,7 +3523,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
for (const e of entries) {
if (e.type !== "teaser") continue;
EMIT("card", { id: e.id, i: entries.indexOf(e), n: entries.length });
- await buildTeaserSegment(e, { manifestPath, render, outDir, variant });
+ await buildTeaserSegment(e, { manifestPath, render, outDir, variant, transition: D });
}
const segs = entries.map((e) => path.join(outDir, "segments", `${e.id}.mp4`));
for (const seg of segs) {
@@ -3428,7 +3553,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
const out = path.join(outDir, `${manifest.slug}.preview.mp4`);
if (await freshConcat(prerail, segs, schedule.total, render.fps, joins)) {
EMIT("chrome", { phase: "overlay", base: path.basename(prerail) });
- await applyChrome(prerail, out, render, plan, { start: at, dur });
+ await applyChrome(prerail, out, render, plan, { start: at, dur }, await cutDips(segs, joins));
} else {
const { starts, durs } = await cutOffsets(segs, D, render.fps, joins);
EMIT("chrome", { phase: "overlay", base: "segments" });
@@ -3453,7 +3578,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
await concatHardCut(segs, outDir, prerail, { record: true, joins, render });
}
await assertConcatLength(prerail, schedule.total, render.fps, "hard-cut concat");
- await applyChrome(prerail, dirs.final, render, plan, null);
+ await applyChrome(prerail, dirs.final, render, plan, null, await cutDips(segs, joins));
} else {
await concatWithXfade(segs, render, dirs.final, null, plan, joins);
}
@@ -3473,7 +3598,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
segments.push(await buildCardSegment(entry, render, outDir, manifest.timelineNodes, framing));
} else if (entry.type === "teaser") {
EMIT("card", { id: entry.id, i, n: entries.length });
- segments.push(await buildTeaserSegment(entry, { manifestPath, render, outDir, variant }));
+ segments.push(await buildTeaserSegment(entry, { manifestPath, render, outDir, variant, transition: D }));
} else if (entry.type === "image") {
// `card`, not a new event name: umtool's activity feed and build chain
// key off this one to mean "a segment that needs no network", and a
@@ -3593,16 +3718,16 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
if (railPlan) {
await concatHardCut(segments, outDir, prerail, { joins, render });
await assertConcatLength(prerail, railPlan.total, render.fps, "hard-cut concat");
- await applyRail(prerail, final, render, railPlan, null);
+ await applyRail(prerail, final, render, railPlan, null, await cutDips(segments, joins));
} else if (chromePlan) {
// Only the deck reaches here (the band refused above, and the deck
// refuses a rail). Hard-cut concat to the prerail, then ONE overlay
// re-encode to the final.
await concatHardCut(segments, outDir, prerail, { record: true, joins, render });
await assertConcatLength(prerail, schedule.total, render.fps, "hard-cut concat");
- await applyChrome(prerail, final, render, chromePlan, null);
+ await applyChrome(prerail, final, render, chromePlan, null, await cutDips(segments, joins));
} else {
- await concatHardCut(segments, outDir, final, { joins, render });
+ await concatHardCut(segments, outDir, final, { joins, render, dips: await cutDips(segments, joins) });
}
} else {
await concatWithXfade(segments, render, final, railPlan, chromePlan, joins);