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});
|
||||
}
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user