209 lines
6.3 KiB
JavaScript
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);
|