148 lines
6.2 KiB
TypeScript
148 lines
6.2 KiB
TypeScript
import { demoScenes, demoDesign } from "./scenes.ts";
|
||
import { z } from "zod";
|
||
import { chromium } from "@playwright/test";
|
||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||
import { spawnSync } from "node:child_process";
|
||
import path from "node:path";
|
||
|
||
const root = path.resolve("reports/image-reveal-demo");
|
||
const scenes = await Promise.all(
|
||
demoScenes.map(
|
||
async (scene) =>
|
||
[
|
||
scene.id,
|
||
scene.label,
|
||
demoDesign(scene.id).title[0],
|
||
z
|
||
.object({ ink: z.string() })
|
||
.parse(JSON.parse(await readFile(`${root}/${scene.id}/product-timing.json`, "utf8"))).ink,
|
||
scene.description,
|
||
] as const,
|
||
),
|
||
);
|
||
const browser = await chromium.launch();
|
||
const page = await browser.newPage({
|
||
viewport: { width: 1440, height: 900 },
|
||
deviceScaleFactor: 1,
|
||
});
|
||
const font = (await readFile("public/assets/fonts/Arizona/ABCArizonaPlusVariable.woff2")).toString(
|
||
"base64",
|
||
);
|
||
const mono = (await readFile("public/assets/fonts/IBMPlexMono/IBMPlexMono-Regular.woff2")).toString(
|
||
"base64",
|
||
);
|
||
const css = `@font-face{font-family:Arizona;src:url(data:font/woff2;base64,${font})}@font-face{font-family:Mono;src:url(data:font/woff2;base64,${mono})}*{box-sizing:border-box}body{margin:0;width:1440px;height:900px;background:#121019;color:#f8f1e8;font-family:Arizona;overflow:hidden}main{position:absolute;inset:0;padding:70px 80px;background:radial-gradient(ellipse at 80% 42%,color-mix(in srgb,var(--ink) 14%,transparent),transparent 58%)}header{font:14px Mono;letter-spacing:.1em;display:flex;justify-content:space-between;color:#bbb3c6}.eyebrow{font:13px Mono;letter-spacing:.18em;color:var(--ink)}h1{font-size:85px;line-height:.98;font-weight:380;margin:28px 0;max-width:760px;letter-spacing:-3px}h2{font-size:50px;font-weight:400;margin:20px 0}p{font-size:25px;line-height:1.4;color:#bdb6c7;max-width:660px}footer{position:absolute;bottom:64px;left:80px;right:80px;font:11px Mono;color:#aaa2b7;display:flex;justify-content:space-between}hr{border:0;border-top:1px solid #ffffff24;margin:28px 0;width:620px}.emblem{width:100px;height:100px;margin-bottom:30px;filter:drop-shadow(0 0 25px var(--ink))}.badge{font:12px Mono;border:1px solid #ffffff35;border-radius:30px;padding:12px 18px;display:inline-block;color:var(--ink)}.steps{display:flex;gap:22px;font:12px Mono;color:#c2bacc;margin-top:40px}.steps b{color:var(--ink)}.dots{display:flex;gap:8px;margin-top:35px}.dots i{width:34px;height:3px;background:#ffffff26}.dots i.active{background:var(--ink)}`;
|
||
await mkdir(`${root}/film`, { recursive: true });
|
||
async function background(name: string, content: string, ink = "#d5adff") {
|
||
await page.setContent(
|
||
`<style>${css}</style><main style="--ink:${ink}"><header><span>STORYTELLURX / 2.0</span><span>HUIT MONDES · HUIT VISIONS</span></header>${content}<footer><span>REV ANIMATED · IMAGES GÉNÉRÉES</span><span>DÉMONSTRATION SCÉNARISÉE · CAPTURE DE L’APPLICATION</span></footer></main>`,
|
||
);
|
||
await page.evaluate(() => document.fonts.ready);
|
||
await page.screenshot({ path: `${root}/film/${name}.png` });
|
||
}
|
||
function ffmpeg(args: string[]) {
|
||
const run = spawnSync("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", ...args], {
|
||
stdio: "inherit",
|
||
});
|
||
if (run.status !== 0) throw new Error("ffmpeg failed");
|
||
}
|
||
await background(
|
||
"intro",
|
||
'<div style="margin-top:135px"><div class="eyebrow">DES RÉCITS QUI PRENNENT FORME</div><h1>Et soudain,<br>une vision.</h1><p>Choisissez un monde. Entrez dans le récit.<br>Découvrez l’image comme un objet précieux.</p><div class="steps"><b>01</b> CHOISIR <b>02</b> EXPLORER <b>03</b> DÉCOUVRIR</div></div>',
|
||
);
|
||
ffmpeg([
|
||
"-loop",
|
||
"1",
|
||
"-i",
|
||
`${root}/film/intro.png`,
|
||
"-t",
|
||
"4",
|
||
"-vf",
|
||
"fade=t=in:d=0.4,fade=t=out:st=3.6:d=0.4",
|
||
"-r",
|
||
"30",
|
||
"-c:v",
|
||
"libx264",
|
||
"-pix_fmt",
|
||
"yuv420p",
|
||
`${root}/film/00-intro.mp4`,
|
||
]);
|
||
const files = [`${root}/film/00-intro.mp4`];
|
||
for (const [index, [id, label, title, ink, description]] of scenes.entries()) {
|
||
const timing = z
|
||
.object({ summon: z.number(), video: z.string() })
|
||
.parse(JSON.parse(await readFile(`${root}/${id}/product-timing.json`, "utf8")));
|
||
await background(
|
||
id,
|
||
`<div style="margin-top:105px"><div class="eyebrow">MONDE ${String(index + 1).padStart(2, "0")} / ${label}</div><h1 style="font-size:70px;max-width:710px">${title}</h1><p>${description}</p><hr><span class="badge">UNE IMAGE COMME UNE DÉCOUVERTE</span><div class="steps">APPARITION <b>→</b> RÉVÉLATION <b>→</b> RETOUR AU RÉCIT</div><div class="dots">${scenes.map((_, i) => `<i class="${i === index ? "active" : ""}"></i>`).join("")}</div></div>`,
|
||
ink,
|
||
);
|
||
const video = path.isAbsolute(timing.video) ? timing.video : path.resolve(timing.video);
|
||
const out = `${root}/film/${String(index + 1).padStart(2, "0")}-${id}.mp4`;
|
||
ffmpeg([
|
||
"-loop",
|
||
"1",
|
||
"-i",
|
||
`${root}/film/${id}.png`,
|
||
"-ss",
|
||
String(Math.max(0, timing.summon / 1000 - 1.1)),
|
||
"-i",
|
||
video,
|
||
"-filter_complex",
|
||
"[1:v]scale=432:720,setsar=1[app];[0:v][app]overlay=910:90:shortest=1,fade=t=in:d=0.3,fade=t=out:st=9.2:d=0.4[v]",
|
||
"-map",
|
||
"[v]",
|
||
"-t",
|
||
"9.6",
|
||
"-r",
|
||
"30",
|
||
"-an",
|
||
"-c:v",
|
||
"libx264",
|
||
"-crf",
|
||
"19",
|
||
"-pix_fmt",
|
||
"yuv420p",
|
||
out,
|
||
]);
|
||
files.push(out);
|
||
console.log(`${id}: film segment assembled`);
|
||
}
|
||
await background(
|
||
"outro",
|
||
'<div style="margin-top:155px"><div class="eyebrow">STORYTELLURX</div><h1>Votre prochain<br>monde vous attend.</h1><p>Huit personas. Huit matières.<br>Un même rituel de découverte.</p><div class="badge" style="margin-top:25px">DU CHOIX DU MONDE AU RÉCIT, DIRECTEMENT.</div></div>',
|
||
);
|
||
ffmpeg([
|
||
"-loop",
|
||
"1",
|
||
"-i",
|
||
`${root}/film/outro.png`,
|
||
"-t",
|
||
"4",
|
||
"-vf",
|
||
"fade=t=in:d=0.4,fade=t=out:st=3.6:d=0.4",
|
||
"-r",
|
||
"30",
|
||
"-c:v",
|
||
"libx264",
|
||
"-pix_fmt",
|
||
"yuv420p",
|
||
`${root}/film/09-outro.mp4`,
|
||
]);
|
||
files.push(`${root}/film/09-outro.mp4`);
|
||
await writeFile(`${root}/film/concat.txt`, files.map((file) => `file '${file}'`).join("\n"));
|
||
ffmpeg([
|
||
"-f",
|
||
"concat",
|
||
"-safe",
|
||
"0",
|
||
"-i",
|
||
`${root}/film/concat.txt`,
|
||
"-c",
|
||
"copy",
|
||
"-movflags",
|
||
"+faststart",
|
||
`${root}/storytellurx-product-demo.mp4`,
|
||
]);
|
||
await browser.close();
|
||
console.log("Product film complete");
|