html-to-video · Code Frame für Frame Video erstellen
Was ist das hier
Kein KI-Video – es ist Code, der Frame für Frame malt:
HTML/Canvas schreiben, die Szene als reine Funktion frame(t) definieren
→ Headless Browser sucht Frame für Frame t auf, macht Screenshots
→ ffmpeg kodiert zu H.264 MP4
Untertitel / Kamerafahrten / Übergänge alles im Code – Code ändern, neu rendern
Vorteile: Deterministisch (gleicher Input = gleicher Output), bis zum Frame kontrollierbar, perfekte chinesische Schriftzeichen, keine Kosten. Am besten geeignet für: Erklärungsanimationen, dynamische Diagramme, Produktdemos, Datenvisualisierungsanimationen, Werbevorspänne. Nicht geeignet: realistisch wirkende / atmosphärische Aufnahmen (dafür VideoGen).
Was man damit machen kann (11 Vorlagen in assets/templates/ – Text ändern, Video ausgeben):
| Vorlage | Verwendung | Ausgabe |
|---|---|---|
explainer.html | Multi-Shot-Erklärvideo: Intro / Person+Sprechblase / Punkte / Balkenvergleich / Outro, Shots mit Übergängen, füttern mit JSON-Shotliste; bei genau 3 Punkten im Querformat automatisch 3-spaltig, Hochformat (1080×1920) mit vertikaler Version | --duration=17.6 |
title-card.html | Video-Intro: Haupttitel erscheint Buchstabe für Buchstabe + Untertitel | --duration=6 |
bar-chart.html | Balkendiagramm/Ranking: Balken wachsen nacheinander, Zahlen rollen synchron | --duration=6 |
line-chart.html | Liniendiagramm-Trend: Linie wächst + Datenpunkte leuchten auf + letzter Punkt als Markierung | --duration=8 |
number-counter.html | Große Zahlen/KPI: Von 0 zum Zielwert rollen + Auf/Ab-Label | --duration=7 |
timeline.html | Zeitleiste/Ablauf: Mittelachse wächst, Knoten werden nacheinander aktiviert | --duration=7 |
vertical-card.html | Hochformat 9:16 Karte: Großer Titel + große Zahlen, für Douyin/Video Account/Reels | --duration=6 --width=1080 --height=1920 |
compare.html | Vorher-Nachher-Vergleich: Links/rechts zwei Spalten, Zusammenfassungszeile am Ende | --duration=7 |
flow-steps.html | Ablaufdiagramm-Animation: Schritte nacheinander aktiviert, aktueller Schritt hervorgehoben | --duration=7 |
subtitle-card.html | Satzweise Erklärungskarte: Ein Satz nach dem anderen, Fortschrittsbalken unten | --duration=8 + --srt |
vertical-subtitle.html | Hochformat-Untertitelkarte: Schlüsselwörter hervorgehoben, natives 9:16-Layout | --duration=8 --width=1080 --height=1920 + --srt |
Jede Vorlage ist eine einzige, in sich geschlossene Datei mit einem deutlich sichtbaren CFG-Konfigurationsblock oben – Text/Daten ändern reicht.
Man muss kein Canvas können, um Videos zu erstellen. Alle 11 Vorlagen unterstützen Themenwechsel (siehe Abschnitt „Farbschema wechseln“).
Will man etwas Eigenes, dann assets/animation-skeleton.html von Grund auf verwenden.
„Ein ganzes Video“ statt „einen Clip“ machen: assets/lib/scenes.js für mehrere Shots + Übergänge verwenden,
die Shotliste kann sogar mit --story=shots.json extern bereitgestellt werden – LLM schreibt JSON zuverlässiger als hunderte Zeilen Canvas-Code.
Mehr dazu im Abschnitt „Multi-Shot und Übergänge“.
Größen: Vorlagen folgen dem Viewport, kein manuelles Anpassen
Alle 11 Vorlagen setzen die Leinwand auf window.innerWidth/innerHeight, daher kein Beschneiden bei Größenänderung:
# Querformat 1080p (Standard)
bash assets/render.sh title-card.html --duration=6 --fps=60
# Hochformat 9:16
bash assets/render.sh title-card.html --duration=6 --fps=60 --width=1080 --height=1920
# 720p / 4K ebenso
bash assets/render.sh title-card.html --duration=6 --fps=60 --width=1280 --height=720
Intern verwenden die Vorlagen drei Skalierungsfaktoren, um das Design auf die tatsächliche Leinwand zu übertragen:
| Variable | Bedeutung | 1920×1080 | 1080×1920 |
|---|---|---|---|
SX = W/1920 | Horizontale Größen, linke/rechte Ränder | 1 | 0.5625 |
SY = H/1080 | Vertikale Größen, Zeilenabstände | 1 | 1.7778 |
SF = √(SX·SY) | Schriftgröße, Linienstärken, Eckenradius, Unschärfe | 1 | 1 |
Schriftgrößen verwenden flächengleiche Skalierung (SF) statt einfacher Breitenanpassung – im Hochformat werden die Buchstaben nicht kleiner, das Layout verteilt sich nur.
Bei 1920×1080 sind alle drei Faktoren 1, daher entspricht die Standardausgabe pixelgenau der alten Version (gemessener PSNR = inf).
Eigene Animationen sollten ebenfalls danach vorgehen, sonst werden bei --width/--height-Änderungen stumm die untere rechte Ecke abgeschnitten:
const cv = document.getElementById('stage');
cv.width = window.innerWidth; // ← nicht auf 1920 fest codieren
cv.height = window.innerHeight; // ← nicht auf 1080 fest codieren
Der Renderer misst jetzt aktiv die Leinwandgröße und gibt eine deutliche Warnung aus, wenn sie kleiner als der Viewport ist (kein stummes Ausgeben von halben Bildern mehr).
Zuerst Abhängigkeiten installieren (nur einmal)
Der Renderer benötigt einen Node-Arbeitsbereich (enthält Playwright + vollständiges ffmpeg). Ein Befehl erledigt alles:
bash scripts/install-deps.sh # Arbeitsbereich standardmäßig ~/.workbuddy/binaries/node/workspace
bash scripts/install-deps.sh /path/to/workspace # oder selbst angeben
Es erkennt automatisch den lokalen Browser-Cache, wählt die passende Playwright-Version aus, installiert das vollständige ffmpeg
und schreibt den Pfad des Arbeitsbereichs in ~/.workbuddy/.html2video-env. Danach sind render.sh / smoke.sh / qa.sh sofort einsatzbereit.
Manuelle Installation ist auch möglich (Arbeitsbereich muss playwright + @ffmpeg-installer/ffmpeg enthalten), dann Konfiguration selbst schreiben:
# ~/.workbuddy/.html2video-env
WB_NODE_WORKSPACE="<absoluter Pfad deines Node-Arbeitsbereichs>"
Umgebungsvariable für temporäre Nutzung:
export WB_NODE_WORKSPACE="<Pfad>". Den Pfad nicht in${VAR:-Standardwert}setzen – wenn der Benutzername Apostrophe enthält (z. B.O'Brien), interpretiert Bash sie als Anführungszeichen und meldetbad substitution.
Vier harte Regeln (schon erlebt, nicht wiederholen)
-
Playwright-Version muss mit dem Browser-Cache übereinstimmen.
playwright-core@Xerkennt nur die Chromium-Build-Nummer, die in der eigenenbrowsers.jsondeklariert ist. Bei Nichtübereinstimmung erscheint „Executable doesn't exist“.→ Nicht die Version raten,
scripts/install-deps.shverwenden – es führtscripts/detect-env.jsaus, um den Cache zu erkennen und automatisch die passende Version zu wählen. Einzelerkennung:node scripts/detect-env.js(gibtRECOMMEND_PW=...aus). Bekannte Zuordnungen: Build 1148→1.49.1, 1155→1.50.1, 1169→1.52.0, 1187→1.55.0, 1208→1.58.0.Hinweis: Unter bestimmten Netzwerkbedingungen kann der Chromium-Download wiederholt abbrechen (erkennbar an unvollständigen Verzeichnissen im Cache). In diesem Fall sollte man den vorhandenen Cache priorisieren, nicht aktualisieren – genau dafür existiert das Erkennungsskript.
-
Das ffmpeg, das Playwright mitbringt, ist nutzlos.
ms-playwright/ffmpeg-*/fffmpeg-win64.exewurde mit--disable-everythingkompiliert, hat nur VP8-Codec + webm-Container, kein H.264, kein GIF, nicht einmal PNG-Dekodierung. Man muss das vollständige ffmpeg von@ffmpeg-installer/ffmpegverwenden. -
ESM erkennt
NODE_PATHnicht. In.mjsführtimport 'playwright'zwangsläufig zuERR_MODULE_NOT_FOUND. → Hilfsskripte daher immer als CJS (.cjs/require) schreiben. -
PATH ist unzuverlässig. Besonders unter Git Bash / Windows werden Windows-Pfade, die in PATH gesetzt sind, durch Konvertierung beschädigt. → ffmpeg nicht über PATH übergeben, sondern die Umgebungsvariable
FFMPEGverwenden (wird vom Renderer unterstützt).
Videoerstellungs-Workflow
Schritt 0 (Der einfachste Weg): Eine Vorlage auswählen, Text ändern
cp assets/templates/bar-chart.html my-video.html
# my-video.html öffnen, nur den CFG-Block oben bearbeiten (title / data / note)
bash assets/render.sh my-video.html --duration=6 --fps=60
Die Vorlagen berücksichtigen die Untertitel-Sicherheitszone: Text liegt nicht in den unteren 15% des Bildes (wird nach dem Einbrennen von Untertiteln abgedeckt). Beim eigenen Layout bitte ebenfalls darauf achten.
Schritt 1: Animations-HTML schreiben (wenn Vorlagen nicht ausreichen)
assets/animation-skeleton.html als Grundgerüst verwenden. Kernvertrag (der Renderer arbeitet damit):
// Muss beides bereitgestellt werden
window.__seek = (t) => { /* Zeichne den gesamten Frame zum Zeitpunkt t neu */ };
window.__ready = true; // Bereitsignal
// Der Renderer injiziert window.__seekRender = true,
// dann muss der **selbsttreibende Takt eingefroren** werden, nur externes __seek schiebt Frames (garantiert Determinismus)
if (window.__seekRender) { renderAt(0); window.__ready = true; }
else { /* Browser-Vorschau: requestAnimationFrame selbsttreibende Schleife */ }
Ehernes Gesetz: Jeder Frame = reine Funktion der Zeit t. Nicht auf den „vorherigen Frame“ verlassen,
kein Math.random() / Date.now() / performance.now() verwenden – das würde bei gleichem Input zu unterschiedlichem Output führen,
der Determinismus-Test in der Qualitätssicherung würde rot aufleuchten, und paralleles Rendern mit mehreren Workern würde jeder seinen eigenen Film produzieren.
Die Leinwand ist die einzige Ausgabe: <canvas width=1920 height=1080>, CSS-Position left:0; top:0,
body margin:0. Der Renderer verwendet clip:{x:0,y:0,width:W,height:H}, um die obere linke Ecke zu erfassen. Die Leinwand muss also genau den Viewport ausfüllen, nicht zentriert sein.
(Der Renderer unterstützt auch DOM/CSS/SVG-Animationen – auch hier muss nur __seek bereitgestellt werden. Aber DOM-Animationen verursachen pro Frame ein teures Reflow; reines Canvas ist in der Regel stabiler und schneller.)
Verbotene Klassennamen (der Renderer injiziert CSS, das sie bedingungslos ausblendet – die Elemente verschwinden dann spurlos):
.no-record .progress .progress-bar .counter .tCur .phases .phase-label .phase .replay .masthead .kicker .title .footer
Außerdem <button>-Elemente innerhalb von position:fixed/sticky-Elementen nicht verwenden – sie werden von heuristischen Regeln als Play-Steuerelemente erkannt und ausgeblendet.
(Möchte man, dass ein bestimmter Block im fertigen Video erscheint, fügt man data-record-keep="true" hinzu, um ihn von der heuristischen Ausblendung auszunehmen.)
Chinesische Schriftarten: '"Microsoft YaHei", "PingFang SC", system-ui, sans-serif'; für Monospace: 'ui-monospace, Consolas, monospace'.
Schritt 2: Rauchtest (Nicht überspringen)
Zuerst nur ein paar Frames einfangen, um sicherzustellen, dass der Vertrag erfüllt ist, keine Konsolenfehler auftreten und die Bilder nicht verschwommen sind. Erst dann die Vollausgabe starten:
bash assets/smoke.sh animation.html
# SMOKE_T="0,1,2" SMOKE_W=1280 SMOKE_H=720 bash assets/smoke.sh animation.html
Schnellere Iterationsmethode: Nur einen kurzen Abschnitt rendern – in Sekunden sieht man den dynamischen Effekt.
bash assets/render.sh animation.html --from=3 --to=5 --fps=30 # Nur 3s–5s rendern
bash assets/render.sh animation.html --stills=1,3,5 # Nur drei Standbilder exportieren
Schritt 3: Vollständiges Rendern
bash assets/render.sh animation.html --duration=12 --fps=60 --width=1920 --height=1080 --concurrency=4
Häufige Parameter:
| Parameter | Funktion |
|---|---|
--duration / --from / --to | Dauer / nur bestimmten Abschnitt rendern (Sekunden). Bei Bereichsangabe wird der Ausgabename zu Name.start-ende.mp4, überschreibt nicht das fertige Video |
--stills=0.5,2.0 | Nur PNGs zu diesen Zeitpunkten exportieren, keine Kodierung |
--fps / --width / --height | Bildrate und Auflösung |
--preset=<Name> | Vordefinierte Größen nach Plattform: douyin / tiktok / vertical (1080×1920), xiaohongshu (1080×1440), instagram (1080×1350), square (1080×1080), bilibili / youtube / landscape (1920×1080) |
--presets=a,b,c | Ein Befehl erzeugt alle Seitenverhältnisse, jedes als eigenes Video (Dateiname erhält automatisch Suffix -seitenverhältnis). Für Cross-Plattform-Veröffentlichung |
--theme=<Name> | Farbschema wechseln (midnight / paper / neon), überschreibt CFG.theme in der Vorlage, ohne die Datei zu ändern. Bei falschem Namen wird deutlich gewarnt und auf midnight zurückgefallen |
--out=<Datei> | Ausgabepfad festlegen (Standard: gleiches Verzeichnis, gleicher Name). Nützlich, wenn ein HTML mehrere Farbschemata ausgibt – sonst überschreibt das spätere das frühere; übergeordnete Verzeichnisse, die nicht existieren, werden automatisch erstellt |
--json | stdout gibt nur eine Zeile Ergebnis-JSON aus (output / duration / frames / warnings usw.), menschenlesbare Logs gehen komplett über stderr. Für Agenten zum Parsen |
--concurrency | Anzahl paralleler Worker (jeder Worker eine Chromium-Seite, auf Arbeitsspeicher achten). auto wählt basierend auf CPU und RAM selbst |
--audio=<Datei> | Nach der Kodierung automatisch Audiospur mischen (Videostream direkt kopiert, keine Neukodierung). Wenn --duration nicht angegeben, wird die Videolänge automatisch an die Audiolänge angepasst (auf den nächsten ganzen Frame aufgerundet, sodass kein Ton abgeschnitten wird) |
--audio-fit=trim|pad | Verhalten bei unterschiedlicher Länge von Audio und Video: trim schneidet am Audiospur-Ende ab (Standard) / pad fügt Stille an das Audio an, um die Videolänge zu erhalten |
--gop=<Sekunden> | Keyframe-Intervall, z. B. --gop=1 ein Keyframe pro Sekunde, für späteres sekundengenaues Suchen |
--keep-frames | Auch bei Erfolg die Einzelframe-PNGs behalten (für erneute Kodierung mit anderem CRF ohne erneutes Capturing) |
--tmpdir=<Verzeichnis> | Wo die Einzelframe-PNGs abgelegt werden (Standard: neben dem HTML). 1080p eine Minute ≈ 2,4 GB; wenn die Systemplatte keinen Platz mehr hat, auf eine andere Platte verweisen |
--resume | Letztes Rendern wurde unterbrochen? Einfach weitermachen – bereits fertige Frames werden wiederverwendet (bei abweichenden Parametern wird abgelehnt, siehe unten) |
--alpha | Transparenter Hintergrund (ProRes 4444 .mov), zum Überlagern über echte Aufnahmen / KI-generierte Szenen, siehe unten |
--srt=<Datei> | Externe Untertitel-Zeitleiste (SRT / VTT / JSON), wird als window.__cues injiziert, siehe unten. Synonym: --cues |
--cue-log=<Datei> | CSV mit frame,t,cue_index,cue_start,cue_end exportieren, um die Synchronisation der Untertitel zu überprüfen |
--story=<Datei> | JSON-Shotliste, wird als window.__story injiziert, ersetzt das gesamte Drehbuch (siehe „Multi-Shot und Übergänge“) |
--scene-log=<Datei> | CSV der Shot-Zeitleiste exportieren; bei Abweichung der Gesamtlänge der Shotliste von --duration wird gewarnt |
--gif[=fps,breite] | Nach dem Rendern direkt ein GIF erstellen (Standard 15fps / Breite 960), zweistufige Palette-Optimierung, kein erneutes Rendern |
--poster=<Sekunden> | Nach dem Rendern ein Standbild .jpg extrahieren, für Website / Social Media |
--batch=<datei.json> | Liste von Aufgaben nacheinander rendern, am Ende eine Zusammenfassungstabelle; einzelner Fehler beeinträchtigt nicht bereits fertig gerenderte Aufgaben, aber das Skript beendet sich insgesamt mit Nicht-Null |
--headed | Mit sichtbarem Browserfenster rendern (zum Debuggen; Parallelität wird automatisch auf 1 reduziert, um nicht ein ganzes Fenster aufzumachen) |
--allow-errors | Fehler auf der Seite führen nicht zum Fehlschlag (Standard: bei Fehlern → Nicht-Null-Exit) |
--keep-chrome | Play-Steuerelemente nicht ausblenden (Debuggen) |
--readytimeout=8 | Sekunden, die auf __seek-Bereitschaft gewartet werden (bei langsamen Starts mit großen Schriftarten/Daten kann man diesen Wert erhöhen) |
--no-serve | Seite über file:// laden (Standard: lokaler HTTP-Server, siehe unten) |
ESM / fetch-Projekte können direkt gerendert werden. Der Renderer startet standardmäßig einen lokalen HTTP-Server, der nur an 127.0.0.1 gebunden ist, um die Seite zu laden –
unter file:// werden <script type="module"> und fetch() durch CORS blockiert; mit Vite/esbuild organisierte Animationen oder Projekte, die lokale JSONs lesen, funktionieren gar nicht.
Der HTTP-Server vermeidet diese Probleme. Die Adresse wird zu Beginn des Renderings ausgegeben und kann für die Fehlersuche direkt im Browser eingegeben werden, um die Konsole zu sehen.
Wenn der Server nicht startet, wird automatisch auf file:// zurückgefallen und ein Hinweis ausgegeben (nur Warnung, kein Fehlschlag).
- Ausgabe:
.mp4mit demselben Namen wie das HTML - Anzahl Frames =
fps × duration. 12s@60fps = 720 Frames, gemessen ca. 6~7 Minuten (concurrency=4) - Kodierung ca. 1 Minute (crf 18 / preset medium)
Bei Seitenfehlern am Ende des Renderings wird mit Nicht-Null beendet (das fertige Video wird trotzdem ausgegeben, damit man es überprüfen kann).
Mitten im Rendern abgebrochen – nicht von vorne anfangen
Bei langen Videos (60fps × 30s = 1800 Frames) bei 80% abgebrochen – nochmal von vorne anfangen ist das Schlimmste. Mit --resume einfach weitermachen:
bash assets/render.sh animation.html --fps=60 --duration=30 --resume
# ▸ resume: 1432 Frames bereits vorhanden, 368 müssen noch gerendert werden
Drei Regeln, alle mit dem Ziel: Das fortgesetzte Video ist identisch mit einem in einem Durchgang gerenderten Video:
- Frames werden zuerst als
.partgeschrieben, dann umbenannt. Ein getöteter Frame hinterlässt kein halbes PNG, das „erledigt“ vortäuscht. Wiederverwendete Frames sind also garantiert vollständig gezeichnet. - Bei geänderten Parametern wird abgelehnt. Letztes Mal 30fps, dieses Mal 60fps – das würde zu einer Mischung zweier Framegruppen bei der Kodierung führen, der Video-Rhythmus wäre inkonsistent, ohne Fehlermeldung.
Daher wird bei Abweichung von
fps / Größe / Bereich / Rendermodusabgebrochen und „letztes Mal vs. dieses Mal“ ausgegeben, damit man selbst entscheiden kann, ob man die alten Dateien löscht. - Frame-Nummern müssen lückenlos sein. Fehlt ein Frame, beendet ffmpeg stumm vorzeitig, das Video wird kürzer.
Daher werden vor der Kodierung alle Frame-Nummern im Bereich
[Start, Ende)überprüft – bei Lücken wird gemeldet und--resumeempfohlen.
Das temporäre Verzeichnis ist fest auf .seek-tmp-resume/ neben dem HTML gesetzt (ohne --resume wird immer noch ein Einmal-Verzeichnis verwendet, parallele Renderings stören sich nicht).
Nach dem Rendern wird es automatisch gelöscht; mit --keep-frames wird es behalten.
Dies ist beabsichtigt: Seitenfehler bedeuten oft „komplett schwarzer Bildschirm / halbe Bildschirminhalte fehlen“. Frühere Versionen meldeten dann trotzdem Erfolg, bis man die Einzelframes überprüfte – jetzt werden die Fehler vor der Kodierung gemeldet und am Ende als Fehlschlag gewertet.
Transparente Ausgabe: Textebene über fremde Aufnahmen legen
KI-generierter Hintergrund + Code-generierte Textebene ist die derzeit günstigste Kombination (KI zeichnet Text garantiert unscharf, Code zeichnet Text pixelgenau).
--alpha ist genau für diesen Weg gedacht:
bash assets/render.sh animation.html --duration=6 --fps=30 --alpha
# → animation.mov (ProRes 4444 / yuva444p10le, Hintergrund transparent)
Die Vorlage muss eine Sache unterstützen: Wenn window.__transparent wahr ist, die Hintergrundebene nicht zeichnen.
Derzeit sind title-card, compare, flow-steps, subtitle-card, vertical-subtitle,
explainer – diese 6 sind bereits entsprechend geschrieben; einfach --alpha hinzufügen, um transparente Effekte zu erhalten.
Die restlichen 5 sind vollflächige Diagramme (Balken / Linie / große Zahlen / Zeitleiste / Hochformatkarte), die von Natur aus einen Hintergrund benötigen – mit --alpha erhält man nur ein nicht-transparentes Video.
Eigene Animationen bitte genauso handhaben:
const TRANSPARENT = !!window.__transparent;
function drawFrame(t) {
g.clearRect(0, 0, W, H);
if (!TRANSPARENT) {
// Hintergrundfarbe / Vollbild-Verlauf / Lichtflecken – das alles muss übersprungen werden
g.fillStyle = '#080A10';
g.fillRect(0, 0, W, H);
}
// Text, Grafiken normal zeichnen
}
Vollbild-Halbdurchsichtige Lichteffekte müssen ebenfalls übersprungen werden. Selbst wenn sie nur 5% Deckkraft haben, lassen sie die gesamte Fläche beim Überlagern über echte Aufnahmen blass erscheinen – der erste Gedanke des Cutters ist „Dieses Material ist schmutzig“. Gemessen: Überspringt man nur die Hintergrundfarbe, bleiben die Lichteffekte, haben die Ecken alpha=12 (nicht 0); überspringt man alles, haben die Ecken alpha=0, nur 3,7% der Pixel sind undurchsichtig (nur Text).
Größenkosten (960×540 / 2s / 20 Frames gemessen):
| Ausgabe | Größe | Verwendung |
|---|---|---|
H.264 .mp4 | 118 KB | Fertiges Video direkt senden |
ProRes 4444 .mov | 760 KB (6,5×) | In Schnittsoftware importieren und überlagern |
Nur ProRes, kein VP9. Getestet mit --pix_fmt yuva420p + -auto-alt-ref 0, die Datei wuchs zwar von 7,8 KB
auf 16,8 KB (Alpha-Daten wurden geschrieben), aber Chromium zeigte beim Abspielen Alpha komplett 0 – sogar der Text war transparent.
Verlustfrei, konstante Bitrate, -lag-in-frames 0 – alles gleich. Da man sich nicht selbst von der Korrektheit überzeugen kann, wird es nicht als Option veröffentlicht.
Für kleine transparente Materialien: ProRes in der Schnittsoftware importieren, dann als webm exportieren – das ist deren Aufgabe.
Audio-Ausrichtung: Erst den Ton, dann die Bildlänge bestimmen
Der echte Workflow ist „Text schreiben → Ton aufnehmen → Bilder anpassen“, nicht erst 6 Sekunden festlegen und dann versuchen, den Ton hineinzuquetschen.
Daher: Wenn --audio gegeben, aber --duration nicht, wird die Videolänge an die Audiolänge angepasst:
bash assets/render.sh title-card.html --audio=voice.m4a --story=shots.json
# ▸ audio: Audio 12.48s, kein --duration angegeben → Videolänge nach Audio (12.5s, aufgerundet auf nächsten ganzen Frame)
- Auf nächsten ganzen Frame aufgerundet, nicht gerundet: Ein halber Frame weniger würde den letzten Konsonanten des letzten Wortes abschneiden.
- Wird
--durationexplizit angegeben und die Abweichung zum Audio beträgt mehr als 0,35s, erfolgt eine deutliche Warnung mit Angabe der Differenz und was betroffen ist. In älteren Versionen wurde hier stumm ein kurzes Video ausgegeben – die zweite Hälfte des Bildes fehlte komplett, das Log zeigte nur✓ Done. --audio-fit=pad: Wenn Audio kürzer als Video ist, Stille hinzufügen, um die gesamte Bildlänge zu erhalten (Standardtrimschneidet am Audiospur-Ende ab).
Hinweis: Bei trim entspricht das fertige Video nicht exakt der Audiosekundenzahl. Der Videostream wird mit -c:v copy direkt kopiert,
ffmpeg kann nur an Frame-Paket-Grenzen schneiden; gemessen bleiben ca. 0,3–0,5s übrig. Für millisekundengenaue Synchronisation müsste man neu kodieren –
das ist Bildqualität gegen Genauigkeit eintauschen, nicht lohnenswert. Ein paar zusätzliche Frames am Ende fallen in der Regel nicht auf.
Ein Beitrag, mehrere Plattformen: Ein Befehl erzeugt alle Seitenverhältnisse
Das gleiche Video soll auf Douyin, Xiaohongshu und Bilibili erscheinen? In der Schnittsoftware bräuchte man pro Seitenverhältnis eine Sequenz und müsste jedes einzeln exportieren und überwachen. Hier ist das Layout parametrisiert – ein weiterer Durchlauf ist nur eine weitere Schleife:
bash assets/render.sh bar-chart.html --duration=8 \
--presets=douyin,xiaohongshu,square --out=dist/chart.mp4
# → dist/chart-douyin.mp4 1080×1920
# dist/chart-xiaohongshu.mp4 1080×1440
# dist/chart-square.mp4 1080×1080
| Voreinstellung | Größe | Für wen |
|---|---|---|
douyin / tiktok / vertical | 1080×1920 | Douyin, Kuaishou, Video Account, Reels, Shorts |
xiaohongshu / xhs | 1080×1440 | Xiaohongshu 3:4 |
instagram / ig | 1080×1350 | IG Feed 4:5 |
square | 1080×1080 | Quadratisch 1:1 |
bilibili / youtube / landscape | 1920×1080 | Querformat 16:9 |
- Einzelnes Seitenverhältnis:
--preset=xiaohongshu; mehrere:--presets=a,b(mindestens zwei, nur eines gewünscht →--preset). - Falscher Voreinstellungsname führt zu sofortigem Fehlschlag mit Auflistung der verfügbaren Namen – es wird nicht stillschweigend ein falsches Seitenverhältnis (1920×1080) gerendert.
- Ob das Layout funktioniert, ist eine andere Frage: Die Vorlagen passen sich dem Viewport an, aber 9:16 und 16:9 haben unterschiedliche Informationsdichte –
bei 3:4 können die Schriftgrößen zu groß / der Inhalt zu gedrängt wirken.
--presetsgarantiert nur die korrekte Größe, nicht eine gelungene Komposition. - Der weiße Bereich am unteren Rand im Hochformat ist beabsichtigt, kein Layoutfehler: Die Fortschrittsleiste und Buttons der Plattform befinden sich dort; die Vorlagen lassen einheitlich 15% Sicherheitszone für eingebrannte Untertitel.
- Inkompatibel mit
--stills(eines macht mehrere Videos, eines nur Screenshots); nicht verschachtelbar mit--batch.
Für Agenten: --json
Der Hauptaufrufer dieses Tools ist ein KI-Agent. Menschen können ✓ Done: xxx.mp4 (1.2 MB · 30fps · 12s) problemlos lesen,
Agenten müssen mit Regex raten – bei Pfaden mit Leerzeichen, chinesischen Zeichen oder Klammern bricht das. --json strukturiert die Ausgabe:
bash assets/render.sh title-card.html --duration=6 --json
{"ok":true,"exitCode":0,"input":"…/title-card.html","output":"…/title-card.mp4",
"width":1920,"height":1080,"fps":60,"duration":6,"frames":360,"bytes":2411724,
"gif":null,"poster":null,"warnings":[],"mode":"video","theme":null,"preset":null}
- stdout enthält nur diese eine Zeile, Fortschritts- und menschenlesbare Logs gehen komplett über stderr – direkt
JSON.parse(stdout)verwenden. warningsist ein Array: Parameterkonflikte, Abweichungen zwischen Audio- und Videolänge, unbekannte Themennamen landen hier. Leeres Array = sauber.durationist die tatsächliche Länge des fertigen Videos (von ffmpeg ausgelesen), nicht der Planwert.- Bei
--presetswirdoutputzu einem Array, zusätzlich gibt espresets: [{name,width,height,output,ok,bytes}].
Untertitel mit externer Zeitleiste: Vertonung geändert, Untertitel folgen
Untertiteltexte in CFG.lines der Vorlage zu schreiben ist am bequemsten, aber sobald eine Vertonung kommt, müssen die Zeitpunkte dem Audio folgen –
man kann nicht nach jeder Änderung eines Satzes manuell die Sekundenzahl nachzählen. Daher unterstützt der Renderer direkte Zufütterung einer Untertiteldatei:
bash assets/render.sh subtitle-card.html --duration=8 --srt=voice.srt --cue-log=cues.csv
- Unterstützt werden SRT (
HH:MM:SS,mmm), WebVTT (MM:SS.mmmfunktioniert auch) und JSON ([{start,end,text}]). - Wird einmal geparst und als
window.__cuesinjiziert. Die Vorlage sucht anhand vontdes aktuellen Frames selbst, welcher Satz angezeigt werden soll. Bei Verwendung von--srtin der Vorlage hat__cuesVorrang; wenn nicht vorhanden, wird aufCFG.lineszurückgegriffen – so funktioniert dieselbe Vorlage mit beiden Methoden. --cue-loggibt eine CSV mitframe,t,cue_index,cue_start,cue_endaus. Die Frage „Sind die Untertitel zeitlich korrekt?“ wird zu einer berechenbaren Zahl: Man zieht die Startzeit des ersten Frames jedes Satzes vom SRT-Startpunkt ab – das ist der Fehler. Gemessen: 4 Sätze / 80 Frames, Fehler pro Satz 0 ms, die Frame-Abdeckung stimmt exakt mit der Theorie überein.
Auf Seiten der Vorlage reichen zwei Zeilen für die Unterstützung:
const cues = (window.__cues && window.__cues.length) ? window.__cues : CFG.lines;
const cur = cues.find(c => t >= c.start && t < c.end) || cues[cues.length - 1];
Untertitel nicht in die unteren 15% legen. Das ist der Bereich der Fortschrittsleiste / Like-Buttons auf den Plattformen – dort geschriebener Text ist umsonst.
qa.js gibt den Hinweis „Untertitelbereich betreten“; die Hochformat-Layouts der vier neuen Vorlagen lassen diesen Sicherheitsgurt frei.
Farbschema wechseln: Ein Feld ändern, kein Canvas-Code anfassen
assets/templates/themes.js enthält drei Paletten, alle 11 Vorlagen sind angebunden. Zwei Änderungsmethoden:
# ① Über die Kommandozeile wechseln (empfohlen): Keine Datei ändern, ein Thema pro Rendering
bash assets/render.sh bar-chart.html --duration=6 --theme=paper --out=bar-paper.mp4
# ② Datei ändern: Im CFG-Block oben in der Vorlage fest codieren
const CFG = {
theme: 'paper', // midnight (Standard, dunkel) | paper (hell) | neon (schwarz-violettes Neon)
...
};
Die Kommandozeile hat Vorrang vor CFG.theme (der Renderer injiziert window.__h2vTheme; die Vorlage liest
h2vTheme(window.__h2vTheme || CFG.theme)). So kann man aus demselben HTML drei Farbschemata in Serie ausgeben:
[{ "html": "...", "theme": "midnight" }, { "html": "...", "theme": "paper" }, { "html": "...", "theme": "neon" }]
Abgefragt wird, ob es die Palette gibt: Fehlt sie, gibt es eine klare Warnung und Rückfall auf midnight. Die Paletten selbst:
// midnight (Standard)
{ bg: '#080A10', text: '#EAECEF', accent: '#3B82F6', muted: '#6B7280', success: '#10B981', warning: '#F59E0B' }
// paper
{ bg: '#F8FAFC', text: '#1E293B', accent: '#2563EB', muted: '#94A3B8', success: '#059669', warning: '#D97706' }
// neon
{ bg: '#0D0015', text: '#E0E0FF', accent: '#A855F7', muted: '#6B21A8', success: '#22D3EE', warning: '#FBBF24' }
--theme injiziert nur das palette-Objekt, ersetzt nicht die gesamte theme.js. Man kann also eine eigene themes.js haben, die mehr Einträge definiert,
und trotzdem --theme=meinname verwenden. Die Werte können dann nach Belieben ergänzt werden.
--batch verarbeitet mehrere Farbschemata in einem Durchlauf (siehe nächster Abschnitt).
Batch-Modus: Eine Liste, mehrere Videos, eine Zusammenfassung
--batch ist gedacht für: Ein HTML, mehrere SRT-Dateien, mehrere Farbschemata, oder mehrere HTML-Seiten – der Anwendungsfall ist
„zehnerlei Variationen eines Videos in Produktion geben“. Das Prinzip:
bash assets/render.sh --batch=batch.json
Beispiel für batch.json:
[
{
"html": "subtitle-card.html",
"duration": 8,
"fps": 60,
"srt": "deutsch.srt",
"out": "dist/deutsch.mp4",
"theme": "paper"
},
{
"html": "subtitle-card.html",
"duration": 8,
"fps": 60,
"srt": "english.srt",
"out": "dist/english.mp4",
"theme": "midnight"
}
]
Fehlerbehandlung: Ein fehlschlagender Auftrag unterbricht nicht die nachfolgenden; bereits fertig gerenderte Videos bleiben erhalten. Am Ende erscheint eine Tabelle mit allen Ergebnissen, und das Skript beendet sich mit dem Exit-Code der fehlgeschlagenen Aufträge (summiert alle Exit-Codes ≠ 0).
Parameter-Delegation: Alle CLI-Parameter (außer --batch selbst) werden als Standardwerte an jeden Auftrag vererbt –
--fps / --width / --height / --concurrency / --gif usw., die in der Kommandozeile stehen, gelten für alle Aufträge,
sofern nicht der einzelne Auftrag seinen eigenen Wert angibt. Das spart Tipparbeit.
Kein --json-Modus: Der Batch-Modus gibt immer strukturiertes JSON aus (weil er für Agenten konzipiert ist).
--json in der Kommandozeile zu setzen ist erlaubt, aber überflüssig.
Nicht verschachtelbar: --batch-Aufträge können kein weiteres --batch enthalten.
Unter der Haube: Renderer-Mechanismus
assets/render.sh ist der Einstiegspunkt. Es tut Folgendes:
- Lade die Umgebung (entweder aus
~/.workbuddy/.html2video-envoder aus Umgebungsvariablen) - Suche oder starte einen lokalen HTTP-Server
- Starte ein Headless-Chromium (Playwright) und öffne die Seite
- Warte auf
window.__ready === true(Zeitüberschreitung:--readytimeout, Standard 8s) - Rufe für jede Frame-Nummer von 0 bis
fps × durationnacheinander__seek(frameIndex / fps)auf - Mache einen Screenshot der Leinwand und speichere ihn als PNG
- Nach Abschluss der Screenshots rufe ffmpeg auf: Verbinde PNGs zu einem Video (-crf 18)
- Wenn
--audiovorhanden ist, füge die Audiospur hinzu (Video-Stream wird kopiert) - Gib das fertige Video aus
Fehlerbehandlung:
- Wenn die Seite einen Fehler meldet, wird das Rendern nicht sofort gestoppt (ermöglicht die Inspektion des fehlerhaften Frames)
- Aber am Ende wird der Exit-Code auf Nicht-Null gesetzt, d. h. der Agent sieht: Es gab eine Warnung
--allow-errorsüberschreibt dieses Verhalten
Qualitätssicherung: Determinismus-Test und Konsolenüberprüfung
Das Skript bash assets/qa.sh animation.html führt aus:
- Zweimaliges Rendern mit denselben Parametern, dann
diffder beiden MP4 → sicherstellen, dass sie byteweise identisch sind - Überprüfung auf Konsolenfehler (console.error / nicht abgefangene Promise-Ablehnungen)
- Warnung, wenn die Leinwandgröße nicht mit dem Viewport übereinstimmt
Entwicklungsempfehlung: Rauchtest → QA → Vollrendern. Defekte Bilder erkennt man am besten bei 5 Frames, nicht erst nach 720 Frames.
Verzeichnisstruktur
assets/
├── render.sh # Haupterstellungsskript
├── smoke.sh # Rauchtest (einige Frames zum Überprüfen)
├── qa.sh # Qualitätssicherung (Determinismus, Fehler)
├── animation-skeleton.html # Grundgerüst zum Selberschreiben
├── lib/
│ └── scenes.js # Multi-Shot + Übergänge
└── templates/
├── explainer.html # Multi-Shot-Erklärvideo
├── title-card.html # Video-Intro mit Titel
├── bar-chart.html # Balkendiagramm
├── line-chart.html # Liniendiagramm
├── number-counter.html # Große Zahlen / KPI
├── timeline.html # Zeitleiste / Ablauf
├── vertical-card.html # Hochformat 9:16 Karte
├── compare.html # Vorher-Nachher-Vergleich
├── flow-steps.html # Ablaufdiagramm
├── subtitle-card.html # Satzweise Erklärungskarte
├── vertical-subtitle.html # Hochformat-Untertitelkarte
└── themes.js # Farbschema-Paletten
Häufig gestellte Fragen (FAQ)
F: Warum Canvas statt DOM? A: DOM (CSS/SVG)-Animationen verursachen pro Frame ein teures Reflow und sind auf Nicht-PC-Browsern (wie Headless-Chromium) nicht stabil. Canvas zeichnet direkt auf die Bitmap und bietet maximale Kontrolle und Geschwindigkeit.
F: Wie viele Frames pro Sekunde unterstützt der Renderer? A: Theoretisch beliebig, praktisch empfehlen wir 60 fps für flüssige Animationen. Bei 120 fps steigt die Kodierungszeit proportional, und die meisten Plattformen geben die Framerate am Ende doch auf 60 herunter.
F: Unterstützt der Renderer GPU-Beschleunigung?
A: Playwright verwendet standardmäßig die Software-Rendering-Pipeline (--disable-gpu).
Das ist beabsichtigt: Ergebnisse sind zwischen verschiedenen Maschinen deterministisch.
GPU-Rendering führt aufgrund unterschiedlicher Treiber zu leicht abweichenden Pixeln und macht den Determinismus-Test zunichte.
F: Wie lange dauert das Rendern eines 10-Sekunden-Videos? A: Abhängig von der Komplexität der Animation und der Hardware: 1080p, 60fps, 10 Sekunden ≈ 5–8 Minuten bei 4 Nebenläufigkeit. Die Hauptzeit wird für das Ausführen von JavaScript und das Erstellen von Screenshots aufgewendet, nicht für die Videokodierung.
F: Kann ich den Renderer in CI/CD integrieren?
A: Ja. --json und --batch sind beide für die Automatisierung ausgelegt.
Stelle sicher, dass Chromium und ffmpeg in der CI-Umgebung installiert sind.
Abschließende Hinweise
Dateiverwaltung: Die Ausgabedatei überschreibt standardmäßig das vorherige Ergebnis (sofern nichts anderes angegeben).
Verwende --out oder sichere die Ausgabe manuell, um Datenverlust zu vermeiden.
Ressourcen: Jeder Render-Worker benötigt ca. 200–400 MB Arbeitsspeicher. Bei --concurrency=4 sind also insgesamt etwa 1–2 GB zu erwarten.
Ein langes Rendern kann auch die CPU stark belasten – lasse andere Aufgaben nach Möglichkeit parallel laufen.
Fehlersuche: Wenn das Ergebnis nicht den Erwartungen entspricht:
--smokemit wenigen Frames testen- Gegebenenfalls
--headedfür die visuelle Inspektion verwenden - Die Browser-Konsole über den HTTP-Server auf Fehler überprüfen
- Die
--still-Einstellung für die Inspektion einzelner Frames verwenden - Die
--scene-log- und--cue-log-Dateien auf zeitliche Abweichungen überprüfen
Erweiterung: Eigene Vorlagen können nach dem Vorbild eines beliebigen assets/templates/*.html erstellt werden,
mit nur einem CFG-Block und einer __seek-Funktion.