diff --git a/CLAUDE.md b/CLAUDE.md index 8e57193..6106f9b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,12 @@ ES „El pequeño mundo de Pino", FR „Le petit monde de Pino". **Format ab Folge 2:** Folgen entstehen in Remotion nach dem Muster von `remotion/src/folge02/` (Figuren-Bibliothek + Baukasten, siehe -`remotion/README.md`), nicht mehr als Higgsfield-Clips. +`remotion/README.md`), nicht mehr als Higgsfield-Clips. Sobald eine Folge in +Remotion umgesetzt ist, stehen ihre Sprechtexte **nur noch** in +`remotion/src/folgeNN/texte/de.json`, `es.json`, `fr.json`; das Drehbuch +(`drehbuecher/…`) beschreibt Handlung und Bild. Prüfen mit +`npx tsx werkzeuge/texte.mts FNN` (im Ordner `remotion/`), Fassung je Sprache +mit `node werkzeuge/render.mjs FNN --sprache es`. ## Rahmen - **Zielgruppe:** Kinder von 3–6 Jahren. @@ -49,6 +54,7 @@ Projektordner (`clips/`, `ton/`). Auf dem VPS liegen die unter auf dem VPS im gemeinsamen Ordner `/shared/youtube/`. - **Drehbücher nur hier pflegen.** Das Repo ist die gültige Fassung; das frühere Plan-Dokument (Artifact) dient nur noch als Übersicht. -- Alle drei Sprachfassungen einer Folge gemeinsam ändern, damit sie +- Alle drei Sprachfassungen einer Folge gemeinsam ändern (bei Remotion-Folgen + in `texte/.json`), damit sie inhaltlich gleich bleiben. - Inhalte kindgerecht: keine Gewalt, keine Angstszenen, kurze Sätze. diff --git a/drehbuecher/folge02-pino-ist-muede/de.md b/drehbuecher/folge02-pino-ist-muede/de.md index 51bd813..4579fd9 100644 --- a/drehbuecher/folge02-pino-ist-muede/de.md +++ b/drehbuecher/folge02-pino-ist-muede/de.md @@ -2,6 +2,8 @@ Drehbuch, deutsche Fassung, Entwurf vom 25.10.2026. Online am 22.11.2026. +> **Sprechtexte fürs Video:** seit 02.11.2026 gilt `remotion/src/folge02/texte/de.json` (Zeilen-IDs wie `s2/e1`). Dieses Drehbuch beschreibt Handlung und Bild; bei Abweichungen im Text gilt die JSON-Datei. + ## Eckdaten Pino gähnt den ganzen Tag, und Lulu und die Erzählstimme erklären den Winterschlaf. Zu dritt bauen sie Pino ein Nest aus Blättern. Aber Pino will nicht schlafen, bevor das Winterfest da ist. Noa hat die Idee mit dem Kalender. So leitet die Folge in den Adventskalender ab 1. Dezember über. diff --git a/drehbuecher/folge02-pino-ist-muede/es.md b/drehbuecher/folge02-pino-ist-muede/es.md index fb36baa..db3867f 100644 --- a/drehbuecher/folge02-pino-ist-muede/es.md +++ b/drehbuecher/folge02-pino-ist-muede/es.md @@ -2,6 +2,8 @@ Spanische Sprechfassung für ElevenLabs, Entwurf vom 26.10.2026. Neutrales Spanisch für Spanien und Lateinamerika (kein „vosotros“, kein „coger“). Die Bilder sind dieselben wie in der deutschen Fassung. *Kursiv* = Pausen und Geräusche, nicht einsprechen. +> **Sprechtexte fürs Video:** seit 02.11.2026 gilt `remotion/src/folge02/texte/es.json`. Bei Abweichungen gilt die JSON-Datei. + ## Escena 1: Intro (0:00–0:15) *Música de la serie.* diff --git a/drehbuecher/folge02-pino-ist-muede/fr.md b/drehbuecher/folge02-pino-ist-muede/fr.md index 83ca2eb..f79dae2 100644 --- a/drehbuecher/folge02-pino-ist-muede/fr.md +++ b/drehbuecher/folge02-pino-ist-muede/fr.md @@ -2,6 +2,8 @@ Französische Sprechfassung für ElevenLabs, Entwurf vom 26.10.2026. Die Figuren sagen „tu“, die Erzählstimme spricht das Kind mit „tu“ an. Die Bilder sind dieselben wie in der deutschen Fassung. *Kursiv* = Pausen und Geräusche, nicht einsprechen. +> **Sprechtexte fürs Video:** seit 02.11.2026 gilt `remotion/src/folge02/texte/fr.json`. Bei Abweichungen gilt die JSON-Datei. + ## Scène 1 : Intro (0:00–0:15) *Générique.* diff --git a/remotion/README.md b/remotion/README.md index e8acfcd..d5bca8d 100644 --- a/remotion/README.md +++ b/remotion/README.md @@ -79,7 +79,8 @@ Mitmach-Pausen zeigen ein „?“. | `src/baukasten/untertitel.tsx` | Untertitel, Mitmach-Pause, Stimmen abspielen | | `src/baukasten/folge.tsx` | macht aus der Szenenliste die Kompositionen | | `src/baukasten/posen.ts` | automatisch erzeugt von `werkzeuge/posen.py` | -| `src/folge02/` | Folge 2: `Folge02.tsx` (Szenenliste), `szene2.tsx` … `szene7.tsx`, `lied.tsx`, `stimmen.json` | +| `src/baukasten/sprache.ts` | gewählte Sprache und die Wörter des Baukastens je Sprache | +| `src/folge02/` | Folge 2: `Folge02.tsx` (Szenenliste), `szene2.tsx` … `szene7.tsx`, `lied.tsx`, `plan.ts`, `texte/.json` (Sprechtexte), `stimmen/.json` (Aufnahmen) | | `public/bibliothek/figuren/` | freigestellte Standbilder (WebP, im Git) | | `public/bibliothek/clips/` | freigestellte Clips (WebM mit Alpha, nicht im Git; VPS: Link auf `/shared/youtube/figuren_bibliothek/clips_frei/`) | | `public/stimmen/` | Sprachaufnahmen (nicht im Git; VPS: Link auf `/shared/youtube/stimmen/`) | @@ -87,8 +88,9 @@ Mitmach-Pausen zeigen ein „?“. **Kompositionen:** `F02` ist die ganze Folge, `F02-01-Intro`, `F02-02-Gaehnen-ueberall` … jede Szene einzeln. -**Neue Folge:** Ordner `src/folgeNN/` anlegen, `plan.ts` und `stimmen.json` -(`{}`) aus Folge 2 kopieren und den Ordnernamen anpassen, Szenen nach dem +**Neue Folge:** Ordner `src/folgeNN/` anlegen, `plan.ts` aus Folge 2 kopieren, +`stimmen/de.json`, `es.json`, `fr.json` mit `{}` anlegen, `texte/.json` +mit den Sprechtexten füllen, Ordnernamen anpassen, Szenen nach dem Muster von `szene2.tsx` schreiben (oben die Zeilen mit `plane('sX', [...])`, darunter Posenfolgen und Kamerafahrt, die sich auf `z..start/ende` beziehen), in `FolgeNN.tsx` auflisten und in `src/Root.tsx` eintragen. @@ -99,7 +101,8 @@ node werkzeuge/render.mjs F02 --entwurf # schnelle Fassung zum Begutachten node werkzeuge/render.mjs F02 # volle Qualität node werkzeuge/render.mjs F02 --szene 3 # nur Szene 3 neu ``` -Das Skript rendert jede Szene einzeln nach `out/F02/` und klebt sie +Mit `--sprache es` bzw. `fr` entsteht die jeweilige Fassung (siehe unten). +Das Skript rendert jede Szene einzeln nach `out/F02//` und klebt sie zusammen. Szenen, deren Code, Bilder oder Aufnahmen sich nicht geändert haben, werden übersprungen. Im Entwurf stehen oben links Gesamtzeit, Szene, Szenenzeit und Zeilen-ID – für Rückmeldungen einfach diese Angaben nennen @@ -110,14 +113,36 @@ Auf dem VPS: vorher `export LD_LIBRARY_PATH=$HOME/.local/chrome-libs/root/usr/li `npx remotion studio` öffnet alle Kompositionen im Browser; Änderungen am Code sind sofort zu sehen. Anleitung für einen Windows-PC: `ANLEITUNG_PC.md`. +### Sprachen (DE, ES, FR) +Die Sprechtexte stehen **nur** in `src/folgeNN/texte/de.json`, `es.json`, +`fr.json` (Titel der Folge, Liedtitel und jede Zeile unter ihrem Schlüssel +`szene/zeile`, z. B. `s2/e1`). Die Szenendateien enthalten nur Ablauf und +Bewegung: `{id: 'e1', wer: 'ERZ'}` ohne Text. Wörter, die der Baukasten selbst +mitbringt (Kanalname, „Heute: …“, Sprechernamen im Untertitel, Kalendertitel, +„Hatschi!“), stehen in `src/baukasten/sprache.ts`. + +Die Sprache wird beim Rendern gewählt; der Zeitplan richtet sich nach der +Länge der Texte bzw. Aufnahmen dieser Sprache, die Bewegungen rutschen mit. +```bash +npx tsx werkzeuge/texte.mts F02 # fehlt in einer Sprache etwas? +npx tsx werkzeuge/texte.mts F02 --tabelle # alle Sprachen nebeneinander zum Gegenlesen +node werkzeuge/render.mjs F02 --sprache es # spanische Fassung → out/F02/F02-es.mp4 +npx remotion studio --props='{"sprache":"fr"}' +``` +Orte mit Schrift im Bild (bisher nur `kalender`) gibt es je Sprache als +`public/orte/kalender_es.webp` usw. (vorberechnen mit +`--props='{"sprache":"es"}'`). Fehlt eine Übersetzung, zeigt der Untertitel +`[fehlt: s2/e1]`. + ### Stimmen (ElevenLabs) 1. Stimmen-IDs je Sprecher in `werkzeuge/stimmen.config.json` eintragen - (`ALLE` und `NOA_LULU` verweisen auf einen anderen Sprecher). + (`ALLE` und `NOA_LULU` verweisen auf einen anderen Sprecher). Andere Stimmen + für eine Sprache unter `"sprachen": {"es": {"stimmen": {"ERZ": "…"}}}`. 2. `npx tsx werkzeuge/stimmen.mts F02 --probe` zeigt, welche Zeilen erzeugt würden, und zählt die Zeichen (kostet nichts). -3. `ELEVENLABS_API_KEY=… npx tsx werkzeuge/stimmen.mts F02` erzeugt die - Aufnahmen nach `public/stimmen/folge02/` und trägt ihre Länge in - `src/folge02/stimmen.json` ein. Der Zeitplan richtet sich danach; ändert +3. `ELEVENLABS_API_KEY=… npx tsx werkzeuge/stimmen.mts F02 --sprache es` erzeugt die + Aufnahmen nach `public/stimmen/folge02/es/` und trägt ihre Länge in + `src/folge02/stimmen/es.json` ein (ohne `--sprache`: Deutsch). Der Zeitplan richtet sich danach; ändert sich ein Text, wird nur diese Zeile neu erzeugt. Untertitel ausblenden: `--props='{"untertitel": false}'` (bzw. in diff --git a/remotion/public/orte/kalender_es.webp b/remotion/public/orte/kalender_es.webp new file mode 100644 index 0000000..4c12438 Binary files /dev/null and b/remotion/public/orte/kalender_es.webp differ diff --git a/remotion/public/orte/kalender_fr.webp b/remotion/public/orte/kalender_fr.webp new file mode 100644 index 0000000..3a5894a Binary files /dev/null and b/remotion/public/orte/kalender_fr.webp differ diff --git a/remotion/src/baukasten/buch.tsx b/remotion/src/baukasten/buch.tsx index b4f9815..9b9e5b9 100644 --- a/remotion/src/baukasten/buch.tsx +++ b/remotion/src/baukasten/buch.tsx @@ -4,6 +4,7 @@ import {kurve} from '../bewegung'; import {Figur, Kamera} from './figur'; import {Blende, Vignette, kamFahrt} from './helfer'; import {Blatt} from './natur'; +import {W} from './sprache'; import {SCHRIFT, Untertitel} from './untertitel'; import {plane, type Plan, type Zeile} from './zeit'; @@ -32,7 +33,7 @@ const Seite: React.FC<{links: number; children?: React.ReactNode}> = ({links, ch ); const Titel: React.FC<{text: string; groesse: number; farbe?: string; y: number}> = ({text, groesse, farbe = '#c8642c', y}) => ( -
{text}
+
{text}
); // Deckel: dreht sich um den Buchrücken (0° = zu, -180° = offen) @@ -49,7 +50,7 @@ const Deckel: React.FC<{winkel: number}> = ({winkel}) => ( }} >
- + 1 ? 72 : 84} farbe="#f6dc95" y={W.kanalZeilen.length > 1 ? 92 : 120} />
@@ -61,8 +62,8 @@ const Deckel: React.FC<{winkel: number}> = ({winkel}) => ( {[0, 1, 2, 3, 4, 5].map((i) => ( ))} - - + 1 ? 180 : 230} /> + 1 ? 365 : 340} />
@@ -94,8 +95,8 @@ const Tisch: React.FC = () => ( // Die Folge plant die Zeilen selbst (mit `introZeilen`), damit Aufnahmen greifen. export const introZeilen = (folgenTitel: string): Zeile[] => [ {id: 'buch', dauer: 2.2}, - {id: 't1', wer: 'ERZ', text: 'Pinos kleine Welt.'}, - {id: 't2', wer: 'ERZ', text: `Heute: ${folgenTitel}.`}, + {id: 't1', wer: 'ERZ', text: `${W.kanal}.`}, + {id: 't2', wer: 'ERZ', text: W.heute(folgenTitel)}, ]; export const INTRO_OPTIONEN = {vorlauf: 0.3, nachlauf: 1.6}; diff --git a/remotion/src/baukasten/orte.tsx b/remotion/src/baukasten/orte.tsx index 627775d..8b09b61 100644 --- a/remotion/src/baukasten/orte.tsx +++ b/remotion/src/baukasten/orte.tsx @@ -1,5 +1,6 @@ import React from 'react'; import {AbsoluteFill, Img, random, staticFile} from 'remotion'; +import {SPRACHE, W} from './sprache'; import './untertitel'; // lädt die Schrift für den Kalender import {NEST_BILD, NestBlaetter} from './natur'; @@ -330,7 +331,7 @@ export const KalenderSVG: React.FC<{leer?: boolean}> = ({leer = false}) => { {!leer && - Pinos Winterfest-Kalender + {W.kalenderTitel} {nummern.map((n, i) => { const x = 330 + (i % 6) * 215; @@ -430,7 +431,7 @@ export const KalenderMalen: React.FC<{p: number; wackeln: number}> = ({p, wackel - Pinos Winterfest-Kalender + {W.kalenderTitel} {new Array(24).fill(0).map((_, i) => { if (i > aktiv || p < titelAnteil) return null; @@ -473,7 +474,11 @@ export const KalenderMalen: React.FC<{p: number; wackeln: number}> = ({p, wackel }; // Vorberechnete Bilder einbinden -export const Ort: React.FC<{name: string}> = ({name}) => ; +// Orte mit Schrift im Bild gibt es je Sprache (orte/kalender_es.webp …) +export const SPRACH_ORTE = ['kalender']; +const ortDatei = (name: string) => (SPRACH_ORTE.includes(name) && SPRACHE !== 'de' ? `${name}_${SPRACHE}` : name); + +export const Ort: React.FC<{name: string}> = ({name}) => ; // Kompositionen zum Vorberechnen export const ORTE: Record = { diff --git a/remotion/src/baukasten/sprache.ts b/remotion/src/baukasten/sprache.ts new file mode 100644 index 0000000..4bc603a --- /dev/null +++ b/remotion/src/baukasten/sprache.ts @@ -0,0 +1,72 @@ +import {getInputProps} from 'remotion'; +import type {Sprecher} from './zeit'; + +// Sprache des Videos. Wird beim Rendern bzw. in der Vorschau gewählt: +// node werkzeuge/render.mjs F02 --sprache es +// npx remotion studio --props='{"sprache":"fr"}' +// In Node-Skripten (szenenliste, stimmen) über die Umgebungsvariable SPRACHE. +// Ohne Angabe: Deutsch. + +export const SPRACHEN = ['de', 'es', 'fr'] as const; +export type Sprache = (typeof SPRACHEN)[number]; + +const waehle = (): Sprache => { + const s = typeof window === 'undefined' ? (typeof process === 'undefined' ? undefined : process.env.SPRACHE) : getInputProps().sprache; + return SPRACHEN.includes(s as Sprache) ? (s as Sprache) : 'de'; +}; + +export const SPRACHE: Sprache = waehle(); + +// Wörter, die der Baukasten selbst ins Bild oder in den Ton bringt +type Woerter = { + kanal: string; + kanalZeilen: string[]; // Umbruch auf dem Buchdeckel + buchUntertitel: string; + heute: (titel: string) => string; + namen: Record; + leise: string; + kalenderTitel: string; + hatschi: string; +}; + +const WOERTER: Record = { + de: { + kanal: 'Pinos kleine Welt', + kanalZeilen: ['Pinos kleine Welt'], + buchUntertitel: 'Eine Geschichte aus Noas Garten', + heute: (t) => `Heute: ${t}.`, + namen: {ERZ: 'Erzählstimme', PINO: 'Pino', NOA: 'Noa', LULU: 'Lulu', ALLE: 'Alle', NOA_LULU: 'Noa und Lulu'}, + leise: '(leise)', + kalenderTitel: 'Pinos Winterfest-Kalender', + hatschi: 'Hatschi!', + }, + es: { + kanal: 'El pequeño mundo de Pino', + kanalZeilen: ['El pequeño mundo', 'de Pino'], + buchUntertitel: 'Una historia del jardín de Noa', + heute: (t) => `Hoy: ${t}.`, + namen: {ERZ: 'Narración', PINO: 'Pino', NOA: 'Noa', LULU: 'Lulu', ALLE: 'Todos', NOA_LULU: 'Noa y Lulu'}, + leise: '(bajito)', + kalenderTitel: 'El calendario de Pino', + hatschi: '¡Achís!', + }, + fr: { + kanal: 'Le petit monde de Pino', + kanalZeilen: ['Le petit monde', 'de Pino'], + buchUntertitel: 'Une histoire du jardin de Noa', + heute: (t) => `Aujourd'hui : ${t}.`, + namen: {ERZ: 'Narration', PINO: 'Pino', NOA: 'Noa', LULU: 'Lulu', ALLE: 'Tous', NOA_LULU: 'Noa et Lulu'}, + leise: '(doucement)', + kalenderTitel: 'Le calendrier de Pino', + hatschi: 'Atchoum !', + }, +}; + +export const W = WOERTER[SPRACHE]; + +// Texte einer Folge: je Sprache eine Datei src/folgeNN/texte/.json +export type FolgenTexte = { + titel: string; + lied?: string; + zeilen: Record; // Schlüssel „szene/zeile“, z. B. „s2/e1“ +}; diff --git a/remotion/src/baukasten/untertitel.tsx b/remotion/src/baukasten/untertitel.tsx index 7970a16..9347d98 100644 --- a/remotion/src/baukasten/untertitel.tsx +++ b/remotion/src/baukasten/untertitel.tsx @@ -1,18 +1,12 @@ import React from 'react'; import {Audio, Sequence, getInputProps, getStaticFiles, interpolate, staticFile, useCurrentFrame} from 'remotion'; import {loadFont} from '@remotion/google-fonts/Fredoka'; +import {W} from './sprache'; import type {Geplant, Sprecher} from './zeit'; export const {fontFamily: SCHRIFT} = loadFont('normal', {weights: ['500', '600'], subsets: ['latin']}); -const NAME: Record = { - ERZ: 'Erzählstimme', - PINO: 'Pino', - NOA: 'Noa', - LULU: 'Lulu', - ALLE: 'Alle', - NOA_LULU: 'Noa und Lulu', -}; +const NAME = W.namen; const FARBE: Record = { ERZ: '#7a6f66', PINO: '#3f5a8a', @@ -74,7 +68,7 @@ export const Untertitel: React.FC<{zeilen: Geplant[]}> = ({zeilen}) => { >
{NAME[z.wer]} - {z.leise ? ' (leise)' : ''} + {z.leise ? ` ${W.leise}` : ''}
{z.text}
diff --git a/remotion/src/baukasten/zeit.ts b/remotion/src/baukasten/zeit.ts index a9c4369..ae65ca4 100644 --- a/remotion/src/baukasten/zeit.ts +++ b/remotion/src/baukasten/zeit.ts @@ -1,8 +1,8 @@ // Zeitplan einer Szene aus ihren Sprechzeilen. // // Ohne Sprachaufnahme wird die Dauer jeder Zeile aus der Textlänge geschätzt. -// Gibt es eine Aufnahme (werkzeuge/stimmen.ts schreibt sie in die -// stimmen.json der Folge), gilt ihre echte Länge, und alle Bewegungen, die +// Gibt es eine Aufnahme (werkzeuge/stimmen.mts schreibt sie in die +// stimmen/.json der Folge), gilt ihre echte Länge, und alle Bewegungen, die // sich auf die Zeilen beziehen, rutschen automatisch mit. export const FPS = 30; diff --git a/remotion/src/folge02/Folge02.tsx b/remotion/src/folge02/Folge02.tsx index 9b2999a..c58418c 100644 --- a/remotion/src/folge02/Folge02.tsx +++ b/remotion/src/folge02/Folge02.tsx @@ -2,7 +2,7 @@ import React from 'react'; import {INTRO_OPTIONEN, Intro, OUTRO, Outro, introZeilen} from '../baukasten/buch'; import {baueFolge, type SzenenEintrag} from '../baukasten/folge'; import {LIED, Lied} from './lied'; -import {plane} from './plan'; +import {TITEL, plane} from './plan'; import {S2, Szene2} from './szene2'; import {S3, Szene3} from './szene3'; import {S4, Szene4} from './szene4'; @@ -12,7 +12,6 @@ import {S7, Szene7} from './szene7'; // Folge 2 „Pino ist müde“ – komplett per Code aus der Figuren-Bibliothek. -const TITEL = 'Pino ist müde'; const INTRO = plane('intro', introZeilen(TITEL), INTRO_OPTIONEN); export const SZENEN: SzenenEintrag[] = [ diff --git a/remotion/src/folge02/lied.tsx b/remotion/src/folge02/lied.tsx index 51fb0a0..841c08c 100644 --- a/remotion/src/folge02/lied.tsx +++ b/remotion/src/folge02/lied.tsx @@ -4,7 +4,7 @@ import {Figur, Kamera} from '../baukasten/figur'; import {Blende, Vignette} from '../baukasten/helfer'; import {BODEN, Ort} from '../baukasten/orte'; import {SCHRIFT, Untertitel} from '../baukasten/untertitel'; -import {plane} from './plan'; +import {TEXTE, plane} from './plan'; // Refrain „Einer klein und einer groß“ (aus Folge 1), einmal. Die drei wippen im Takt. @@ -12,10 +12,10 @@ const TAKT = 19; // Frames pro Schlag (ca. 95 bpm) export const LIED = plane('lied', [ - {id: 'r1', wer: 'ALLE', text: 'Einer klein und einer groß,', dauer: 3.2}, - {id: 'r2', wer: 'ALLE', text: 'und dazwischen – na, wer bloß?', dauer: 3.2}, - {id: 'r3', wer: 'ALLE', text: 'Noa steht in der Mitte drin,', dauer: 3.2}, - {id: 'r4', wer: 'ALLE', text: 'zusammen kriegen wir alles hin!', dauer: 3.6}, + {id: 'r1', wer: 'ALLE', dauer: 3.2}, + {id: 'r2', wer: 'ALLE', dauer: 3.2}, + {id: 'r3', wer: 'ALLE', dauer: 3.2}, + {id: 'r4', wer: 'ALLE', dauer: 3.6}, ], {vorlauf: 1.0, nachlauf: 1.0}, ); @@ -56,7 +56,7 @@ export const Lied: React.FC = () => { return
; })}
- ♫ Einer klein und einer groß ♫ + ♫ {TEXTE.lied} ♫
diff --git a/remotion/src/folge02/plan.ts b/remotion/src/folge02/plan.ts index 4bc310d..9dc4853 100644 --- a/remotion/src/folge02/plan.ts +++ b/remotion/src/folge02/plan.ts @@ -1,7 +1,29 @@ +import {SPRACHE, type FolgenTexte} from '../baukasten/sprache'; import {plane as basis, type PlanOptionen, type Zeile} from '../baukasten/zeit'; -import AUFNAHMEN from './stimmen.json'; +import DE from './texte/de.json'; +import ES from './texte/es.json'; +import FR from './texte/fr.json'; +import STIMMEN_DE from './stimmen/de.json'; +import STIMMEN_ES from './stimmen/es.json'; +import STIMMEN_FR from './stimmen/fr.json'; -// Zeitplan einer Szene dieser Folge; nimmt vorhandene Sprachaufnahmen -// (public/stimmen/folge02/, erzeugt von werkzeuge/stimmen.ts) automatisch mit. +// Sprechtexte stehen nur in texte/.json (Schlüssel „szene/zeile“), +// die Szenen enthalten nur Ablauf und Bewegung. Aufnahmen je Sprache in +// stimmen/.json bzw. public/stimmen/folge02// (erzeugt von +// werkzeuge/stimmen.mts). + +export const TEXTE: FolgenTexte = {de: DE, es: ES, fr: FR}[SPRACHE]; +const AUFNAHMEN = {de: STIMMEN_DE, es: STIMMEN_ES, fr: STIMMEN_FR}[SPRACHE]; + +export const TITEL = TEXTE.titel; + +// Zeitplan einer Szene dieser Folge, mit den Texten der gewählten Sprache export const plane = (szene: string, zeilen: Zeile[], opt: PlanOptionen = {}) => - basis(zeilen, {...opt, szene, aufnahmen: AUFNAHMEN, ordner: 'stimmen/folge02'}); + basis( + zeilen.map((z) => { + if (z.text !== undefined || !z.wer) return z; + const text = TEXTE.zeilen[`${szene}/${z.id}`]; + return {...z, text: text ?? `[fehlt: ${szene}/${z.id}]`}; + }), + {...opt, szene, aufnahmen: AUFNAHMEN, ordner: `stimmen/folge02/${SPRACHE}`}, + ); diff --git a/remotion/src/folge02/stimmen.json b/remotion/src/folge02/stimmen/de.json similarity index 100% rename from remotion/src/folge02/stimmen.json rename to remotion/src/folge02/stimmen/de.json diff --git a/remotion/src/folge02/stimmen/es.json b/remotion/src/folge02/stimmen/es.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/remotion/src/folge02/stimmen/es.json @@ -0,0 +1 @@ +{} diff --git a/remotion/src/folge02/stimmen/fr.json b/remotion/src/folge02/stimmen/fr.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/remotion/src/folge02/stimmen/fr.json @@ -0,0 +1 @@ +{} diff --git a/remotion/src/folge02/szene2.tsx b/remotion/src/folge02/szene2.tsx index ccca3cd..c5b13a0 100644 --- a/remotion/src/folge02/szene2.tsx +++ b/remotion/src/folge02/szene2.tsx @@ -15,18 +15,18 @@ export const PINO_X = 1060; export const S2 = plane('s2', [ {id: 'bild0', dauer: 2.5}, - {id: 'e1', wer: 'ERZ', text: 'Es ist kalt geworden in Noas Garten.'}, - {id: 'e2', wer: 'ERZ', text: 'Die Bäume haben fast alle Blätter verloren.'}, + {id: 'e1', wer: 'ERZ'}, + {id: 'e2', wer: 'ERZ'}, {id: 'bild1', dauer: 2.6}, - {id: 'p1', wer: 'PINO', text: 'Hoaaah.', dauer: 2.2}, - {id: 'n1', wer: 'NOA', text: 'Guten Morgen, Pino! Wollen wir spielen?'}, - {id: 'p2', wer: 'PINO', text: 'Ja! Ich bin ganz wach. Gaaanz … wach …', dauer: 4.6}, + {id: 'p1', wer: 'PINO', dauer: 2.2}, + {id: 'n1', wer: 'NOA'}, + {id: 'p2', wer: 'PINO', dauer: 4.6}, {id: 'bild2', dauer: 3.2}, - {id: 'n2', wer: 'NOA', text: 'Pino? Pino!', leise: true, dauer: 2.2}, + {id: 'n2', wer: 'NOA', leise: true, dauer: 2.2}, {id: 'bild3', dauer: 1.0}, - {id: 'p3', wer: 'PINO', text: 'Was? Ich hab nicht geschlafen!'}, - {id: 'e3', wer: 'ERZ', text: 'Hat er doch.', dauer: 1.6}, - {id: 'e4', wer: 'ERZ', text: 'Was meinst du: Warum ist Pino so müde?', pause: 3}, + {id: 'p3', wer: 'PINO'}, + {id: 'e3', wer: 'ERZ', dauer: 1.6}, + {id: 'e4', wer: 'ERZ', pause: 3}, ]); export const Szene2: React.FC = () => { diff --git a/remotion/src/folge02/szene3.tsx b/remotion/src/folge02/szene3.tsx index 1ce9f97..4f00c97 100644 --- a/remotion/src/folge02/szene3.tsx +++ b/remotion/src/folge02/szene3.tsx @@ -16,19 +16,19 @@ export const LULU_X = 1520; export const S3 = plane('s3', [ {id: 'bild0', dauer: 3.2}, {id: 'bild1', dauer: 2.4}, - {id: 'l1', wer: 'LULU', text: 'Guten Morgen, ihr zwei!'}, - {id: 'l2', wer: 'LULU', text: 'Von hier oben sehe ich: Die Vögel fliegen weg, ganz weit in den Süden.'}, - {id: 'l3', wer: 'LULU', text: 'Und die Bäume sind kahl. Der Winter kommt.'}, - {id: 'e1', wer: 'ERZ', text: 'Wenn es kalt wird, finden Igel kaum noch etwas zu fressen.'}, - {id: 'e2', wer: 'ERZ', text: 'Darum machen sie etwas ganz Besonderes. Sie schlafen.'}, - {id: 'e3', wer: 'ERZ', text: 'Den ganzen Winter lang, bis zum Frühling. Das heißt Winterschlaf.'}, - {id: 'p1', wer: 'PINO', text: 'Den ganzen Winter? Aber dann verpasse ich ja das Winterfest!'}, + {id: 'l1', wer: 'LULU'}, + {id: 'l2', wer: 'LULU'}, + {id: 'l3', wer: 'LULU'}, + {id: 'e1', wer: 'ERZ'}, + {id: 'e2', wer: 'ERZ'}, + {id: 'e3', wer: 'ERZ'}, + {id: 'p1', wer: 'PINO'}, {id: 'bild2', dauer: 2.4}, - {id: 'e4', wer: 'ERZ', text: 'Schau mal, Pinos Stacheln hängen ganz schlaff herunter.'}, - {id: 'e5', wer: 'ERZ', text: 'Wie fühlt sich Pino jetzt?', pause: 3}, - {id: 'e6', wer: 'ERZ', text: 'Ja. Pino ist traurig.'}, + {id: 'e4', wer: 'ERZ'}, + {id: 'e5', wer: 'ERZ', pause: 3}, + {id: 'e6', wer: 'ERZ'}, {id: 'bild3', dauer: 2.6}, - {id: 'n1', wer: 'NOA', text: 'Ach, Pino. Bis zum Winterfest ist es doch noch ganz lange hin.'}, + {id: 'n1', wer: 'NOA'}, ]); // Das große Blatt, auf das Pino sich setzt diff --git a/remotion/src/folge02/szene4.tsx b/remotion/src/folge02/szene4.tsx index b3c3850..972f832 100644 --- a/remotion/src/folge02/szene4.tsx +++ b/remotion/src/folge02/szene4.tsx @@ -7,6 +7,7 @@ import {Blaetterregen, Blatt, LAUB, Nest, Ruf} from '../baukasten/natur'; import {BODEN, NEST_X, Ort} from '../baukasten/orte'; import {NOA_X, PINO_X} from './szene2'; import {GROSSES_BLATT_X} from './szene3'; +import {W} from '../baukasten/sprache'; import {Untertitel} from '../baukasten/untertitel'; import {plane} from './plan'; @@ -18,26 +19,26 @@ const LULU_BAUM_X = 650; const REIHE = [0, 1, 2, 3, 4].map((i) => ({x: 1290 + i * 78, y: BODEN + 4})); export const S4 = plane('s4', [ - {id: 'n1', wer: 'NOA', text: 'Wenn du so müde bist, brauchst du ein warmes Bett.'}, - {id: 'e1', wer: 'ERZ', text: 'Igel bauen sich für den Winter ein Nest.'}, - {id: 'e2', wer: 'ERZ', text: 'Aus ganz vielen Blättern, unter einer Hecke oder einem Holzstapel.'}, - {id: 'e3', wer: 'ERZ', text: 'Da ist es trocken und warm.'}, - {id: 'n2', wer: 'NOA', text: 'Zählst du mit?', pause: 0.6}, - {id: 'c1', wer: 'NOA', text: 'Eins …', dauer: 1.1, pause: 0.5}, - {id: 'c2', wer: 'NOA', text: 'Zwei …', dauer: 1.1, pause: 0.5}, - {id: 'c3', wer: 'NOA', text: 'Drei …', dauer: 1.1, pause: 0.5}, - {id: 'c4', wer: 'NOA', text: 'Vier …', dauer: 1.1, pause: 0.5}, - {id: 'c5', wer: 'NOA', text: 'Fünf!', dauer: 1.4}, + {id: 'n1', wer: 'NOA'}, + {id: 'e1', wer: 'ERZ'}, + {id: 'e2', wer: 'ERZ'}, + {id: 'e3', wer: 'ERZ'}, + {id: 'n2', wer: 'NOA', pause: 0.6}, + {id: 'c1', wer: 'NOA', dauer: 1.1, pause: 0.5}, + {id: 'c2', wer: 'NOA', dauer: 1.1, pause: 0.5}, + {id: 'c3', wer: 'NOA', dauer: 1.1, pause: 0.5}, + {id: 'c4', wer: 'NOA', dauer: 1.1, pause: 0.5}, + {id: 'c5', wer: 'NOA', dauer: 1.4}, {id: 'bild1', dauer: 2.6}, - {id: 'l1', wer: 'LULU', text: 'Hier oben hängen noch welche! Die erreicht sonst keiner.'}, + {id: 'l1', wer: 'LULU'}, {id: 'bild2', dauer: 2.4}, {id: 'bild3', dauer: 2.6}, - {id: 'l2', wer: 'LULU', text: 'Oh! Entschuldigung!'}, - {id: 'n3', wer: 'NOA', text: 'Macht nichts. Jetzt haben wir noch mehr Blätter.'}, + {id: 'l2', wer: 'LULU'}, + {id: 'n3', wer: 'NOA'}, {id: 'bild4', dauer: 4.6}, {id: 'bild5', dauer: 2.2}, - {id: 'e4', wer: 'ERZ', text: 'Ein Igelnest braucht ganz viele Blätter.'}, - {id: 'e5', wer: 'ERZ', text: 'Wenn ihr im Garten einen Laubhaufen liegen lasst, schenkt ihr einem Igel vielleicht ein Winterbettchen.'}, + {id: 'e4', wer: 'ERZ'}, + {id: 'e5', wer: 'ERZ'}, ]); export const Szene4: React.FC = () => { @@ -247,7 +248,7 @@ export const Szene4: React.FC = () => { {zaehlen.map((c, i) => ( ))} - + diff --git a/remotion/src/folge02/szene5.tsx b/remotion/src/folge02/szene5.tsx index 786e4ae..4223ef2 100644 --- a/remotion/src/folge02/szene5.tsx +++ b/remotion/src/folge02/szene5.tsx @@ -17,13 +17,13 @@ const LULU_X5 = 1790; export const S5 = plane('s5', [ {id: 'bild1', dauer: 3.0}, {id: 'bild2', dauer: 2.2}, - {id: 'p1', wer: 'PINO', text: 'Nein! Ich will nicht schlafen, bevor das Winterfest da ist!'}, - {id: 'l1', wer: 'LULU', text: 'Aber Pino, du bist doch so müde!'}, + {id: 'p1', wer: 'PINO'}, + {id: 'l1', wer: 'LULU'}, {id: 'bild3', dauer: 3.0}, - {id: 'e1', wer: 'ERZ', text: 'Hmm. Was können Noa und Lulu tun, damit Pino bis zum Winterfest wach bleibt?'}, - {id: 'e2', wer: 'ERZ', text: 'Was meinst du?', pause: 3}, + {id: 'e1', wer: 'ERZ'}, + {id: 'e2', wer: 'ERZ', pause: 3}, {id: 'bild4', dauer: 1.2}, - {id: 'n1', wer: 'NOA', text: 'Ich hab eine Idee!'}, + {id: 'n1', wer: 'NOA'}, ]); const Denkblase: React.FC<{x: number; y: number; start: number; ende: number}> = ({x, y, start, ende}) => { diff --git a/remotion/src/folge02/szene6.tsx b/remotion/src/folge02/szene6.tsx index 4add9b9..1f7e768 100644 --- a/remotion/src/folge02/szene6.tsx +++ b/remotion/src/folge02/szene6.tsx @@ -19,14 +19,14 @@ const LULU_X6 = 1440; export const S6 = plane('s6', [ {id: 'bild0', dauer: 3.0}, {id: 'malen', dauer: 5.5}, - {id: 'l1', wer: 'LULU', text: 'Was malst du da, Noa?'}, - {id: 'n1', wer: 'NOA', text: 'Einen Kalender! Mit 24 Türchen.'}, - {id: 'n2', wer: 'NOA', text: 'Wir öffnen jeden Tag eins. Und hinter jedem Türchen steckt etwas, das Pino wach hält.'}, - {id: 'p1', wer: 'PINO', text: 'Was denn?'}, - {id: 'n3', wer: 'NOA', text: 'Etwas zum Entdecken, zum Basteln, zum Backen und zum Singen.'}, - {id: 'n4', wer: 'NOA', text: 'Und wenn das letzte Türchen offen ist, ist Winterfest!'}, + {id: 'l1', wer: 'LULU'}, + {id: 'n1', wer: 'NOA'}, + {id: 'n2', wer: 'NOA'}, + {id: 'p1', wer: 'PINO'}, + {id: 'n3', wer: 'NOA'}, + {id: 'n4', wer: 'NOA'}, {id: 'bild1', dauer: 2.0}, - {id: 'p2', wer: 'PINO', text: 'Meine Stacheln kribbeln!'}, + {id: 'p2', wer: 'PINO'}, {id: 'bild2', dauer: 4.2}, ]); diff --git a/remotion/src/folge02/szene7.tsx b/remotion/src/folge02/szene7.tsx index 7ffc3d1..443f116 100644 --- a/remotion/src/folge02/szene7.tsx +++ b/remotion/src/folge02/szene7.tsx @@ -17,11 +17,11 @@ const NACHT = 'brightness(0.62) saturate(0.8) hue-rotate(-8deg)'; // nur auf Fig export const S7 = plane('s7', [ {id: 'bild0', dauer: 3.4}, - {id: 'n1', wer: 'NOA', text: 'Schlaf gut, Pino. Nur bis morgen.', leise: true}, - {id: 'e1', wer: 'ERZ', text: 'Ab dem 1. Dezember öffnen Noa, Lulu und Pino jeden Tag ein Türchen.'}, - {id: 'e2', wer: 'ERZ', text: 'Und du kannst mitmachen! Schaffst du es mit Pino bis zum Winterfest?', pause: 3}, - {id: 't1', wer: 'NOA_LULU', text: 'Tschüss! Bis bald!', leise: true, dauer: 2.6}, - {id: 'p1', wer: 'PINO', text: '… bis bald …', leise: true, dauer: 2.6}, + {id: 'n1', wer: 'NOA', leise: true}, + {id: 'e1', wer: 'ERZ'}, + {id: 'e2', wer: 'ERZ', pause: 3}, + {id: 't1', wer: 'NOA_LULU', leise: true, dauer: 2.6}, + {id: 'p1', wer: 'PINO', leise: true, dauer: 2.6}, ], {vorlauf: 0.5, nachlauf: 1.6}, ); diff --git a/remotion/src/folge02/texte/de.json b/remotion/src/folge02/texte/de.json new file mode 100644 index 0000000..e5765ca --- /dev/null +++ b/remotion/src/folge02/texte/de.json @@ -0,0 +1,62 @@ +{ + "titel": "Pino ist müde", + "lied": "Einer klein und einer groß", + "zeilen": { + "s2/e1": "Es ist kalt geworden in Noas Garten.", + "s2/e2": "Die Bäume haben fast alle Blätter verloren.", + "s2/p1": "Hoaaah.", + "s2/n1": "Guten Morgen, Pino! Wollen wir spielen?", + "s2/p2": "Ja! Ich bin ganz wach. Gaaanz … wach …", + "s2/n2": "Pino? Pino!", + "s2/p3": "Was? Ich hab nicht geschlafen!", + "s2/e3": "Hat er doch.", + "s2/e4": "Was meinst du: Warum ist Pino so müde?", + "s3/l1": "Guten Morgen, ihr zwei!", + "s3/l2": "Von hier oben sehe ich: Die Vögel fliegen weg, ganz weit in den Süden.", + "s3/l3": "Und die Bäume sind kahl. Der Winter kommt.", + "s3/e1": "Wenn es kalt wird, finden Igel kaum noch etwas zu fressen.", + "s3/e2": "Darum machen sie etwas ganz Besonderes. Sie schlafen.", + "s3/e3": "Den ganzen Winter lang, bis zum Frühling. Das heißt Winterschlaf.", + "s3/p1": "Den ganzen Winter? Aber dann verpasse ich ja das Winterfest!", + "s3/e4": "Schau mal, Pinos Stacheln hängen ganz schlaff herunter.", + "s3/e5": "Wie fühlt sich Pino jetzt?", + "s3/e6": "Ja. Pino ist traurig.", + "s3/n1": "Ach, Pino. Bis zum Winterfest ist es doch noch ganz lange hin.", + "s4/n1": "Wenn du so müde bist, brauchst du ein warmes Bett.", + "s4/e1": "Igel bauen sich für den Winter ein Nest.", + "s4/e2": "Aus ganz vielen Blättern, unter einer Hecke oder einem Holzstapel.", + "s4/e3": "Da ist es trocken und warm.", + "s4/n2": "Zählst du mit?", + "s4/c1": "Eins …", + "s4/c2": "Zwei …", + "s4/c3": "Drei …", + "s4/c4": "Vier …", + "s4/c5": "Fünf!", + "s4/l1": "Hier oben hängen noch welche! Die erreicht sonst keiner.", + "s4/l2": "Oh! Entschuldigung!", + "s4/n3": "Macht nichts. Jetzt haben wir noch mehr Blätter.", + "s4/e4": "Ein Igelnest braucht ganz viele Blätter.", + "s4/e5": "Wenn ihr im Garten einen Laubhaufen liegen lasst, schenkt ihr einem Igel vielleicht ein Winterbettchen.", + "lied/r1": "Einer klein und einer groß,", + "lied/r2": "und dazwischen – na, wer bloß?", + "lied/r3": "Noa steht in der Mitte drin,", + "lied/r4": "zusammen kriegen wir alles hin!", + "s5/p1": "Nein! Ich will nicht schlafen, bevor das Winterfest da ist!", + "s5/l1": "Aber Pino, du bist doch so müde!", + "s5/e1": "Hmm. Was können Noa und Lulu tun, damit Pino bis zum Winterfest wach bleibt?", + "s5/e2": "Was meinst du?", + "s5/n1": "Ich hab eine Idee!", + "s6/l1": "Was malst du da, Noa?", + "s6/n1": "Einen Kalender! Mit 24 Türchen.", + "s6/n2": "Wir öffnen jeden Tag eins. Und hinter jedem Türchen steckt etwas, das Pino wach hält.", + "s6/p1": "Was denn?", + "s6/n3": "Etwas zum Entdecken, zum Basteln, zum Backen und zum Singen.", + "s6/n4": "Und wenn das letzte Türchen offen ist, ist Winterfest!", + "s6/p2": "Meine Stacheln kribbeln!", + "s7/n1": "Schlaf gut, Pino. Nur bis morgen.", + "s7/e1": "Ab dem 1. Dezember öffnen Noa, Lulu und Pino jeden Tag ein Türchen.", + "s7/e2": "Und du kannst mitmachen! Schaffst du es mit Pino bis zum Winterfest?", + "s7/t1": "Tschüss! Bis bald!", + "s7/p1": "… bis bald …" + } +} diff --git a/remotion/src/folge02/texte/es.json b/remotion/src/folge02/texte/es.json new file mode 100644 index 0000000..db097d2 --- /dev/null +++ b/remotion/src/folge02/texte/es.json @@ -0,0 +1,62 @@ +{ + "titel": "Pino tiene sueño", + "lied": "Uno pequeño y otra alta", + "zeilen": { + "s2/e1": "En el jardín de Noa ya hace frío.", + "s2/e2": "Los árboles han perdido casi todas sus hojas.", + "s2/p1": "Uaaaah.", + "s2/n1": "¡Buenos días, Pino! ¿Jugamos?", + "s2/p2": "¡Sí! Estoy muy despierto. Muuuy… despierto…", + "s2/n2": "¿Pino? ¡Pino!", + "s2/p3": "¿Qué? ¡Yo no estaba durmiendo!", + "s2/e3": "Sí que estaba durmiendo.", + "s2/e4": "¿Tú qué crees? ¿Por qué tiene Pino tanto sueño?", + "s3/l1": "¡Buenos días a los dos!", + "s3/l2": "Desde aquí arriba lo veo: los pájaros se van volando, muy lejos, hacia el sur.", + "s3/l3": "Y los árboles están desnudos. Ya llega el invierno.", + "s3/e1": "Cuando hace frío, los erizos casi no encuentran comida.", + "s3/e2": "Por eso hacen algo muy especial: duermen.", + "s3/e3": "Todo el invierno, hasta la primavera. Eso se llama hibernar.", + "s3/p1": "¿Todo el invierno? ¡Pero entonces me pierdo la Fiesta del Invierno!", + "s3/e4": "Mira, las púas de Pino cuelgan hacia abajo.", + "s3/e5": "¿Cómo se siente Pino ahora?", + "s3/e6": "Sí. Pino está triste.", + "s3/n1": "Ay, Pino. Todavía falta mucho para la Fiesta del Invierno.", + "s4/n1": "Si tienes tanto sueño, necesitas una cama calentita.", + "s4/e1": "Para el invierno, los erizos se hacen un nido.", + "s4/e2": "Con muchísimas hojas, debajo de un seto o de un montón de leña.", + "s4/e3": "Allí está seco y calentito.", + "s4/n2": "¿Cuentas conmigo?", + "s4/c1": "Uno…", + "s4/c2": "Dos…", + "s4/c3": "Tres…", + "s4/c4": "Cuatro…", + "s4/c5": "¡Cinco!", + "s4/l1": "¡Aquí arriba quedan algunas! Nadie más llega hasta aquí.", + "s4/l2": "¡Uy! ¡Perdón!", + "s4/n3": "No pasa nada. Así tenemos todavía más hojas.", + "s4/e4": "Un nido de erizo necesita muchas hojas.", + "s4/e5": "Mamás y papás: si dejan un montón de hojas en el jardín, a lo mejor un erizo encuentra allí su camita para el invierno.", + "lied/r1": "Uno pequeño y otra alta,", + "lied/r2": "y en el medio… ¿quién nos falta?", + "lied/r3": "Noa, Noa, en el centro está,", + "lied/r4": "¡y juntos sí se podrá!", + "s5/p1": "¡No! ¡No quiero dormir hasta que llegue la Fiesta del Invierno!", + "s5/l1": "¡Pero Pino, si tienes muchísimo sueño!", + "s5/e1": "Mmm. ¿Qué pueden hacer Noa y Lulu para que Pino se quede despierto hasta la Fiesta del Invierno?", + "s5/e2": "¿Tú qué crees?", + "s5/n1": "¡Tengo una idea!", + "s6/l1": "¿Qué estás dibujando, Noa?", + "s6/n1": "¡Un calendario! Con veinticuatro ventanitas.", + "s6/n2": "Cada día abrimos una. Y detrás de cada ventanita hay algo que mantiene despierto a Pino.", + "s6/p1": "¿Qué cosa?", + "s6/n3": "Algo para descubrir, para hacer manualidades, para hornear y para cantar.", + "s6/n4": "¡Y cuando abramos la última ventanita, será la Fiesta del Invierno!", + "s6/p2": "¡Me hacen cosquillas las púas!", + "s7/n1": "Que duermas bien, Pino. Solo hasta mañana.", + "s7/e1": "A partir del uno de diciembre, Noa, Lulu y Pino abren una ventanita cada día.", + "s7/e2": "¡Y tú puedes participar! ¿Llegarás con Pino hasta la Fiesta del Invierno?", + "s7/t1": "¡Adiós! ¡Hasta pronto!", + "s7/p1": "…hasta pronto…" + } +} diff --git a/remotion/src/folge02/texte/fr.json b/remotion/src/folge02/texte/fr.json new file mode 100644 index 0000000..7520a7e --- /dev/null +++ b/remotion/src/folge02/texte/fr.json @@ -0,0 +1,62 @@ +{ + "titel": "Pino a sommeil", + "lied": "Un tout petit, une très grande", + "zeilen": { + "s2/e1": "Il fait froid dans le jardin de Noa.", + "s2/e2": "Les arbres ont perdu presque toutes leurs feuilles.", + "s2/p1": "Ouaaaah.", + "s2/n1": "Bonjour, Pino ! On joue ?", + "s2/p2": "Oui ! Je suis tout réveillé. Tout… ré…veillé…", + "s2/n2": "Pino ? Pino !", + "s2/p3": "Quoi ? Je ne dormais pas !", + "s2/e3": "Mais si, il dormait.", + "s2/e4": "Qu'est-ce que tu en penses : pourquoi Pino a-t-il tellement sommeil ?", + "s3/l1": "Bonjour, vous deux !", + "s3/l2": "D'ici, je vois que les oiseaux s'envolent, très loin, vers le sud.", + "s3/l3": "Et les arbres sont tout nus. L'hiver arrive.", + "s3/e1": "Quand il fait froid, les hérissons ne trouvent presque plus rien à manger.", + "s3/e2": "Alors ils font quelque chose de très spécial : ils dorment.", + "s3/e3": "Tout l'hiver, jusqu'au printemps. Ça s'appelle l'hibernation.", + "s3/p1": "Tout l'hiver ? Mais alors je vais rater la fête de l'hiver !", + "s3/e4": "Regarde, les piquants de Pino pendent tout mous.", + "s3/e5": "Comment se sent Pino maintenant ?", + "s3/e6": "Oui. Pino est triste.", + "s3/n1": "Oh, Pino. La fête de l'hiver, c'est encore dans très longtemps.", + "s4/n1": "Si tu as tellement sommeil, il te faut un lit bien chaud.", + "s4/e1": "Pour l'hiver, les hérissons se construisent un nid.", + "s4/e2": "Avec plein de feuilles, sous une haie ou sous un tas de bois.", + "s4/e3": "Là, c'est sec et bien chaud.", + "s4/n2": "Tu comptes avec moi ?", + "s4/c1": "Un…", + "s4/c2": "Deux…", + "s4/c3": "Trois…", + "s4/c4": "Quatre…", + "s4/c5": "Cinq !", + "s4/l1": "Il en reste là-haut ! Personne d'autre ne peut les attraper.", + "s4/l2": "Oh ! Pardon !", + "s4/n3": "Ce n'est pas grave. Comme ça, on a encore plus de feuilles.", + "s4/e4": "Un nid de hérisson a besoin de beaucoup de feuilles.", + "s4/e5": "Chers parents : si vous laissez un tas de feuilles dans votre jardin, vous offrez peut-être un petit lit d'hiver à un hérisson.", + "lied/r1": "Un tout petit, une très grande,", + "lied/r2": "Noa au milieu de la bande.", + "lied/r3": "En haut, en bas, et entre les deux –", + "lied/r4": "ensemble, on fait de notre mieux !", + "s5/p1": "Non ! Je ne veux pas dormir avant la fête de l'hiver !", + "s5/l1": "Mais Pino, tu es tellement fatigué !", + "s5/e1": "Hmm. Que peuvent faire Noa et Lulu pour que Pino reste réveillé jusqu'à la fête de l'hiver ?", + "s5/e2": "Qu'est-ce que tu en penses ?", + "s5/n1": "J'ai une idée !", + "s6/l1": "Qu'est-ce que tu dessines, Noa ?", + "s6/n1": "Un calendrier ! Avec vingt-quatre petites portes.", + "s6/n2": "Chaque jour, on en ouvre une. Et derrière chaque petite porte, il y a quelque chose qui garde Pino réveillé.", + "s6/p1": "Quoi donc ?", + "s6/n3": "Quelque chose à découvrir, à bricoler, à cuisiner et à chanter.", + "s6/n4": "Et quand la dernière petite porte sera ouverte, ce sera la fête de l'hiver !", + "s6/p2": "Mes piquants me chatouillent !", + "s7/n1": "Dors bien, Pino. Juste jusqu'à demain.", + "s7/e1": "À partir du premier décembre, Noa, Lulu et Pino ouvrent une petite porte chaque jour.", + "s7/e2": "Et toi aussi, tu peux participer ! Est-ce que tu arriveras avec Pino jusqu'à la fête de l'hiver ?", + "s7/t1": "Au revoir ! À bientôt !", + "s7/p1": "…à bientôt…" + } +} diff --git a/remotion/werkzeuge/render.mjs b/remotion/werkzeuge/render.mjs index 2e457c3..791c357 100644 --- a/remotion/werkzeuge/render.mjs +++ b/remotion/werkzeuge/render.mjs @@ -3,12 +3,13 @@ // // node werkzeuge/render.mjs F02 ganze Folge, volle Qualität // node werkzeuge/render.mjs F02 --entwurf halbe Auflösung + Zeit/Szene eingeblendet +// node werkzeuge/render.mjs F02 --sprache es spanische Fassung (de, es, fr; ohne Angabe de) // node werkzeuge/render.mjs F02 --szene 3,5 nur diese Szenen (neu) rendern // node werkzeuge/render.mjs F02 --neu alles neu, Zwischenstand ignorieren // node werkzeuge/render.mjs F02 --kerne 6 Anzahl paralleler Browser-Tabs // node werkzeuge/render.mjs F02 --ziel /shared/youtube/folge02/folge02.mp4 // -// Läuft unter Linux und Windows. Ergebnis und Zwischenstand: out// +// Läuft unter Linux und Windows. Ergebnis und Zwischenstand: out/// import {spawnSync} from 'node:child_process'; import {createHash} from 'node:crypto'; @@ -35,12 +36,17 @@ const neu = args.includes('--neu'); const nurSzenen = wert('--szene')?.split(',').map(Number); const kerne = Number(wert('--kerne') ?? Math.max(1, os.cpus().length - 1)); const ziel = wert('--ziel'); +const sprache = wert('--sprache') ?? 'de'; +if (!['de', 'es', 'fr'].includes(sprache)) { + console.error(`Unbekannte Sprache: ${sprache} (de, es, fr)`); + process.exit(1); +} const nummer = kuerzel.replace(/\D/g, ''); const ordner = `src/folge${nummer}`; const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx'; const lauf = (cmd, a, opt = {}) => { - const r = spawnSync(cmd, a, {stdio: opt.still ? 'pipe' : 'inherit', encoding: 'utf8', shell: process.platform === 'win32'}); + const r = spawnSync(cmd, a, {stdio: opt.still ? 'pipe' : 'inherit', encoding: 'utf8', shell: process.platform === 'win32', env: {...process.env, SPRACHE: sprache}}); if (r.status !== 0) { if (opt.still) console.error(r.stdout, r.stderr); throw new Error(`Fehlgeschlagen: ${cmd} ${a.join(' ')}`); @@ -57,10 +63,11 @@ const liste = JSON.parse( ); // Fingerabdruck: gemeinsamer Code + Bilder/Clips/Stimmen + eigene Szenendatei -const hashDatei = (h, p) => { +const hashDatei = (h, p, ohne = []) => { + if (ohne.includes(p.replaceAll('\\', '/'))) return; const st = fs.statSync(p); if (st.isDirectory()) { - for (const n of fs.readdirSync(p).sort()) hashDatei(h, path.join(p, n)); + for (const n of fs.readdirSync(p).sort()) hashDatei(h, path.join(p, n), ohne); } else { h.update(p.replaceAll('\\', '/')); h.update(fs.readFileSync(p)); @@ -68,18 +75,22 @@ const hashDatei = (h, p) => { }; const szenenDateien = new Set(liste.map((s) => s.datei)); const gemeinsam = createHash('sha256'); -for (const p of ['src/baukasten', 'src/bewegung.ts', 'public', 'package-lock.json']) if (fs.existsSync(p)) hashDatei(gemeinsam, p); +// Texte, Stimmen und Orte mit Schrift: nur die der gewählten Sprache +const andere = ['de', 'es', 'fr'].filter((s) => s !== sprache); +const ohne = [`public/stimmen/folge${nummer}`, ...andere.flatMap((s) => [`${ordner}/texte/${s}.json`, `${ordner}/stimmen/${s}.json`, `public/orte/kalender_${s}.webp`])]; +for (const p of ['src/baukasten', 'src/bewegung.ts', 'public', 'package-lock.json']) if (fs.existsSync(p)) hashDatei(gemeinsam, p, ohne); +if (fs.existsSync(`public/stimmen/folge${nummer}/${sprache}`)) hashDatei(gemeinsam, `public/stimmen/folge${nummer}/${sprache}`); for (const n of fs.readdirSync(ordner).sort()) { const p = path.join(ordner, n); - if (!szenenDateien.has(n) || n === `Folge${nummer}.tsx`) hashDatei(gemeinsam, p); + if (!szenenDateien.has(n) || n === `Folge${nummer}.tsx`) hashDatei(gemeinsam, p, ohne); } const basis = gemeinsam.digest('hex'); const qualitaet = entwurf ? 'entwurf' : 'voll'; -const aus = path.join('out', kuerzel, qualitaet); +const aus = path.join('out', kuerzel, sprache, qualitaet); fs.mkdirSync(aus, {recursive: true}); const propsDatei = path.join(aus, 'props.json'); -fs.writeFileSync(propsDatei, JSON.stringify({entwurf})); +fs.writeFileSync(propsDatei, JSON.stringify({entwurf, sprache})); const gl = process.platform === 'linux' ? 'swangle' : 'angle'; const teile = []; @@ -111,6 +122,6 @@ for (const [i, s] of liste.entries()) { // Zusammenkleben (ohne neu zu kodieren) const listeDatei = path.join(aus, 'teile.txt'); fs.writeFileSync(listeDatei, teile.map((t) => `file '${path.resolve(t).replaceAll('\\', '/')}'`).join('\n')); -const ergebnis = ziel ?? path.join('out', kuerzel, `${kuerzel}${entwurf ? '-entwurf' : ''}.mp4`); +const ergebnis = ziel ?? path.join('out', kuerzel, `${kuerzel}-${sprache}${entwurf ? '-entwurf' : ''}.mp4`); lauf(npx, ['remotion', 'ffmpeg', '-v', 'error', '-y', '-f', 'concat', '-safe', '0', '-i', listeDatei, '-c', 'copy', '-movflags', '+faststart', ergebnis]); console.log(`\nFertig: ${ergebnis} (${((Date.now() - start) / 60000).toFixed(1)} min)`); diff --git a/remotion/werkzeuge/stimmen.mts b/remotion/werkzeuge/stimmen.mts index 3218b6c..9610d54 100644 --- a/remotion/werkzeuge/stimmen.mts +++ b/remotion/werkzeuge/stimmen.mts @@ -1,15 +1,17 @@ // Erzeugt die Sprachaufnahmen einer Folge mit ElevenLabs und trägt ihre -// Länge in src/folgeNN/stimmen.json ein. Danach passt sich der Zeitplan der +// Länge in src/folgeNN/stimmen/.json ein. Danach passt sich der Zeitplan der // Szenen automatisch an die echten Längen an. // // ELEVENLABS_API_KEY=… npx tsx werkzeuge/stimmen.mts F02 // … F02 --szene s3 nur eine Szene // … F02 --neu alle Zeilen neu erzeugen // … F02 --probe nur anzeigen, was erzeugt würde (kostet nichts) +// … F02 --sprache es andere Sprache (de, es, fr; ohne Angabe de) // // Der Schlüssel kommt nur aus der Umgebungsvariable, nie aus einer Datei im Repo. -// Stimmen-IDs und Modell stehen in werkzeuge/stimmen.config.json. -// Aufnahmen landen in public/stimmen/folgeNN/ (nicht im Git; auf dem VPS ein +// Stimmen-IDs und Modell stehen in werkzeuge/stimmen.config.json (je Sprache +// lassen sich dort unter „sprachen“ eigene Stimmen eintragen). +// Aufnahmen landen in public/stimmen/folgeNN// (nicht im Git; auf dem VPS ein // Link nach /shared/youtube/stimmen/). Nur Zeilen, deren Text sich geändert // hat oder deren Datei fehlt, werden neu erzeugt. @@ -27,6 +29,8 @@ const wert = (n: string) => (args.includes(n) ? args[args.indexOf(n) + 1] : unde const nurSzene = wert('--szene'); const neu = args.includes('--neu'); const probe = args.includes('--probe'); +const sprache = wert('--sprache') ?? 'de'; +if (!['de', 'es', 'fr'].includes(sprache)) throw new Error(`Unbekannte Sprache: ${sprache}`); if (!kuerzel) throw new Error('Folge angeben, z. B. F02'); const nummer = kuerzel.replace(/\D/g, ''); @@ -34,26 +38,27 @@ const cfg = JSON.parse(fs.readFileSync('werkzeuge/stimmen.config.json', 'utf8')) modell: string; format: string; stimmen: Record; + sprachen?: Record}>; einstellungen?: Record; }; const schluessel = process.env.ELEVENLABS_API_KEY; if (!schluessel && !probe) throw new Error('ELEVENLABS_API_KEY ist nicht gesetzt.'); const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx'; -const ausgabe = spawnSync(npx, ['tsx', 'werkzeuge/szenenliste.mts', `./src/folge${nummer}/Folge${nummer}.tsx`, kuerzel], {encoding: 'utf8', shell: process.platform === 'win32'}); +const ausgabe = spawnSync(npx, ['tsx', 'werkzeuge/szenenliste.mts', `./src/folge${nummer}/Folge${nummer}.tsx`, kuerzel], {encoding: 'utf8', shell: process.platform === 'win32', env: {...process.env, SPRACHE: sprache}}); const szenen = JSON.parse(ausgabe.stdout.split('\n').filter((z) => z.startsWith('[')).pop()!) as { szene?: string; zeilen: {id: string; wer?: string; text: string; leise?: boolean}[]; }[]; -const jsonDatei = `src/folge${nummer}/stimmen.json`; +const jsonDatei = `src/folge${nummer}/stimmen/${sprache}.json`; const aufnahmen: Record = JSON.parse(fs.readFileSync(jsonDatei, 'utf8')); -const ordner = `public/stimmen/folge${nummer}`; +const ordner = `public/stimmen/folge${nummer}/${sprache}`; fs.mkdirSync(ordner, {recursive: true}); // Stimme je Sprecher; ein Eintrag kann auf einen anderen Sprecher verweisen (z. B. ALLE → ERZ) const stimmeVon = (wer: string): string | undefined => { - const v = cfg.stimmen[wer]; + const v = cfg.sprachen?.[sprache]?.stimmen?.[wer] || cfg.stimmen[wer]; if (!v) return undefined; return v in cfg.stimmen ? stimmeVon(v) : v; }; diff --git a/remotion/werkzeuge/szenenliste.mts b/remotion/werkzeuge/szenenliste.mts index 3d8781f..c337f69 100644 --- a/remotion/werkzeuge/szenenliste.mts +++ b/remotion/werkzeuge/szenenliste.mts @@ -1,4 +1,4 @@ -// Gibt die Szenen einer Folge als JSON aus (für werkzeuge/render.mjs und stimmen.ts). +// Gibt die Szenen einer Folge als JSON aus (für werkzeuge/render.mjs, stimmen.mts und texte.mts; Sprache über SPRACHE). // npx tsx werkzeuge/szenenliste.mts ./src/folge02/Folge02.tsx F02 import path from 'node:path'; import {pathToFileURL} from 'node:url'; diff --git a/remotion/werkzeuge/texte.mts b/remotion/werkzeuge/texte.mts new file mode 100644 index 0000000..84f4042 --- /dev/null +++ b/remotion/werkzeuge/texte.mts @@ -0,0 +1,69 @@ +// Prüft die Sprechtexte einer Folge (src/folgeNN/texte/.json): +// Fehlt in einer Sprache eine Zeile, die eine Szene braucht, oder steht dort +// eine, die keine Szene mehr benutzt? +// +// npx tsx werkzeuge/texte.mts F02 prüfen +// npx tsx werkzeuge/texte.mts F02 --tabelle alle Sprachen nebeneinander (Markdown) +// +// Kostet nichts und ändert nichts. + +import {spawnSync} from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const WURZEL = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +process.chdir(WURZEL); + +const args = process.argv.slice(2); +const kuerzel = args.find((a) => !a.startsWith('--')); +if (!kuerzel) throw new Error('Folge angeben, z. B. F02'); +const nummer = kuerzel.replace(/\D/g, ''); +const SPRACHEN = ['de', 'es', 'fr']; + +type Texte = {titel: string; lied?: string; zeilen: Record}; +const texte: Record = Object.fromEntries( + SPRACHEN.map((s) => [s, JSON.parse(fs.readFileSync(`src/folge${nummer}/texte/${s}.json`, 'utf8')) as Texte]), +); + +// Welche Zeilen sprechen die Szenen? (Zeilen, die der Baukasten selbst +// mitbringt, z. B. das Intro, stehen nicht in den Textdateien.) +const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx'; +const ausgabe = spawnSync(npx, ['tsx', 'werkzeuge/szenenliste.mts', `./src/folge${nummer}/Folge${nummer}.tsx`, kuerzel], { + encoding: 'utf8', + shell: process.platform === 'win32', + env: {...process.env, SPRACHE: 'de'}, +}); +const szenen = JSON.parse(ausgabe.stdout.split('\n').filter((z) => z.startsWith('[')).pop()!) as {szene?: string; zeilen: {id: string; wer?: string; text: string}[]}[]; +const gebraucht: {schl: string; wer?: string}[] = []; +for (const s of szenen) { + if (!s.szene) continue; + for (const z of s.zeilen) { + const schl = `${s.szene}/${z.id}`; + if (schl in texte.de.zeilen || z.text.startsWith('[fehlt:')) gebraucht.push({schl, wer: z.wer}); + } +} + +if (args.includes('--tabelle')) { + const zelle = (t?: string) => (t ?? '**fehlt**').replaceAll('|', '\\|'); + console.log(`| Zeile | Wer | ${SPRACHEN.join(' | ')} |`); + console.log(`| --- | --- | ${SPRACHEN.map(() => '---').join(' | ')} |`); + console.log(`| Titel | | ${SPRACHEN.map((s) => zelle(texte[s].titel)).join(' | ')} |`); + for (const g of gebraucht) console.log(`| ${g.schl} | ${g.wer ?? ''} | ${SPRACHEN.map((s) => zelle(texte[s].zeilen[g.schl])).join(' | ')} |`); + process.exit(0); +} + +let fehler = 0; +const schluessel = new Set(gebraucht.map((g) => g.schl)); +for (const s of SPRACHEN) { + const fehlt = gebraucht.filter((g) => !(g.schl in texte[s].zeilen)).map((g) => g.schl); + const uebrig = Object.keys(texte[s].zeilen).filter((k) => !schluessel.has(k)); + const leer = Object.entries(texte[s].zeilen).filter(([, t]) => !t.trim()).map(([k]) => k); + if (!texte[s].titel) fehlt.unshift('titel'); + for (const k of fehlt) console.log(`✗ ${s}: fehlt ${k}`); + for (const k of leer) console.log(`✗ ${s}: leer ${k}`); + for (const k of uebrig) console.log(`? ${s}: nicht benutzt ${k}`); + fehler += fehlt.length + leer.length; + if (!fehlt.length && !leer.length && !uebrig.length) console.log(`✓ ${s}: ${Object.keys(texte[s].zeilen).length} Zeilen`); +} +process.exit(fehler ? 1 : 0);