storytellurx/scripts/product-demo/render.mjs
2026-10-06 12:18:03 +02:00

209 lines
6.3 KiB
JavaScript

import { bundle } from "@remotion/bundler";
import { renderMedia, renderStill, selectComposition } from "@remotion/renderer";
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { execFile, execFileSync } from "node:child_process";
import path from "node:path";
import { promisify } from "node:util";
import { z } from "zod";
const directory = path.resolve("reports/viewport-demo");
const capture = z
.object({
video: z.string(),
started: z.number(),
finished: z.number(),
videoStarted: z.number(),
fps: z.number().positive().default(30),
clips: z.array(
z.object({
id: z.string(),
caption: z.string(),
startMs: z.number(),
endMs: z.number(),
persona: z.string().optional(),
}),
),
})
.parse(JSON.parse(await readFile(path.join(directory, "capture.json"), "utf8")));
const assets = path.join(directory, "assets");
await mkdir(assets, { recursive: true });
await copyFile(capture.video, path.join(assets, "viewport.mp4"));
await copyFile(
"public/assets/fonts/Arizona/ABCArizonaSansVariable.woff2",
path.join(assets, "ArizonaSans.woff2"),
);
const duration = Number(
execFileSync(
"ffprobe",
[
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
capture.video,
],
{ encoding: "utf8" },
).trim(),
);
// Both clocks use epoch milliseconds; never infer an offset from the encoded duration.
const offset = (capture.started - capture.videoStarted) / 1000;
const fps = capture.fps;
const clockErrorMs = Math.abs(duration * 1000 - (capture.finished - capture.videoStarted));
if (clockErrorMs > (1000 / fps) * 2) {
throw new Error(`Capture clock drifted by ${clockErrorMs.toFixed(1)} ms`);
}
const clips = [
...capture.clips.filter((clip) => !clip.persona),
...capture.clips.filter((clip) => clip.persona),
];
const props = {
source: "viewport.mp4",
plain: false,
fps,
background: process.env["DEMO_BACKGROUND"] ?? "#000000",
captionColor: process.env["DEMO_CAPTION_COLOR"] ?? "#ffffff",
clips: clips.map((clip) => {
return {
id: clip.id,
caption: clip.caption,
startFrame: Math.round((clip.startMs / 1000 + offset) * fps),
frames: Math.max(1, Math.round(((clip.endMs - clip.startMs) / 1000) * fps)),
};
}),
};
const landing = props.clips.find((clip) => clip.id === "start");
if (!landing) throw new Error("Missing landing screen for the opening shot");
// Reuse moving footage after the credits panel has closed; never freeze the first frame.
const opening = {
id: "opening",
caption: "",
startFrame: landing.startFrame + Math.round(0.35 * fps),
frames: Math.min(Math.round(2 * fps), landing.frames - Math.round(0.35 * fps)),
};
const inputWithOpening = { ...props, opening };
await writeFile(
path.join(directory, "render-props.json"),
JSON.stringify(inputWithOpening, null, 2),
);
await writeFile(
path.join(directory, "timing.json"),
JSON.stringify(
{
rawDuration: duration,
offset,
clockErrorMs,
frames: props.clips.reduce((sum, clip) => sum + clip.frames, 0) + opening.frames,
plainFrames: props.clips.reduce((sum, clip) => sum + clip.frames, 0),
openingFrames: opening.frames,
fps,
},
null,
2,
),
);
const serveUrl = await bundle({
entryPoint: path.resolve("scripts/product-demo/composition.tsx"),
publicDir: assets,
});
const variants = process.argv.includes("--plain-only")
? [true]
: process.argv.includes("--commented-only")
? [false]
: [false, true];
for (const plain of variants) {
if (plain) {
// The phone-only edit needs frame-accurate cuts, without browser compositing.
const graph = props.clips.map(
(clip, index) =>
`[0:v]trim=start_frame=${clip.startFrame}:end_frame=${clip.startFrame + clip.frames},setpts=PTS-STARTPTS[v${index}]`,
);
graph.push(
`${props.clips.map((_, index) => `[v${index}]`).join("")}concat=n=${props.clips.length}:v=1:a=0[out]`,
);
await promisify(execFile)("ffmpeg", [
"-hide_banner",
"-loglevel",
"error",
"-i",
capture.video,
"-filter_complex",
graph.join(";\n"),
"-map",
"[out]",
"-an",
"-r",
String(fps),
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"18",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
"-y",
path.join(directory, "storytellurx-plain-viewport.mp4"),
]);
console.log("PlainViewport 100%");
continue;
}
const inputProps = { ...inputWithOpening, plain };
const id = "CommentedDemo";
const composition = await selectComposition({ serveUrl, id, inputProps });
{
const previews = path.join(directory, "phase-preview");
await mkdir(previews, { recursive: true });
for (const [name, seconds] of [
["opening-centered", 0],
["opening-moving", 1],
["opening-right", 1.7],
]) {
await renderStill({
composition,
serveUrl,
inputProps,
frame: Math.round(Number(seconds) * fps),
output: path.join(previews, `${name}.png`),
});
}
for (const clipId of ["tour-4", "suggestion", "image-reveal", "rewrite-cut", "rewrite"]) {
const index = inputProps.clips.findIndex((clip) => clip.id === clipId);
const clip = inputProps.clips[index];
if (!clip) throw new Error(`Missing preview moment: ${clipId}`);
const frame =
opening.frames +
inputProps.clips.slice(0, index).reduce((sum, item) => sum + item.frames, 0) +
Math.floor(clip.frames * (["tour-4", "suggestion"].includes(clipId) ? 0.25 : 0.5));
await renderStill({
composition,
serveUrl,
inputProps,
frame,
output: path.join(previews, `${clipId}.png`),
});
}
}
let lastProgress = -1;
await renderMedia({
composition,
serveUrl,
codec: "h264",
outputLocation: path.join(directory, "storytellurx-commented-demo.mp4"),
inputProps,
concurrency: 4,
x264Preset: "fast",
crf: 18,
onProgress: ({ progress }) => {
const step = Math.floor(progress * 10);
if (step > lastProgress) {
console.log(`${id} ${step * 10}%`);
lastProgress = step;
}
},
});
}
console.log(directory);