Communitygithub.com

html-to-video

Wandelt HTML/Canvas-Animationen Frame für Frame in MP4 um (mit Audio, GIF und transparentem Hintergrund).

Was ist html-to-video?

Dieses Skill ermöglicht es, HTML- und Canvas-Animationen Frame für Frame in hochwertige MP4-Videos zu rendern. Es ist keine KI-Videogenerierung, sondern ein deterministischer, Code-basierter Ansatz: Eine JavaScript-Funktion `frame(t)` zeichnet jeden Frame als reine Funktion der Zeit, ein Headless-Browser (Playwright) capturet die Frames, und ffmpeg kodiert sie zu H.264. Der gesamte Prozess ist vollständig kontrollierbar, liefert pixelgenaue Ergebnisse und benötigt keine Cloud-Kosten. Es eignet sich ideal für Erklärvideos, Diagrammanimationen, Produktdemos, Datenvisualisierungen und Social-Media-Content. Das Skill enthält 11 vorgefertigte, sofort nutzbare Vorlagen (z. B. für Erklärfilme mit mehreren Kameraeinstellungen, vertikale 9:16-Karten, Vorher-Nachher-Vergleiche, Ablaufdiagramme und Untertitelkarten), die alle thematisierbar sind. Es unterstützt Multi-Kamera-Sequenzen mit JSON-Shot-Listen, externe Untertitel (SRT/VTT/JSON), transparente Ausgabe (ProRes 4444) für Compositing, Audio-Tracks, die automatisch die Videolänge bestimmen, und die parallele Ausgabe in mehreren Formaten für verschiedene Plattformen (Douyin, YouTube, Instagram usw.). Agenten können den Prozess nahtlos mit `--json` für strukturierte Ausgaben steuern.

Funktioniert mit~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/wx4766/html-to-video/tree/main

In Ihrer bevorzugten KI fragen

Öffnet einen neuen Chat, in dem dieser Agent-Skill bereits geladen ist.

Dokumentation

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):

VorlageVerwendungAusgabe
explainer.htmlMulti-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.htmlVideo-Intro: Haupttitel erscheint Buchstabe für Buchstabe + Untertitel--duration=6
bar-chart.htmlBalkendiagramm/Ranking: Balken wachsen nacheinander, Zahlen rollen synchron--duration=6
line-chart.htmlLiniendiagramm-Trend: Linie wächst + Datenpunkte leuchten auf + letzter Punkt als Markierung--duration=8
number-counter.htmlGroße Zahlen/KPI: Von 0 zum Zielwert rollen + Auf/Ab-Label--duration=7
timeline.htmlZeitleiste/Ablauf: Mittelachse wächst, Knoten werden nacheinander aktiviert--duration=7
vertical-card.htmlHochformat 9:16 Karte: Großer Titel + große Zahlen, für Douyin/Video Account/Reels--duration=6 --width=1080 --height=1920
compare.htmlVorher-Nachher-Vergleich: Links/rechts zwei Spalten, Zusammenfassungszeile am Ende--duration=7
flow-steps.htmlAblaufdiagramm-Animation: Schritte nacheinander aktiviert, aktueller Schritt hervorgehoben--duration=7
subtitle-card.htmlSatzweise Erklärungskarte: Ein Satz nach dem anderen, Fortschrittsbalken unten--duration=8 + --srt
vertical-subtitle.htmlHochformat-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:

VariableBedeutung1920×10801080×1920
SX = W/1920Horizontale Größen, linke/rechte Ränder10.5625
SY = H/1080Vertikale Größen, Zeilenabstände11.7778
SF = √(SX·SY)Schriftgröße, Linienstärken, Eckenradius, Unschärfe11

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 meldet bad substitution.

Vier harte Regeln (schon erlebt, nicht wiederholen)

  1. Playwright-Version muss mit dem Browser-Cache übereinstimmen. playwright-core@X erkennt nur die Chromium-Build-Nummer, die in der eigenen browsers.json deklariert ist. Bei Nichtübereinstimmung erscheint „Executable doesn't exist“.

    → Nicht die Version raten, scripts/install-deps.sh verwenden – es führt scripts/detect-env.js aus, um den Cache zu erkennen und automatisch die passende Version zu wählen. Einzelerkennung: node scripts/detect-env.js (gibt RECOMMEND_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.

  2. Das ffmpeg, das Playwright mitbringt, ist nutzlos. ms-playwright/ffmpeg-*/fffmpeg-win64.exe wurde mit --disable-everything kompiliert, hat nur VP8-Codec + webm-Container, kein H.264, kein GIF, nicht einmal PNG-Dekodierung. Man muss das vollständige ffmpeg von @ffmpeg-installer/ffmpeg verwenden.

  3. ESM erkennt NODE_PATH nicht. In .mjs führt import 'playwright' zwangsläufig zu ERR_MODULE_NOT_FOUND. → Hilfsskripte daher immer als CJS (.cjs / require) schreiben.

  4. 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 FFMPEG verwenden (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:

ParameterFunktion
--duration / --from / --toDauer / nur bestimmten Abschnitt rendern (Sekunden). Bei Bereichsangabe wird der Ausgabename zu Name.start-ende.mp4, überschreibt nicht das fertige Video
--stills=0.5,2.0Nur PNGs zu diesen Zeitpunkten exportieren, keine Kodierung
--fps / --width / --heightBildrate 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,cEin 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
--jsonstdout gibt nur eine Zeile Ergebnis-JSON aus (output / duration / frames / warnings usw.), menschenlesbare Logs gehen komplett über stderr. Für Agenten zum Parsen
--concurrencyAnzahl 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|padVerhalten 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-framesAuch 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
--resumeLetztes Rendern wurde unterbrochen? Einfach weitermachen – bereits fertige Frames werden wiederverwendet (bei abweichenden Parametern wird abgelehnt, siehe unten)
--alphaTransparenter 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
--headedMit sichtbarem Browserfenster rendern (zum Debuggen; Parallelität wird automatisch auf 1 reduziert, um nicht ein ganzes Fenster aufzumachen)
--allow-errorsFehler auf der Seite führen nicht zum Fehlschlag (Standard: bei Fehlern → Nicht-Null-Exit)
--keep-chromePlay-Steuerelemente nicht ausblenden (Debuggen)
--readytimeout=8Sekunden, die auf __seek-Bereitschaft gewartet werden (bei langsamen Starts mit großen Schriftarten/Daten kann man diesen Wert erhöhen)
--no-serveSeite ü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: .mp4 mit 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 .part geschrieben, 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 / Rendermodus abgebrochen 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 --resume empfohlen.

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):

AusgabeGrößeVerwendung
H.264 .mp4118 KBFertiges Video direkt senden
ProRes 4444 .mov760 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 --duration explizit 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 (Standard trim schneidet 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
VoreinstellungGrößeFür wen
douyin / tiktok / vertical1080×1920Douyin, Kuaishou, Video Account, Reels, Shorts
xiaohongshu / xhs1080×1440Xiaohongshu 3:4
instagram / ig1080×1350IG Feed 4:5
square1080×1080Quadratisch 1:1
bilibili / youtube / landscape1920×1080Querformat 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. --presets garantiert 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.
  • warnings ist ein Array: Parameterkonflikte, Abweichungen zwischen Audio- und Videolänge, unbekannte Themennamen landen hier. Leeres Array = sauber.
  • duration ist die tatsächliche Länge des fertigen Videos (von ffmpeg ausgelesen), nicht der Planwert.
  • Bei --presets wird output zu einem Array, zusätzlich gibt es presets: [{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.mmm funktioniert auch) und JSON ([{start,end,text}]).
  • Wird einmal geparst und als window.__cues injiziert. Die Vorlage sucht anhand von t des aktuellen Frames selbst, welcher Satz angezeigt werden soll. Bei Verwendung von --srt in der Vorlage hat __cues Vorrang; wenn nicht vorhanden, wird auf CFG.lines zurückgegriffen – so funktioniert dieselbe Vorlage mit beiden Methoden.
  • --cue-log gibt eine CSV mit frame,t,cue_index,cue_start,cue_end aus. 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:

  1. Lade die Umgebung (entweder aus ~/.workbuddy/.html2video-env oder aus Umgebungsvariablen)
  2. Suche oder starte einen lokalen HTTP-Server
  3. Starte ein Headless-Chromium (Playwright) und öffne die Seite
  4. Warte auf window.__ready === true (Zeitüberschreitung: --readytimeout, Standard 8s)
  5. Rufe für jede Frame-Nummer von 0 bis fps × duration nacheinander __seek(frameIndex / fps) auf
  6. Mache einen Screenshot der Leinwand und speichere ihn als PNG
  7. Nach Abschluss der Screenshots rufe ffmpeg auf: Verbinde PNGs zu einem Video (-crf 18)
  8. Wenn --audio vorhanden ist, füge die Audiospur hinzu (Video-Stream wird kopiert)
  9. 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 diff der 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:

  1. --smoke mit wenigen Frames testen
  2. Gegebenenfalls --headed für die visuelle Inspektion verwenden
  3. Die Browser-Konsole über den HTTP-Server auf Fehler überprüfen
  4. Die --still-Einstellung für die Inspektion einzelner Frames verwenden
  5. 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.

Verwandte Skills