feat(marketing): add captioned product walkthrough video
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
/** Render the local, silent product tour from real app captures and SVG titles. */
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const storyboard = JSON.parse(await fs.readFile(path.join(root, 'scripts/product-video/storyboard.json'), 'utf8'));
|
||||
const out = path.join(root, 'public/landing/video');
|
||||
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'kita-product-video-'));
|
||||
await fs.mkdir(out, { recursive: true });
|
||||
const { width: W, height: H, fps } = storyboard;
|
||||
const colors = { paper: '#f7f4ed', ink: '#302820', brown: '#7b492e', green: '#3f745a', muted: '#696358' };
|
||||
const esc = text => String(text).replace(/[<>&"']/g, c => ({ '<':'<', '>':'>', '&':'&', '"':'"', "'":''' }[c]));
|
||||
const text = (x,y,value,size=30,color=colors.ink,weight=400) => `<text x="${x}" y="${y}" font-family="Avenir Next, sans-serif" font-size="${size}" font-weight="${weight}" fill="${color}">${esc(value)}</text>`;
|
||||
const lines = (x,y,values,size=30,step=44,color=colors.ink,weight=400) => values.map((v,i)=>text(x,y+i*step,v,size,color,weight)).join('');
|
||||
const imageStage = { x:600, y:162, width:1240, height:714 };
|
||||
const total = storyboard.scenes.reduce((sum,scene)=>sum+scene.duration,0);
|
||||
const timestamp = seconds => `${String(Math.floor(seconds/3600)).padStart(2,'0')}:${String(Math.floor(seconds/60)%60).padStart(2,'0')}:${String(seconds%60).padStart(2,'0')}.000`;
|
||||
let time = 0;
|
||||
const cues = [];
|
||||
const assets = [];
|
||||
const webAssets = [];
|
||||
|
||||
function ffmpeg(args) {
|
||||
const result = spawnSync('ffmpeg', ['-hide_banner','-loglevel','error','-y',...args], { encoding:'utf8', maxBuffer:8*1024*1024 });
|
||||
if (result.status !== 0) throw new Error(result.stderr || 'ffmpeg failed');
|
||||
}
|
||||
|
||||
try {
|
||||
for (const [index,scene] of storyboard.scenes.entries()) {
|
||||
const [left,top,width,height] = scene.crop;
|
||||
const shot = await sharp(path.join(root, 'public/landing/screenshots',scene.image))
|
||||
.extract({left,top,width,height}).resize(imageStage.width,imageStage.height,{fit:'contain',background:colors.paper}).png().toBuffer();
|
||||
const scale = Math.min(imageStage.width/width,imageStage.height/height);
|
||||
const offsetX = imageStage.x+(imageStage.width-width*scale)/2;
|
||||
const offsetY = imageStage.y+(imageStage.height-height*scale)/2;
|
||||
let focus = '';
|
||||
if (scene.focus) {
|
||||
const [fx,fy,fw,fh] = scene.focus;
|
||||
focus = `<rect x="${offsetX+(fx-left)*scale}" y="${offsetY+(fy-top)*scale}" width="${fw*scale}" height="${fh*scale}" rx="12" fill="none" stroke="${colors.green}" stroke-width="5"/><circle cx="${offsetX+(fx-left)*scale+12}" cy="${offsetY+(fy-top)*scale-10}" r="18" fill="${colors.green}"/>`;
|
||||
}
|
||||
const chapter = storyboard.chapters[scene.chapter];
|
||||
const title = lines(74,290,scene.title,56,72,colors.ink,600);
|
||||
const hintWords = scene.hint.split(' '); const hintLines = []; let line = '';
|
||||
for (const word of hintWords) { if ((line+' '+word).trim().length > 28) { hintLines.push(line); line=word; } else line=(line+' '+word).trim(); } if(line)hintLines.push(line);
|
||||
const header = `<rect width="${W}" height="${H}" fill="${colors.paper}"/><rect x="72" y="44" width="54" height="54" rx="16" fill="${colors.brown}"/>${text(88,82,'K',30,'white',600)}${text(144,80,'Kita-Planer',32,colors.ink,600)}${text(144,111,'Ein Rundgang durch die Anwendung',19,colors.muted)}${text(1490,80,`${String(scene.chapter+1).padStart(2,'0')} / 08`,24,colors.green,600)}${text(1490,111,'App-Ansichten mit Beispieldaten',18,colors.muted)}<line x1="72" y1="132" x2="1840" y2="132" stroke="#dcd6cb"/>${text(74,208,scene.chapter===0?'WILLKOMMEN':scene.chapter===7?'GEMEINSAM STARTEN':`BEREICH ${String(scene.chapter).padStart(2,'0')}`,20,colors.green,600)}${title}<rect x="74" y="466" width="4" height="${Math.max(90,hintLines.length*40)}" rx="2" fill="${colors.green}"/>${lines(96,498,hintLines,28,40,colors.muted)}${text(74,775,'Für Eltern, Vorstand',24,colors.muted)}${text(74,811,'und das Kita-Team.',24,colors.muted)}<rect x="598" y="160" width="1244" height="718" rx="16" fill="white" stroke="#d9d3c7" stroke-width="2"/>`;
|
||||
const footer = `<rect x="0" y="920" width="1920" height="160" fill="#ebe7dc"/><rect x="72" y="923" width="1768" height="4" rx="2" fill="#d8d1c2"/><rect x="72" y="923" width="${1768*(time+scene.duration)/total}" height="4" rx="2" fill="${colors.green}"/>${text(74,899,chapter.title,20,colors.muted)}${text(1470,899,'mein-kitaplaner.de',20,colors.brown,600)}`;
|
||||
const base = await sharp(Buffer.from(`<svg width="${W}" height="${H}">${header}${footer}</svg>`)).png().toBuffer();
|
||||
const captions = `<rect x="210" y="951" width="1500" height="100" rx="18" fill="#302820"/>${scene.subtitle.map((value,i)=>`<text x="960" y="${989+i*42}" text-anchor="middle" font-family="Avenir Next, sans-serif" font-size="34" font-weight="500" fill="#ffffff">${esc(value)}</text>`).join('')}`;
|
||||
const foreground = Buffer.from(`<svg width="${W}" height="${H}">${focus}${captions}</svg>`);
|
||||
const cleanFrame = await sharp(base).composite([{input:shot,left:imageStage.x,top:imageStage.y},{input:Buffer.from(`<svg width="${W}" height="${H}">${focus}</svg>`),left:0,top:0}]).png().toBuffer();
|
||||
const cleanPath = path.join(temp,`web-frame-${index}.png`);
|
||||
await fs.writeFile(cleanPath,cleanFrame);
|
||||
const frame = await sharp(base).composite([{input:shot,left:imageStage.x,top:imageStage.y},{input:foreground,left:0,top:0}]).png().toBuffer();
|
||||
const framePath = path.join(temp,`frame-${index}.png`);
|
||||
await fs.writeFile(framePath,frame);
|
||||
if(index===0) await sharp(frame).jpeg({quality:88}).toFile(path.join(out,'kita-planer-poster.jpg'));
|
||||
const clip = path.join(temp,`clip-${index}.mp4`);
|
||||
// Gentle transitions leave the UI and all captions still and legible.
|
||||
ffmpeg(['-loop','1','-framerate',String(fps),'-i',framePath,'-t',String(scene.duration),'-vf',`fade=t=in:st=0:d=0.25:color=0xf7f4ed,fade=t=out:st=${scene.duration-0.25}:d=0.25:color=0xf7f4ed,format=yuv420p`,'-c:v','libx264','-preset','fast','-crf','20','-threads','2','-an',clip]);
|
||||
assets.push(clip);
|
||||
const webClip = path.join(temp,`web-clip-${index}.mp4`);
|
||||
ffmpeg(['-loop','1','-framerate',String(fps),'-i',cleanPath,'-t',String(scene.duration),'-vf',`fade=t=in:st=0:d=0.25:color=0xf7f4ed,fade=t=out:st=${scene.duration-0.25}:d=0.25:color=0xf7f4ed,format=yuv420p`,'-c:v','libx264','-preset','fast','-crf','20','-threads','2','-an',webClip]);
|
||||
webAssets.push(webClip);
|
||||
cues.push(`${index+1}\n${timestamp(time)} --> ${timestamp(time+scene.duration)}\n${scene.subtitle.join('\n')}`);
|
||||
time+=scene.duration;
|
||||
console.log(`Rendered scene ${index+1}/${storyboard.scenes.length}: ${chapter.title}`);
|
||||
}
|
||||
await fs.writeFile(path.join(temp,'concat.txt'),assets.map(p=>`file '${p}'`).join('\n'));
|
||||
ffmpeg(['-f','concat','-safe','0','-i',path.join(temp,'concat.txt'),'-c','copy','-movflags','+faststart','-metadata','title=Kita-Planer – Ein Rundgang durch die Anwendung','-metadata','comment=Echte App-Ansichten mit fiktiven Beispieldaten. Deutsche Untertitel und visuelle Hinweise. Ohne Ton.',path.join(out,'kita-planer-rundgang.mp4')]);
|
||||
await fs.writeFile(path.join(temp,'web-concat.txt'),webAssets.map(p=>`file '${p}'`).join('\n'));
|
||||
ffmpeg(['-f','concat','-safe','0','-i',path.join(temp,'web-concat.txt'),'-c','copy','-movflags','+faststart',path.join(out,'kita-planer-rundgang-web.mp4')]);
|
||||
await fs.writeFile(path.join(out,'kita-planer-untertitel.vtt'),`WEBVTT\n\n${cues.join('\n\n')}\n`);
|
||||
await fs.writeFile(path.join(out,'kita-planer-kapitel.vtt'),`WEBVTT\n\n${storyboard.chapters.map((chapter,index)=>`${index+1}\n${timestamp(chapter.start)} --> ${timestamp(storyboard.chapters[index+1]?.start??total)}\n${chapter.title}`).join('\n\n')}\n`);
|
||||
await fs.writeFile(path.join(out,'kita-planer-transkript.txt'),`Kita-Planer – Ein Rundgang durch die Anwendung\nDauer: ${total} Sekunden. Ohne Ton. App-Ansichten mit fiktiven Beispieldaten.\n\n${storyboard.scenes.map(scene=>`${storyboard.chapters[scene.chapter].title}\n${scene.subtitle.join(' ')}`).join('\n\n')}\n`);
|
||||
console.log(`Finished ${total}s video in ${out}`);
|
||||
} finally {
|
||||
await fs.rm(temp,{recursive:true,force:true});
|
||||
}
|
||||
Reference in New Issue
Block a user