feat(marketing): add captioned product walkthrough video

This commit is contained in:
t.indorf
2026-10-14 10:52:31 +02:00
parent d4aa90422f
commit 0de3e9ae7e
15 changed files with 781 additions and 6 deletions
+82
View File
@@ -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 => ({ '<':'&lt;', '>':'&gt;', '&':'&amp;', '"':'&quot;', "'":'&apos;' }[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});
}
+445
View File
@@ -0,0 +1,445 @@
{
"width": 1920,
"height": 1080,
"fps": 25,
"chapters": [
{
"start": 0,
"title": "Willkommen im Kita-Planer"
},
{
"start": 10,
"title": "Notdienst gemeinsam planen"
},
{
"start": 32,
"title": "Termine und Feste im Blick"
},
{
"start": 52,
"title": "Freigegebene Kontakte"
},
{
"start": 66,
"title": "Abwesenheiten verwalten"
},
{
"start": 80,
"title": "Offizielle Mitteilungen"
},
{
"start": 94,
"title": "Arbeitszeit für das Team"
},
{
"start": 110,
"title": "Kita-Planer kennenlernen"
}
],
"scenes": [
{
"duration": 10,
"chapter": 0,
"title": [
"Mehr Ruhe im",
"Kita-Alltag."
],
"hint": "Für Elterninitiativen und Kita-Vereine",
"image": "notdienst.jpg",
"crop": [
0,
0,
1600,
1000
],
"subtitle": [
"Kita-Planer bringt Dienste, Termine und offizielle Informationen",
"an einen gemeinsamen Ort – für Eltern, Vorstand und Kita-Team."
]
},
{
"duration": 7,
"chapter": 1,
"title": [
"Wann könnt",
"ihr helfen?"
],
"hint": "Verfügbarkeiten gemeinsam eintragen",
"image": "notdienst.jpg",
"crop": [
310,
45,
1230,
870
],
"focus": [
325,
365,
394,
200
],
"subtitle": [
"Eltern markieren die Tage, an denen ihr Haushalt",
"für den Notdienst verfügbar ist."
]
},
{
"duration": 7,
"chapter": 1,
"title": [
"Kita-Termine",
"gleich sehen."
],
"hint": "Teamtage sind für die Auswahl gesperrt",
"image": "notdienst.jpg",
"crop": [
330,
170,
1200,
720
],
"focus": [
355,
670,
335,
105
],
"subtitle": [
"Feste, Elternabende und gesperrte Teamtage stehen direkt im Kalender.",
"So lässt sich die Auswahl mit dem Kita-Alltag abstimmen."
]
},
{
"duration": 8,
"chapter": 1,
"title": [
"Auswahl prüfen.",
"Dann speichern."
],
"hint": "Der Fortschritt bleibt sichtbar",
"image": "notdienst.jpg",
"crop": [
310,
45,
1230,
870
],
"focus": [
1050,
80,
450,
65
],
"subtitle": [
"Die Anzeige zeigt, ob genug Tage ausgewählt sind.",
"Mit Speichern wird die Verfügbarkeit für die Planung übernommen."
]
},
{
"duration": 9,
"chapter": 2,
"title": [
"Was steht",
"als Nächstes an?"
],
"hint": "Ein gemeinsamer Kalender",
"image": "kalender.jpg",
"crop": [
300,
35,
1240,
920
],
"focus": [
830,
660,
415,
155
],
"subtitle": [
"Im Monatskalender stehen Ausflüge, Elternabende, Feste und Schließtage.",
"Alle sehen die Termine im selben Zusammenhang."
]
},
{
"duration": 5,
"chapter": 2,
"title": [
"Zusagen pro",
"Familie."
],
"hint": "Anzahl der Erwachsenen angeben",
"image": "termin-details.jpg",
"crop": [
543,
335,
501,
370
],
"focus": [
561,
444,
470,
150
],
"subtitle": [
"Für dieses Herbstfest antwortet ihr pro Familie",
"und gebt an, wie viele Erwachsene teilnehmen."
]
},
{
"duration": 6,
"chapter": 2,
"title": [
"Wer bringt",
"was mit?"
],
"hint": "Eigene Beiträge bearbeiten oder löschen",
"image": "termin-details.jpg",
"crop": [
543,
690,
501,
318
],
"focus": [
575,
748,
440,
62
],
"subtitle": [
"In der Mitbringliste seht ihr, wer schon etwas beisteuert.",
"Eigene Beiträge könnt ihr bearbeiten oder löschen."
]
},
{
"duration": 7,
"chapter": 3,
"title": [
"Familien und",
"Kontakte finden."
],
"hint": "Haushalte und Kinder zusammen sehen",
"image": "adressbuch.jpg",
"crop": [
295,
35,
1220,
700
],
"focus": [
315,
325,
1080,
120
],
"subtitle": [
"Das interne Adressbuch verbindet Haushalte, Kinder",
"und die freigegebenen Kontakte der Eltern."
]
},
{
"duration": 7,
"chapter": 3,
"title": [
"Freigabe bleibt",
"eine Entscheidung."
],
"hint": "Kontaktdaten erscheinen nach Opt-in",
"image": "adressbuch.jpg",
"crop": [
580,
290,
900,
310
],
"focus": [
638,
330,
420,
95
],
"subtitle": [
"Jeder Elternteil entscheidet selbst über die Freigabe seiner Kontaktdaten.",
"Im Adressbuch erscheinen nur ausdrücklich freigegebene Angaben."
]
},
{
"duration": 7,
"chapter": 4,
"title": [
"Kind abmelden.",
"Kita informieren."
],
"hint": "Krankheit, Urlaub und andere Gründe",
"image": "abwesenheiten.jpg",
"crop": [
395,
25,
1060,
640
],
"focus": [
420,
320,
1010,
130
],
"subtitle": [
"Eltern melden Abwesenheiten digital.",
"Die Kita sieht die Meldung in ihrer Übersicht."
]
},
{
"duration": 7,
"chapter": 4,
"title": [
"Pläne dürfen",
"sich ändern."
],
"hint": "Zukünftige Meldungen korrigieren",
"image": "abwesenheiten.jpg",
"crop": [
415,
305,
1020,
340
],
"focus": [
446,
565,
320,
45
],
"subtitle": [
"Zukünftige Abwesenheiten können Eltern bearbeiten oder zurücknehmen.",
"Sobald eine Meldung begonnen hat, übernimmt die Kita die Änderung."
]
},
{
"duration": 7,
"chapter": 5,
"title": [
"Offizielle Infos",
"bleiben auffindbar."
],
"hint": "Das digitale Schwarze Brett",
"image": "mitteilungen.jpg",
"crop": [
410,
275,
780,
450
],
"focus": [
484,
367,
630,
80
],
"subtitle": [
"Mitteilungen bündeln offizielle Informationen an einem Ort.",
"Formatierter Text und der Aktualisierungshinweis machen Änderungen sichtbar."
]
},
{
"duration": 7,
"chapter": 5,
"title": [
"Auch nach einer",
"Korrektur klar."
],
"hint": "Geschützte Anhänge und optionale E-Mails",
"image": "mitteilungen.jpg",
"crop": [
450,
330,
700,
335
],
"focus": [
485,
580,
625,
53
],
"subtitle": [
"Kita-Admins können Beiträge korrigieren; Anhänge bleiben geschützt abrufbar.",
"Beim optionalen E-Mail-Versand bleibt der Status je Empfänger nachvollziehbar."
]
},
{
"duration": 8,
"chapter": 6,
"title": [
"Arbeitszeit für",
"das Kita-Team."
],
"hint": "Stunden erfassen und Wochen einreichen",
"image": "personal.jpg",
"crop": [
315,
445,
1210,
780
],
"focus": [
345,
1105,
780,
105
],
"subtitle": [
"Fachkräfte erfassen ihre Arbeitszeit mit Pausen",
"und reichen vollständige Wochen zur Prüfung ein."
]
},
{
"duration": 8,
"chapter": 6,
"title": [
"Zeitkonto und",
"Urlaub im Blick."
],
"hint": "Bestätigte Zeit von vorläufiger unterscheiden",
"image": "personal.jpg",
"crop": [
308,
235,
1210,
200
],
"focus": [
315,
275,
1185,
135
],
"subtitle": [
"Das bestätigte Zeitkonto und der verfügbare Urlaub bleiben sichtbar.",
"Urlaub und Freizeitausgleich lassen sich im Personalbereich beantragen."
]
},
{
"duration": 10,
"chapter": 7,
"title": [
"Gemeinsam",
"besser planen."
],
"hint": "Startet mit den Abläufen, die euch entlasten",
"image": "kalender.jpg",
"crop": [
300,
35,
1240,
920
],
"subtitle": [
"Lernt Kita-Planer in Ruhe kennen und entdeckt die passenden Funktionen.",
"Alle gezeigten App-Ansichten verwenden ausschließlich Beispieldaten."
]
}
]
}