/** 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) => `${esc(value)}`; 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 = ``; } 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 = `${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)}${text(74,208,scene.chapter===0?'WILLKOMMEN':scene.chapter===7?'GEMEINSAM STARTEN':`BEREICH ${String(scene.chapter).padStart(2,'0')}`,20,colors.green,600)}${title}${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)}`; const footer = `${text(74,899,chapter.title,20,colors.muted)}${text(1470,899,'mein-kitaplaner.de',20,colors.brown,600)}`; const base = await sharp(Buffer.from(`${header}${footer}`)).png().toBuffer(); const captions = `${scene.subtitle.map((value,i)=>`${esc(value)}`).join('')}`; const foreground = Buffer.from(`${focus}${captions}`); const cleanFrame = await sharp(base).composite([{input:shot,left:imageStage.x,top:imageStage.y},{input:Buffer.from(`${focus}`),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}); }