Sprechtexte nach Sprachen getrennt (DE/ES/FR), Folge 2 in allen drei Sprachen renderbar

- Texte stehen nur noch in remotion/src/folge02/texte/<sprache>.json,
  die Szenen enthalten nur Ablauf und Bewegung
- Sprache beim Rendern wählbar (render.mjs --sprache es, Studio über
  --props); Zeitplan richtet sich nach Textlänge bzw. Aufnahmen der Sprache
- Baukasten-Wörter je Sprache in baukasten/sprache.ts: Kanalname im Buch
  (mit festem Umbruch für ES/FR), „Heute: …“, Sprechernamen, Kalendertitel,
  „Hatschi!“; Kalenderbild für ES/FR vorberechnet
- Stimmen je Sprache: stimmen/<sprache>.json, public/stimmen/folge02/<sprache>/,
  stimmen.mts --sprache, eigene Stimmen je Sprache möglich
- neues werkzeuge/texte.mts: prüft fehlende Übersetzungen, --tabelle zum Gegenlesen
- README, CLAUDE.md und Drehbuch-Hinweise angepasst

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lara 2026-11-02 10:25:52 +01:00
parent 764c7e1fdd
commit 60acaa9921
31 changed files with 516 additions and 112 deletions

View file

@ -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/<sprache>.json`), damit sie
inhaltlich gleich bleiben.
- Inhalte kindgerecht: keine Gewalt, keine Angstszenen, kurze Sätze.

View file

@ -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.

View file

@ -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.*

View file

@ -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.*

View file

@ -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/<sprache>.json` (Sprechtexte), `stimmen/<sprache>.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/<sprache>.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.<zeile>.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/<sprache>/` 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View file

@ -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}) => (
<div style={{position: 'absolute', left: 0, right: 0, top: y, textAlign: 'center', fontFamily: SCHRIFT, fontWeight: 600, fontSize: groesse, color: farbe}}>{text}</div>
<div style={{position: 'absolute', left: 0, right: 0, top: y, textAlign: 'center', fontFamily: SCHRIFT, fontWeight: 600, fontSize: groesse, lineHeight: 1.05, whiteSpace: 'pre-line', color: farbe}}>{text}</div>
);
// Deckel: dreht sich um den Buchrücken (0° = zu, -180° = offen)
@ -49,7 +50,7 @@ const Deckel: React.FC<{winkel: number}> = ({winkel}) => (
}}
>
<div style={{position: 'absolute', left: 40, right: 40, top: 40, bottom: 40, border: '6px solid #e9c97a', borderRadius: 14}} />
<Titel text="Pinos kleine Welt" groesse={84} farbe="#f6dc95" y={120} />
<Titel text={W.kanalZeilen.join('\n')} groesse={W.kanalZeilen.length > 1 ? 72 : 84} farbe="#f6dc95" y={W.kanalZeilen.length > 1 ? 92 : 120} />
<div style={{position: 'absolute', left: 0, right: 0, top: 260, height: 360}}>
<Figur pose="pino_A3_lachen" x={250} y={330} hoehe={120} schatten={false} />
<Figur pose="noa_A3_lachen" x={370} y={330} hoehe={250} schatten={false} />
@ -61,8 +62,8 @@ const Deckel: React.FC<{winkel: number}> = ({winkel}) => (
{[0, 1, 2, 3, 4, 5].map((i) => (
<Blatt key={i} x={120 + i * 95} y={600 + Math.sin(i * 2.1) * 40} r={i * 47} s={0.9} farbe={['#c8642c', '#d99a2b', '#a8452a'][i % 3]} />
))}
<Titel text="Pinos kleine Welt" groesse={70} y={230} />
<Titel text="Eine Geschichte aus Noas Garten" groesse={34} farbe="#8a6a4a" y={340} />
<Titel text={W.kanalZeilen.join('\n')} groesse={70} y={W.kanalZeilen.length > 1 ? 180 : 230} />
<Titel text={W.buchUntertitel} groesse={34} farbe="#8a6a4a" y={W.kanalZeilen.length > 1 ? 365 : 340} />
</div>
</div>
</div>
@ -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};

View file

@ -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}) => {
<rect x={250} y={90} width={1420} height={900} fill="#fdf8ec" filter="url(#aq)" />
{!leer && <g filter="url(#stift)">
<text x={960} y={190} textAnchor="middle" fontSize={78} fontFamily="Fredoka, sans-serif" fontWeight={600} fill="#c8642c">
Pinos Winterfest-Kalender
{W.kalenderTitel}
</text>
{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
<rect x={400} y={100} width={tp * 1120} height={120} />
</clipPath>
<text x={960} y={190} textAnchor="middle" fontSize={78} fontFamily="Fredoka, sans-serif" fontWeight={600} fill="#c8642c" clipPath="url(#titelclip)">
Pinos Winterfest-Kalender
{W.kalenderTitel}
</text>
{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}) => <Img src={staticFile(`orte/${name}.webp`)} style={{position: 'absolute', inset: 0, width: B, height: H}} />;
// 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}) => <Img src={staticFile(`orte/${ortDatei(name)}.webp`)} style={{position: 'absolute', inset: 0, width: B, height: H}} />;
// Kompositionen zum Vorberechnen
export const ORTE: Record<string, React.FC> = {

View file

@ -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<Sprecher, string>;
leise: string;
kalenderTitel: string;
hatschi: string;
};
const WOERTER: Record<Sprache, Woerter> = {
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/<sprache>.json
export type FolgenTexte = {
titel: string;
lied?: string;
zeilen: Record<string, string>; // Schlüssel „szene/zeile“, z. B. „s2/e1“
};

View file

@ -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<Sprecher, string> = {
ERZ: 'Erzählstimme',
PINO: 'Pino',
NOA: 'Noa',
LULU: 'Lulu',
ALLE: 'Alle',
NOA_LULU: 'Noa und Lulu',
};
const NAME = W.namen;
const FARBE: Record<Sprecher, string> = {
ERZ: '#7a6f66',
PINO: '#3f5a8a',
@ -74,7 +68,7 @@ export const Untertitel: React.FC<{zeilen: Geplant[]}> = ({zeilen}) => {
>
<div style={{fontSize: 26, fontWeight: 600, color: FARBE[z.wer], letterSpacing: 1}}>
{NAME[z.wer]}
{z.leise ? ' (leise)' : ''}
{z.leise ? ` ${W.leise}` : ''}
</div>
<div style={{fontSize: 46, fontWeight: 500, color: '#3b2f28', lineHeight: 1.2, fontStyle: z.leise ? 'italic' : 'normal'}}>{z.text}</div>
</div>

View file

@ -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/<sprache>.json der Folge), gilt ihre echte Länge, und alle Bewegungen, die
// sich auf die Zeilen beziehen, rutschen automatisch mit.
export const FPS = 30;

View file

@ -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[] = [

View file

@ -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 <div key={i} style={{opacity: Math.sin(t * Math.PI) * 0.9}}><Note x={x} y={700 - t * 600} s={0.8 + random(`ns${i}`) * 0.6} farbe={['#c8642c', '#3f5a8a', '#5f8a4a', '#a0526b'][i % 4]} /></div>;
})}
<div style={{position: 'absolute', top: 60, left: 0, right: 0, textAlign: 'center', fontFamily: SCHRIFT, fontWeight: 600, fontSize: 40, color: '#8a6a4a', opacity: Math.min(1, f / 20)}}>
♫ Einer klein und einer groß ♫
♫ {TEXTE.lied} ♫
</div>
<Vignette />
<Untertitel zeilen={LIED.liste} />

View file

@ -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/<sprache>.json (Schlüssel „szene/zeile“),
// die Szenen enthalten nur Ablauf und Bewegung. Aufnahmen je Sprache in
// stimmen/<sprache>.json bzw. public/stimmen/folge02/<sprache>/ (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}`},
);

View file

@ -0,0 +1 @@
{}

View file

@ -0,0 +1 @@
{}

View file

@ -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 = () => {

View file

@ -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

View file

@ -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) => (
<Ruf key={i} text={String(i + 1)} x={960} y={300} start={c} dauer={40} groesse={190} farbe={['#d9534f', '#e0a020', '#5f8a4a', '#3f5a8a', '#a0526b'][i]} />
))}
<Ruf text="Hatschi!" x={1060} y={230} start={niesen} dauer={45} groesse={170} />
<Ruf text={W.hatschi} x={1060} y={230} start={niesen} dauer={45} groesse={170} />
<Vignette />
<Untertitel zeilen={S4.liste} />
<Blende dauer={dauer} rein={0} raus={0} />

View file

@ -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}) => {

View file

@ -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},
]);

View file

@ -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},
);

View file

@ -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 …"
}
}

View file

@ -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…"
}
}

View file

@ -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…"
}
}

View file

@ -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/<kürzel>/
// Läuft unter Linux und Windows. Ergebnis und Zwischenstand: out/<kürzel>/<sprache>/
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)`);

View file

@ -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/<sprache>.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/<sprache>/ (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<string, string>;
sprachen?: Record<string, {stimmen?: Record<string, string>}>;
einstellungen?: Record<string, unknown>;
};
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<string, {datei: string; dauer: number; text: string}> = 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;
};

View file

@ -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';

View file

@ -0,0 +1,69 @@
// Prüft die Sprechtexte einer Folge (src/folgeNN/texte/<sprache>.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<string, string>};
const texte: Record<string, Texte> = 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);