!i#Ojh?adGi@)q|{W`C(=8NmAx=*IYS`xcz=~1qB0@z!p-B&F&rL9nMiBmy=0T&
ztWRnab4TiH`p#5xfhDakg(3Ke>ft_w`9=7j6?M}0fVT5=Qafq~$I9M=b#t5(%PV?%
z%M@~FcfN1W@*zY8Vt9!^vK&hdw2)}Ej)Z$8#C%9RI%L4zUC3IrWqK!67ibWlSl^5}
zF-@$B1EFNsL64+VQsoZ&Oc|$Xzlg)}1N_xe=X*uIE6Yx^^&)NBbI2S!ENJIuCVG42
z#$a?q{zfa<%~SEZFKrpGVOOlTln{<^b)6husgaG~#>IAaNzDqV?0Y2Zg5IE?=m5^HxKfHS{zCe
zA8cd$?{d2=*`bp5HIu`!iM({VI4d_Kh&7Y&)mj>O{gz`i05#Dp!&>x)92gwgaFm*;
z6;l{~(hs0#&QFKbspyF_B0t4?bad)z+-K>DOH)EGcG@@_NHwB0!rX=|M-h#HmqRZ8
zK+=k)(yt3lbm(H^
z6V6|RO60p~$n*DE+S=S61e}J%=viCc+RtlLr4TlZ4p9WnA3iQ#cH#F>IE&z>Zh*e^
zNbwhACW)FEH76NMRSr0#lJ{mRAtcc;G&U`iC{%mjz4Y=$T`#QE8!`tA)MEW+rY>%R
zd|p`xcM!S{{`h4|8=I%q*&L0PQZG#qwqB|F+f&~5uSFh~jquy8VbD`@WglPj*!HJT5
z@2;y>y1SH1(%7T8PVSCP2cM)>mZW+ZDNK0Eox#VwnX6nw#g$S9IoxFhu~Zr5O@E2D
z?pcnKHponKkr#r$Cop!b8}74FGX*yPcrGl
z^Kv{KM=8RJBB?dxdr3~PSTKq>3TY=&GrD6+>Bv9D&%(=0vpK*2IeJ$IX>b=k4UzzeQ>KTiGOUzUy8{4di9+nRF_4zu`ZfX1s9UU}yJs`Gh8!$QhETb!A0$2<
zcuo$aef0`4KndceOH;3X!ugeYN60F={js*AbtW~Lzpm!|O;ss!+t*Q0d9Xynmb?^_
zJC<`iPv#wB9c (
-
+
{Object.keys(ORTE).map((name) => (
))}
diff --git a/remotion/src/folge02/buch.tsx b/remotion/src/baukasten/buch.tsx
similarity index 84%
rename from remotion/src/folge02/buch.tsx
rename to remotion/src/baukasten/buch.tsx
index 249d611..eccc79c 100644
--- a/remotion/src/folge02/buch.tsx
+++ b/remotion/src/baukasten/buch.tsx
@@ -5,7 +5,7 @@ import {Figur, Kamera} from './figur';
import {Blende, Vignette, kamFahrt} from './helfer';
import {Blatt} from './natur';
import {SCHRIFT, Untertitel} from './untertitel';
-import {plane} from './zeit';
+import {plane, type Plan, type Zeile} from './zeit';
// Das Bilderbuch: öffnet sich am Anfang (Intro) und klappt am Ende zu.
// Buchrücken bei x = 960; geschlossen liegt der Deckel rechts davon.
@@ -91,19 +91,19 @@ const Tisch: React.FC = () => (
);
// Intro: Buch geht auf, Figuren springen auf die Seite, Kamera fährt in die Seite.
-export const INTRO = plane(
- [
- {id: 'buch', dauer: 2.2},
- {id: 't1', wer: 'ERZ', text: 'Pino, Noa und Lulu.'},
- {id: 't2', wer: 'ERZ', text: 'Heute: Pino ist müde.'},
- ],
- 0.3,
- 1.6,
-);
+// 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: 'Pino, Noa und Lulu.'},
+ {id: 't2', wer: 'ERZ', text: `Heute: ${folgenTitel}.`},
+];
+export const INTRO_OPTIONEN = {vorlauf: 0.3, nachlauf: 1.6};
-export const Intro: React.FC = () => {
+type IntroProps = {plan: Plan; titel: string; posen?: [string, string, string]};
+
+export const Intro: React.FC = ({plan, titel, posen = ['pino_A3_lachen', 'noa_A3_lachen', 'lulu_A3_lachen']}) => {
const frame = useCurrentFrame();
- const {z, dauer} = INTRO;
+ const {z, dauer} = plan;
const auf = 30;
const winkel = kurve(frame, [
[auf, 0],
@@ -120,21 +120,21 @@ export const Intro: React.FC = () => {
<>
- {winkel < -90 && }
+ {winkel < -90 && }
{winkel >= -90 && }
-
+
>
);
};
// Outro: Seite mit winkenden Figuren, Buch klappt zu.
-export const OUTRO = plane([{id: 'buch', dauer: 4.5}], 0.2, 0.6);
+export const OUTRO = plane([{id: 'buch', dauer: 4.5}], {vorlauf: 0.2, nachlauf: 0.6});
-export const Outro: React.FC = () => {
+export const Outro: React.FC<{posen?: [string, string, string]}> = ({posen = ['pino_B3_schlafen', 'noa_A_arm-hoch-winken', 'lulu_A3_lachen']}) => {
const frame = useCurrentFrame();
const {dauer} = OUTRO;
const zu = 60;
@@ -152,7 +152,7 @@ export const Outro: React.FC = () => {
<>
- {winkel < -90 && }
+ {winkel < -90 && }
{winkel >= -90 && }
diff --git a/remotion/src/folge02/figur.tsx b/remotion/src/baukasten/figur.tsx
similarity index 87%
rename from remotion/src/folge02/figur.tsx
rename to remotion/src/baukasten/figur.tsx
index 3fe0357..d900f6e 100644
--- a/remotion/src/folge02/figur.tsx
+++ b/remotion/src/baukasten/figur.tsx
@@ -131,6 +131,19 @@ export const ClipFigur: React.FC = ({name, start, hoehe, tempo = 1, s
);
};
+// Gehende Figur: Geh-Clip der Bibliothek in Schleife (Blick nach links,
+// `spiegeln` = nach rechts). Die Bewegung über den Boden macht die Szene (x).
+export const Geher: React.FC & {figur: FigurName}> = ({figur, ...rest}) => (
+
+);
+
+// Noa geht in die Hocke: Clip „hinsetzen“ bis zur Hocke (ca. 1,65 s), danach
+// bleibt das letzte Bild stehen. `auf` spielt ihn rückwärts (aufstehen).
+export const NOA_HOCKE_FRAMES = 50; // Dauer in Frames der Komposition bei Tempo 1
+export const NoaHocke: React.FC & {auf?: boolean}> = ({auf = false, ...rest}) => (
+
+);
+
// Kamera: zeigt den Ausschnitt um (x, y) mit Zoom z (1 = ganze 1920 × 1080-Welt).
// Der Ausschnitt bleibt immer innerhalb der Welt (kein leerer Rand).
export const Kamera: React.FC<{x?: number; y?: number; zoom?: number; frei?: boolean; children: React.ReactNode}> = ({x: kx = 960, y: ky = 540, zoom = 1, frei = false, children}) => {
diff --git a/remotion/src/baukasten/folge.tsx b/remotion/src/baukasten/folge.tsx
new file mode 100644
index 0000000..e217825
--- /dev/null
+++ b/remotion/src/baukasten/folge.tsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import {AbsoluteFill, Composition, Series, getInputProps, useCurrentFrame} from 'remotion';
+import {SCHRIFT} from './untertitel';
+import type {Geplant} from './zeit';
+
+// Eine Folge = Liste von Szenen. Daraus entstehen die Komposition der ganzen
+// Folge und je Szene eine eigene Komposition (zum einzelnen Rendern):
+// F02 ganze Folge
+// F02-03-... nur Szene 3
+// Mit --props='{"entwurf": true}' blendet jede Szene oben links Szene, Zeit
+// und Zeilen-ID ein, damit Rückmeldungen genau sagen können, wo etwas ist.
+
+export type SzenenEintrag = {
+ name: string;
+ datei: string; // Quelldatei der Szene (für werkzeuge/render.mjs)
+ plan: {dauer: number; liste: Geplant[]};
+ K: React.FC;
+};
+
+const BILD = {fps: 30, width: 1920, height: 1080} as const;
+
+const zeit = (f: number) => {
+ const s = Math.floor(f / 30);
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
+};
+
+export const kennung = (kuerzel: string, i: number, name: string) =>
+ `${kuerzel}-${String(i + 1).padStart(2, '0')}-${name
+ .replace(/ä/g, 'ae')
+ .replace(/ö/g, 'oe')
+ .replace(/ü/g, 'ue')
+ .replace(/ß/g, 'ss')
+ .replace(/[^A-Za-z0-9]+/g, '-')
+ .replace(/^-|-$/g, '')}`;
+
+const Entwurf: React.FC<{szenen: SzenenEintrag[]; index: number; versatz: number}> = ({szenen, index, versatz}) => {
+ const f = useCurrentFrame();
+ const sz = szenen[index];
+ const zeile = sz.plan.liste.find((l) => f >= l.start && f < l.frei + 10);
+ return (
+
+ {zeit(versatz + f)} · Szene {index + 1} „{sz.name}“ · {zeit(f)}
+ {zeile ? ` · ${zeile.id}` : ''}
+
+ );
+};
+
+const istEntwurf = () => getInputProps().entwurf === true;
+
+const versatzVon = (szenen: SzenenEintrag[], index: number) => szenen.slice(0, index).reduce((s, x) => s + x.plan.dauer, 0);
+
+export const folgenDauer = (szenen: SzenenEintrag[]) => versatzVon(szenen, szenen.length);
+
+const EinzelSzene: React.FC<{szenen: SzenenEintrag[]; index: number}> = ({szenen, index}) => {
+ const {K} = szenen[index];
+ return (
+
+
+ {istEntwurf() && }
+
+ );
+};
+
+// Komponenten einmal je Folge bauen (Kompositions-Props müssen ohne
+// Funktionen auskommen, darum stecken die Szenen in den Komponenten selbst).
+export const baueFolge = (kuerzel: string, szenen: SzenenEintrag[]) => {
+ const Ganz: React.FC = () => (
+
+
+ {szenen.map((s, i) => (
+
+
+
+ ))}
+
+
+ );
+ const einzeln = szenen.map((s, i) => {
+ const E: React.FC = () => ;
+ return {id: kennung(kuerzel, i, s.name), K: E, dauer: s.plan.dauer, datei: s.datei, name: s.name};
+ });
+ return {kuerzel, dauer: folgenDauer(szenen), Ganz, einzeln};
+};
+export type Folge = ReturnType;
+
+// Alle Kompositionen einer Folge (für Root.tsx)
+export const FolgenKompositionen: React.FC<{folge: Folge}> = ({folge}) => (
+ <>
+
+ {folge.einzeln.map((e) => (
+
+ ))}
+ >
+);
diff --git a/remotion/src/folge02/helfer.tsx b/remotion/src/baukasten/helfer.tsx
similarity index 100%
rename from remotion/src/folge02/helfer.tsx
rename to remotion/src/baukasten/helfer.tsx
diff --git a/remotion/src/folge02/natur.tsx b/remotion/src/baukasten/natur.tsx
similarity index 83%
rename from remotion/src/folge02/natur.tsx
rename to remotion/src/baukasten/natur.tsx
index 5d3a1de..3015992 100644
--- a/remotion/src/folge02/natur.tsx
+++ b/remotion/src/baukasten/natur.tsx
@@ -69,20 +69,41 @@ export const Blaetterregen: React.FC<{start: number; x0: number; y0: number; bod
);
};
-// Ein Vogelschwarm zieht nach rechts oben davon.
-export const Voegel: React.FC<{start: number; anzahl?: number; skala?: number}> = ({start, anzahl = 11, skala = 1}) => {
+// Ein Vogel von der Seite (fliegt nach rechts). `schlag` -1 … 1 = Flügel unten … oben.
+const Vogel: React.FC<{x: number; y: number; g: number; schlag: number; neigung: number}> = ({x, y, g, schlag, neigung}) => {
+ const h = -8 - schlag * 24; // Flügelspitze relativ zur Schulter
+ const fluegel = (dx: number, dy: number) => `M ${3 + dx} ${-3 + dy} Q ${-3 + dx} ${h * 0.55 + dy} ${-15 + dx} ${h + dy} Q ${-6 + dx} ${h * 0.35 + dy} ${-7 + dx} ${1 + dy} Z`;
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+// Ein Vogelschwarm zieht in V-Form nach rechts oben davon.
+export const Voegel: React.FC<{start: number; anzahl?: number; skala?: number}> = ({start, anzahl = 9, skala = 1}) => {
const frame = useCurrentFrame();
const t = frame - start;
return (
);
diff --git a/remotion/src/folge02/orte.tsx b/remotion/src/baukasten/orte.tsx
similarity index 79%
rename from remotion/src/folge02/orte.tsx
rename to remotion/src/baukasten/orte.tsx
index 5a16a77..8d361ec 100644
--- a/remotion/src/folge02/orte.tsx
+++ b/remotion/src/baukasten/orte.tsx
@@ -8,6 +8,10 @@ import './untertitel'; // lädt die Schrift für den Kalender
const B = 1920;
const H = 1080;
+// Maße im Garten (passen zu den Bildern garten und garten_nacht)
+export const BODEN = 935; // Fußlinie der Figuren auf der Wiese
+export const NEST_X = 1480; // Platz vor dem Holzstapel
+
const Filter: React.FC = () => (
@@ -307,9 +311,13 @@ export const TischSVG: React.FC = () => (
);
// Der gemalte Kalender (Nahaufnahme von oben).
-export const KalenderSVG: React.FC = () => {
- const farben = ['#d9534f', '#e0a020', '#5f8a4a', '#3f5a8a', '#a0526b', '#c8642c'];
- const nummern = [7, 15, 2, 21, 11, 18, 4, 23, 9, 13, 1, 16, 20, 6, 24, 12, 3, 17, 10, 22, 5, 14, 19, 8];
+const K_FARBEN = ['#d9534f', '#e0a020', '#5f8a4a', '#3f5a8a', '#a0526b', '#c8642c'];
+const K_NUMMERN = [7, 15, 2, 21, 11, 18, 4, 23, 9, 13, 1, 16, 20, 6, 24, 12, 3, 17, 10, 22, 5, 14, 19, 8];
+const kTuer = (i: number) => ({x: 330 + (i % 6) * 215, y: 250 + Math.floor(i / 6) * 180, farbe: K_FARBEN[i % K_FARBEN.length], nummer: K_NUMMERN[i]});
+
+export const KalenderSVG: React.FC<{leer?: boolean}> = ({leer = false}) => {
+ const farben = K_FARBEN;
+ const nummern = K_NUMMERN;
return (
@@ -348,8 +356,93 @@ export const KalenderSVG: React.FC = () => {
};
export const KALENDER_TUER1 = {x: 330 + 4 * 215 + 90, y: 250 + 1 * 180 + 75}; // Türchen „1“ (Index 10)
+// Noa malt den Kalender (Blick von oben auf das Blatt, über `kalender_leer`).
+// `p` 0…1: Titel, dann die Türchen Zeile für Zeile. Die Stiftspitze folgt dem Strich.
+const drehe = (x: number, y: number) => {
+ const w = (-2 * Math.PI) / 180;
+ const dx = x - 960;
+ const dy = y - 540;
+ return {x: 960 + dx * Math.cos(w) - dy * Math.sin(w), y: 540 + dx * Math.sin(w) + dy * Math.cos(w)};
+};
+const umfangPunkt = (x: number, y: number, b: number, h: number, t: number) => {
+ const u = 2 * (b + h);
+ let d = t * u;
+ if (d < b) return {x: x + d, y};
+ d -= b;
+ if (d < h) return {x: x + b, y: y + d};
+ d -= h;
+ if (d < b) return {x: x + b - d, y: y + h};
+ d -= b;
+ return {x, y: y + h - d};
+};
+
+export const KalenderMalen: React.FC<{p: number; wackeln: number}> = ({p, wackeln}) => {
+ const titelAnteil = 0.08;
+ const tp = Math.min(1, p / titelAnteil);
+ const tuerP = Math.max(0, (p - titelAnteil) / (1 - titelAnteil)) * 24;
+ const aktiv = Math.min(23, Math.floor(tuerP));
+ const innen = tuerP - aktiv;
+ let spitze: {x: number; y: number};
+ let farbe = '#c8642c';
+ if (p < titelAnteil) {
+ spitze = {x: 560 + tp * 800, y: 175 + Math.sin(tp * 60) * 14};
+ } else {
+ const t = kTuer(aktiv);
+ farbe = t.farbe;
+ spitze = innen < 0.75 ? umfangPunkt(t.x, t.y, 180, 150, innen / 0.75) : {x: t.x + 62 + Math.sin(innen * 40) * 18, y: t.y + 75 + Math.cos(innen * 30) * 22};
+ }
+ const s = drehe(spitze.x, spitze.y);
+ s.x += Math.sin(wackeln * 0.9) * 2;
+ s.y += Math.cos(wackeln * 1.3) * 2;
+ return (
+
+ );
+};
+
// Vorberechnete Bilder einbinden
-export const Ort: React.FC<{name: string}> = ({name}) =>
;
+export const Ort: React.FC<{name: string}> = ({name}) =>
;
// Kompositionen zum Vorberechnen
export const ORTE: Record = {
@@ -360,6 +453,7 @@ export const ORTE: Record = {
zimmer_wand: () => ,
zimmer_tisch: () => ,
kalender: () => ,
+ kalender_leer: () => ,
};
export const OrtStill: React.FC<{name: string}> = ({name}) => {
const K = ORTE[name];
diff --git a/remotion/src/folge02/posen.ts b/remotion/src/baukasten/posen.ts
similarity index 73%
rename from remotion/src/folge02/posen.ts
rename to remotion/src/baukasten/posen.ts
index a137f22..83e3d3d 100644
--- a/remotion/src/folge02/posen.ts
+++ b/remotion/src/baukasten/posen.ts
@@ -25,7 +25,7 @@ export const FIGUREN = {
export const POSEN: Record = {
"pino_A1_augen-zu": {
"figur": "pino",
- "datei": "folge02/figuren/pino_A1_augen-zu.webp",
+ "datei": "bibliothek/figuren/pino_A1_augen-zu.webp",
"x": 391,
"y": 69,
"b": 623,
@@ -39,7 +39,7 @@ export const POSEN: Record = {
},
"pino_A2_augen-halb": {
"figur": "pino",
- "datei": "folge02/figuren/pino_A2_augen-halb.webp",
+ "datei": "bibliothek/figuren/pino_A2_augen-halb.webp",
"x": 391,
"y": 69,
"b": 623,
@@ -53,7 +53,7 @@ export const POSEN: Record = {
},
"pino_A3_lachen": {
"figur": "pino",
- "datei": "folge02/figuren/pino_A3_lachen.webp",
+ "datei": "bibliothek/figuren/pino_A3_lachen.webp",
"x": 391,
"y": 69,
"b": 623,
@@ -67,7 +67,7 @@ export const POSEN: Record = {
},
"pino_A4_staunen": {
"figur": "pino",
- "datei": "folge02/figuren/pino_A4_staunen.webp",
+ "datei": "bibliothek/figuren/pino_A4_staunen.webp",
"x": 391,
"y": 69,
"b": 623,
@@ -81,7 +81,7 @@ export const POSEN: Record = {
},
"pino_A5_blick-links": {
"figur": "pino",
- "datei": "folge02/figuren/pino_A5_blick-links.webp",
+ "datei": "bibliothek/figuren/pino_A5_blick-links.webp",
"x": 297.5,
"y": 69,
"b": 623,
@@ -95,7 +95,7 @@ export const POSEN: Record = {
},
"pino_A6_blick-rechts": {
"figur": "pino",
- "datei": "folge02/figuren/pino_A6_blick-rechts.webp",
+ "datei": "bibliothek/figuren/pino_A6_blick-rechts.webp",
"x": 391,
"y": 69,
"b": 623,
@@ -109,7 +109,7 @@ export const POSEN: Record = {
},
"pino_A7-1_gehen": {
"figur": "pino",
- "datei": "folge02/figuren/pino_A7-1_gehen.webp",
+ "datei": "bibliothek/figuren/pino_A7-1_gehen.webp",
"x": 391,
"y": 69,
"b": 623,
@@ -123,7 +123,7 @@ export const POSEN: Record = {
},
"pino_A7-2_gehen": {
"figur": "pino",
- "datei": "folge02/figuren/pino_A7-2_gehen.webp",
+ "datei": "bibliothek/figuren/pino_A7-2_gehen.webp",
"x": 391,
"y": 69,
"b": 623,
@@ -137,7 +137,7 @@ export const POSEN: Record = {
},
"pino_A7-3_gehen": {
"figur": "pino",
- "datei": "folge02/figuren/pino_A7-3_gehen.webp",
+ "datei": "bibliothek/figuren/pino_A7-3_gehen.webp",
"x": 391,
"y": 69,
"b": 623,
@@ -151,7 +151,7 @@ export const POSEN: Record = {
},
"pino_A7-4_gehen": {
"figur": "pino",
- "datei": "folge02/figuren/pino_A7-4_gehen.webp",
+ "datei": "bibliothek/figuren/pino_A7-4_gehen.webp",
"x": 391,
"y": 69,
"b": 623,
@@ -165,7 +165,7 @@ export const POSEN: Record = {
},
"pino_A8_rueckansicht": {
"figur": "pino",
- "datei": "folge02/figuren/pino_A8_rueckansicht.webp",
+ "datei": "bibliothek/figuren/pino_A8_rueckansicht.webp",
"x": 391,
"y": 69,
"b": 623,
@@ -179,7 +179,7 @@ export const POSEN: Record = {
},
"pino_A_hinterbeine": {
"figur": "pino",
- "datei": "folge02/figuren/pino_A_hinterbeine.webp",
+ "datei": "bibliothek/figuren/pino_A_hinterbeine.webp",
"x": 391,
"y": 69,
"b": 623,
@@ -193,7 +193,7 @@ export const POSEN: Record = {
},
"pino_A_kugel": {
"figur": "pino",
- "datei": "folge02/figuren/pino_A_kugel.webp",
+ "datei": "bibliothek/figuren/pino_A_kugel.webp",
"x": 391,
"y": 69,
"b": 623,
@@ -207,7 +207,7 @@ export const POSEN: Record = {
},
"pino_A_laufen-1": {
"figur": "pino",
- "datei": "folge02/figuren/pino_A_laufen-1.webp",
+ "datei": "bibliothek/figuren/pino_A_laufen-1.webp",
"x": 391,
"y": 69,
"b": 623,
@@ -221,7 +221,7 @@ export const POSEN: Record = {
},
"pino_A_laufen-2": {
"figur": "pino",
- "datei": "folge02/figuren/pino_A_laufen-2.webp",
+ "datei": "bibliothek/figuren/pino_A_laufen-2.webp",
"x": 391,
"y": 69,
"b": 623,
@@ -235,7 +235,7 @@ export const POSEN: Record = {
},
"pino_A_laufen-3": {
"figur": "pino",
- "datei": "folge02/figuren/pino_A_laufen-3.webp",
+ "datei": "bibliothek/figuren/pino_A_laufen-3.webp",
"x": 391,
"y": 69,
"b": 623,
@@ -249,7 +249,7 @@ export const POSEN: Record = {
},
"pino_A_laufen-4": {
"figur": "pino",
- "datei": "folge02/figuren/pino_A_laufen-4.webp",
+ "datei": "bibliothek/figuren/pino_A_laufen-4.webp",
"x": 391,
"y": 69,
"b": 623,
@@ -263,7 +263,7 @@ export const POSEN: Record = {
},
"pino_B1_traurig": {
"figur": "pino",
- "datei": "folge02/figuren/pino_B1_traurig.webp",
+ "datei": "bibliothek/figuren/pino_B1_traurig.webp",
"x": 391,
"y": 69,
"b": 623,
@@ -277,7 +277,7 @@ export const POSEN: Record = {
},
"pino_B2_erschrocken": {
"figur": "pino",
- "datei": "folge02/figuren/pino_B2_erschrocken.webp",
+ "datei": "bibliothek/figuren/pino_B2_erschrocken.webp",
"x": 391,
"y": 69,
"b": 623,
@@ -291,7 +291,7 @@ export const POSEN: Record = {
},
"pino_B3_schlafen": {
"figur": "pino",
- "datei": "folge02/figuren/pino_B3_schlafen.webp",
+ "datei": "bibliothek/figuren/pino_B3_schlafen.webp",
"x": 391,
"y": 69,
"b": 623,
@@ -305,7 +305,7 @@ export const POSEN: Record = {
},
"pino_B_blick-nach-oben": {
"figur": "pino",
- "datei": "folge02/figuren/pino_B_blick-nach-oben.webp",
+ "datei": "bibliothek/figuren/pino_B_blick-nach-oben.webp",
"x": 391,
"y": 69,
"b": 623,
@@ -319,7 +319,7 @@ export const POSEN: Record = {
},
"pino_B_schlaeft-eingerollt": {
"figur": "pino",
- "datei": "folge02/figuren/pino_B_schlaeft-eingerollt.webp",
+ "datei": "bibliothek/figuren/pino_B_schlaeft-eingerollt.webp",
"x": 391,
"y": 69,
"b": 623,
@@ -333,7 +333,7 @@ export const POSEN: Record = {
},
"pino_ref_sitzend": {
"figur": "pino",
- "datei": "folge02/figuren/pino_ref_sitzend.webp",
+ "datei": "bibliothek/figuren/pino_ref_sitzend.webp",
"x": 391,
"y": 69,
"b": 623,
@@ -347,7 +347,7 @@ export const POSEN: Record = {
},
"noa_A1_augen-zu": {
"figur": "noa",
- "datei": "folge02/figuren/noa_A1_augen-zu.webp",
+ "datei": "bibliothek/figuren/noa_A1_augen-zu.webp",
"x": 339,
"y": 156,
"b": 715,
@@ -361,7 +361,7 @@ export const POSEN: Record = {
},
"noa_A2_augen-halb": {
"figur": "noa",
- "datei": "folge02/figuren/noa_A2_augen-halb.webp",
+ "datei": "bibliothek/figuren/noa_A2_augen-halb.webp",
"x": 339,
"y": 156,
"b": 715,
@@ -375,7 +375,7 @@ export const POSEN: Record = {
},
"noa_A3_lachen": {
"figur": "noa",
- "datei": "folge02/figuren/noa_A3_lachen.webp",
+ "datei": "bibliothek/figuren/noa_A3_lachen.webp",
"x": 339,
"y": 156,
"b": 715,
@@ -389,7 +389,7 @@ export const POSEN: Record = {
},
"noa_A4_staunen": {
"figur": "noa",
- "datei": "folge02/figuren/noa_A4_staunen.webp",
+ "datei": "bibliothek/figuren/noa_A4_staunen.webp",
"x": 339,
"y": 156,
"b": 715,
@@ -403,7 +403,7 @@ export const POSEN: Record = {
},
"noa_A5_blick-links": {
"figur": "noa",
- "datei": "folge02/figuren/noa_A5_blick-links.webp",
+ "datei": "bibliothek/figuren/noa_A5_blick-links.webp",
"x": 314.0,
"y": 156,
"b": 715,
@@ -417,7 +417,7 @@ export const POSEN: Record = {
},
"noa_A6_blick-rechts": {
"figur": "noa",
- "datei": "folge02/figuren/noa_A6_blick-rechts.webp",
+ "datei": "bibliothek/figuren/noa_A6_blick-rechts.webp",
"x": 339,
"y": 156,
"b": 715,
@@ -431,7 +431,7 @@ export const POSEN: Record = {
},
"noa_A7-1_gehen": {
"figur": "noa",
- "datei": "folge02/figuren/noa_A7-1_gehen.webp",
+ "datei": "bibliothek/figuren/noa_A7-1_gehen.webp",
"x": 339,
"y": 156,
"b": 715,
@@ -445,7 +445,7 @@ export const POSEN: Record = {
},
"noa_A7-2_gehen": {
"figur": "noa",
- "datei": "folge02/figuren/noa_A7-2_gehen.webp",
+ "datei": "bibliothek/figuren/noa_A7-2_gehen.webp",
"x": 339,
"y": 156,
"b": 715,
@@ -459,7 +459,7 @@ export const POSEN: Record = {
},
"noa_A7-3_gehen": {
"figur": "noa",
- "datei": "folge02/figuren/noa_A7-3_gehen.webp",
+ "datei": "bibliothek/figuren/noa_A7-3_gehen.webp",
"x": 339,
"y": 156,
"b": 715,
@@ -473,7 +473,7 @@ export const POSEN: Record = {
},
"noa_A7-4_gehen": {
"figur": "noa",
- "datei": "folge02/figuren/noa_A7-4_gehen.webp",
+ "datei": "bibliothek/figuren/noa_A7-4_gehen.webp",
"x": 339,
"y": 156,
"b": 715,
@@ -487,7 +487,7 @@ export const POSEN: Record = {
},
"noa_A8_rueckansicht": {
"figur": "noa",
- "datei": "folge02/figuren/noa_A8_rueckansicht.webp",
+ "datei": "bibliothek/figuren/noa_A8_rueckansicht.webp",
"x": 339,
"y": 156,
"b": 715,
@@ -501,7 +501,7 @@ export const POSEN: Record = {
},
"noa_A_arm-hoch-winken": {
"figur": "noa",
- "datei": "folge02/figuren/noa_A_arm-hoch-winken.webp",
+ "datei": "bibliothek/figuren/noa_A_arm-hoch-winken.webp",
"x": 339,
"y": 156,
"b": 715,
@@ -515,7 +515,7 @@ export const POSEN: Record = {
},
"noa_A_arme-hoch": {
"figur": "noa",
- "datei": "folge02/figuren/noa_A_arme-hoch.webp",
+ "datei": "bibliothek/figuren/noa_A_arme-hoch.webp",
"x": 339,
"y": 156,
"b": 715,
@@ -529,7 +529,7 @@ export const POSEN: Record = {
},
"noa_A_laterne": {
"figur": "noa",
- "datei": "folge02/figuren/noa_A_laterne.webp",
+ "datei": "bibliothek/figuren/noa_A_laterne.webp",
"x": 339,
"y": 156,
"b": 715,
@@ -543,7 +543,7 @@ export const POSEN: Record = {
},
"noa_B1_traurig": {
"figur": "noa",
- "datei": "folge02/figuren/noa_B1_traurig.webp",
+ "datei": "bibliothek/figuren/noa_B1_traurig.webp",
"x": 339,
"y": 156,
"b": 715,
@@ -557,7 +557,7 @@ export const POSEN: Record = {
},
"noa_B2_erschrocken": {
"figur": "noa",
- "datei": "folge02/figuren/noa_B2_erschrocken.webp",
+ "datei": "bibliothek/figuren/noa_B2_erschrocken.webp",
"x": 339,
"y": 156,
"b": 715,
@@ -571,7 +571,7 @@ export const POSEN: Record = {
},
"noa_B3_schlafen": {
"figur": "noa",
- "datei": "folge02/figuren/noa_B3_schlafen.webp",
+ "datei": "bibliothek/figuren/noa_B3_schlafen.webp",
"x": 339,
"y": 156,
"b": 715,
@@ -585,7 +585,7 @@ export const POSEN: Record = {
},
"noa_B_hocken": {
"figur": "noa",
- "datei": "folge02/figuren/noa_B_hocken.webp",
+ "datei": "bibliothek/figuren/noa_B_hocken.webp",
"x": 339,
"y": 156,
"b": 715,
@@ -599,7 +599,7 @@ export const POSEN: Record = {
},
"noa_B_sitzt": {
"figur": "noa",
- "datei": "folge02/figuren/noa_B_sitzt.webp",
+ "datei": "bibliothek/figuren/noa_B_sitzt.webp",
"x": 339,
"y": 156,
"b": 715,
@@ -613,7 +613,7 @@ export const POSEN: Record = {
},
"noa_B_zeigen": {
"figur": "noa",
- "datei": "folge02/figuren/noa_B_zeigen.webp",
+ "datei": "bibliothek/figuren/noa_B_zeigen.webp",
"x": 339,
"y": 156,
"b": 715,
@@ -627,7 +627,7 @@ export const POSEN: Record = {
},
"noa_ref_stehend": {
"figur": "noa",
- "datei": "folge02/figuren/noa_ref_stehend.webp",
+ "datei": "bibliothek/figuren/noa_ref_stehend.webp",
"x": 339,
"y": 156,
"b": 715,
@@ -641,7 +641,7 @@ export const POSEN: Record = {
},
"lulu_A1_augen-zu": {
"figur": "lulu",
- "datei": "folge02/figuren/lulu_A1_augen-zu.webp",
+ "datei": "bibliothek/figuren/lulu_A1_augen-zu.webp",
"x": 322,
"y": 23,
"b": 698,
@@ -655,7 +655,7 @@ export const POSEN: Record = {
},
"lulu_A2_augen-halb": {
"figur": "lulu",
- "datei": "folge02/figuren/lulu_A2_augen-halb.webp",
+ "datei": "bibliothek/figuren/lulu_A2_augen-halb.webp",
"x": 322,
"y": 23,
"b": 698,
@@ -669,7 +669,7 @@ export const POSEN: Record = {
},
"lulu_A3_lachen": {
"figur": "lulu",
- "datei": "folge02/figuren/lulu_A3_lachen.webp",
+ "datei": "bibliothek/figuren/lulu_A3_lachen.webp",
"x": 322,
"y": 23,
"b": 698,
@@ -683,7 +683,7 @@ export const POSEN: Record = {
},
"lulu_A4_staunen": {
"figur": "lulu",
- "datei": "folge02/figuren/lulu_A4_staunen.webp",
+ "datei": "bibliothek/figuren/lulu_A4_staunen.webp",
"x": 322,
"y": 23,
"b": 698,
@@ -697,7 +697,7 @@ export const POSEN: Record = {
},
"lulu_A5_blick-links": {
"figur": "lulu",
- "datei": "folge02/figuren/lulu_A5_blick-links.webp",
+ "datei": "bibliothek/figuren/lulu_A5_blick-links.webp",
"x": 373.5,
"y": 23,
"b": 698,
@@ -711,7 +711,7 @@ export const POSEN: Record = {
},
"lulu_A6_blick-rechts": {
"figur": "lulu",
- "datei": "folge02/figuren/lulu_A6_blick-rechts.webp",
+ "datei": "bibliothek/figuren/lulu_A6_blick-rechts.webp",
"x": 322,
"y": 23,
"b": 698,
@@ -725,7 +725,7 @@ export const POSEN: Record = {
},
"lulu_A7-1_gehen": {
"figur": "lulu",
- "datei": "folge02/figuren/lulu_A7-1_gehen.webp",
+ "datei": "bibliothek/figuren/lulu_A7-1_gehen.webp",
"x": 322,
"y": 23,
"b": 698,
@@ -739,7 +739,7 @@ export const POSEN: Record = {
},
"lulu_A7-2_gehen": {
"figur": "lulu",
- "datei": "folge02/figuren/lulu_A7-2_gehen.webp",
+ "datei": "bibliothek/figuren/lulu_A7-2_gehen.webp",
"x": 322,
"y": 23,
"b": 698,
@@ -753,7 +753,7 @@ export const POSEN: Record = {
},
"lulu_A7-3_gehen": {
"figur": "lulu",
- "datei": "folge02/figuren/lulu_A7-3_gehen.webp",
+ "datei": "bibliothek/figuren/lulu_A7-3_gehen.webp",
"x": 322,
"y": 23,
"b": 698,
@@ -767,7 +767,7 @@ export const POSEN: Record = {
},
"lulu_A7-4_gehen": {
"figur": "lulu",
- "datei": "folge02/figuren/lulu_A7-4_gehen.webp",
+ "datei": "bibliothek/figuren/lulu_A7-4_gehen.webp",
"x": 322,
"y": 23,
"b": 698,
@@ -781,7 +781,7 @@ export const POSEN: Record = {
},
"lulu_A8_rueckansicht": {
"figur": "lulu",
- "datei": "folge02/figuren/lulu_A8_rueckansicht.webp",
+ "datei": "bibliothek/figuren/lulu_A8_rueckansicht.webp",
"x": 322,
"y": 23,
"b": 698,
@@ -795,7 +795,7 @@ export const POSEN: Record = {
},
"lulu_A_apfel": {
"figur": "lulu",
- "datei": "folge02/figuren/lulu_A_apfel.webp",
+ "datei": "bibliothek/figuren/lulu_A_apfel.webp",
"x": 322,
"y": 23,
"b": 698,
@@ -809,7 +809,7 @@ export const POSEN: Record = {
},
"lulu_A_hals-unten": {
"figur": "lulu",
- "datei": "folge02/figuren/lulu_A_hals-unten.webp",
+ "datei": "bibliothek/figuren/lulu_A_hals-unten.webp",
"x": 322,
"y": 23,
"b": 698,
@@ -823,7 +823,7 @@ export const POSEN: Record = {
},
"lulu_B1_traurig": {
"figur": "lulu",
- "datei": "folge02/figuren/lulu_B1_traurig.webp",
+ "datei": "bibliothek/figuren/lulu_B1_traurig.webp",
"x": 322,
"y": 23,
"b": 698,
@@ -837,7 +837,7 @@ export const POSEN: Record = {
},
"lulu_B2_erschrocken": {
"figur": "lulu",
- "datei": "folge02/figuren/lulu_B2_erschrocken.webp",
+ "datei": "bibliothek/figuren/lulu_B2_erschrocken.webp",
"x": 322,
"y": 23,
"b": 698,
@@ -851,7 +851,7 @@ export const POSEN: Record = {
},
"lulu_B3_schlafen": {
"figur": "lulu",
- "datei": "folge02/figuren/lulu_B3_schlafen.webp",
+ "datei": "bibliothek/figuren/lulu_B3_schlafen.webp",
"x": 322,
"y": 23,
"b": 698,
@@ -865,7 +865,7 @@ export const POSEN: Record = {
},
"lulu_B_kopf-profil": {
"figur": "lulu",
- "datei": "folge02/figuren/lulu_B_kopf-profil.webp",
+ "datei": "bibliothek/figuren/lulu_B_kopf-profil.webp",
"x": 322,
"y": 23,
"b": 698,
@@ -879,7 +879,7 @@ export const POSEN: Record = {
},
"lulu_B_liegt": {
"figur": "lulu",
- "datei": "folge02/figuren/lulu_B_liegt.webp",
+ "datei": "bibliothek/figuren/lulu_B_liegt.webp",
"x": 322,
"y": 23,
"b": 698,
@@ -896,7 +896,7 @@ export const POSEN: Record = {
export const CLIPS: Record = {
"lulu_C9_hals-runter": {
"figur": "lulu",
- "datei": "folge02/clips/lulu_C9_hals-runter.webm",
+ "datei": "bibliothek/clips/lulu_C9_hals-runter.webm",
"x": 294.70404984423675,
"y": 24.64797507788162,
"b": 709.4330218068535,
@@ -905,7 +905,7 @@ export const CLIPS: Record = {
},
"noa_C4_winken": {
"figur": "noa",
- "datei": "folge02/clips/noa_C4_winken.webm",
+ "datei": "bibliothek/clips/noa_C4_winken.webm",
"x": 529.3956386292834,
"y": 158.6043613707165,
"b": 413.6573208722741,
@@ -914,7 +914,7 @@ export const CLIPS: Record = {
},
"pino_C3_freudensprung": {
"figur": "pino",
- "datei": "folge02/clips/pino_C3_freudensprung.webm",
+ "datei": "bibliothek/clips/pino_C3_freudensprung.webm",
"x": 382.5794392523364,
"y": 0.0,
"b": 512.2492211838006,
@@ -923,7 +923,7 @@ export const CLIPS: Record = {
},
"pino_C7_einrollen": {
"figur": "pino",
- "datei": "folge02/clips/pino_C7_einrollen.webm",
+ "datei": "bibliothek/clips/pino_C7_einrollen.webm",
"x": 390.08099688473516,
"y": 101.80685358255451,
"b": 552.9719626168223,
@@ -932,7 +932,7 @@ export const CLIPS: Record = {
},
"pino_C8_aufrichten": {
"figur": "pino",
- "datei": "folge02/clips/pino_C8_aufrichten.webm",
+ "datei": "bibliothek/clips/pino_C8_aufrichten.webm",
"x": 387.9376947040498,
"y": 62.155763239875384,
"b": 563.6884735202492,
diff --git a/remotion/src/folge02/untertitel.tsx b/remotion/src/baukasten/untertitel.tsx
similarity index 76%
rename from remotion/src/folge02/untertitel.tsx
rename to remotion/src/baukasten/untertitel.tsx
index 0c1f8ad..7970a16 100644
--- a/remotion/src/folge02/untertitel.tsx
+++ b/remotion/src/baukasten/untertitel.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import {interpolate, useCurrentFrame} from 'remotion';
+import {Audio, Sequence, getInputProps, getStaticFiles, interpolate, staticFile, useCurrentFrame} from 'remotion';
import {loadFont} from '@remotion/google-fonts/Fredoka';
import type {Geplant, Sprecher} from './zeit';
@@ -22,13 +22,33 @@ const FARBE: Record = {
NOA_LULU: '#a0526b',
};
-// Untertitel für die Vorschau ohne Ton. Mit Sprachaufnahmen können sie aus.
+// Vorhandene Dateien in public/ (zum Bündeln bekannt). Fehlt eine Aufnahme
+// auf diesem Rechner, läuft die Szene ohne sie statt mit einem Fehler.
+const VORHANDEN = new Set(getStaticFiles().map((d) => d.name));
+
+// Sprachaufnahmen der Zeilen abspielen
+export const Stimmen: React.FC<{zeilen: Geplant[]}> = ({zeilen}) => (
+ <>
+ {zeilen.map((l) =>
+ l.audio && VORHANDEN.has(l.audio) ? (
+
+
+
+ ) : null,
+ )}
+ >
+);
+
+// Untertitel (für die Vorschau ohne Ton) und Stimmen. Untertitel lassen sich
+// beim Rendern abschalten: --props='{"untertitel": false}'
export const Untertitel: React.FC<{zeilen: Geplant[]}> = ({zeilen}) => {
const frame = useCurrentFrame();
+ if (getInputProps().untertitel === false) return ;
const z = zeilen.find((l) => l.text && frame >= l.start && frame < l.ende + 6);
const frage = zeilen.find((l) => (l.pause ?? 0) >= 2.5 && frame >= l.ende && frame < l.frei);
return (
<>
+
{z && z.wer && (
;
+
+export type Geplant = Zeile & {start: number; ende: number; frei: number; audio?: string};
+
+export type PlanOptionen = {
+ vorlauf?: number; // Sekunden vor der ersten Zeile
+ nachlauf?: number; // Sekunden nach der letzten Zeile
+ szene?: string; // Schlüssel der Szene für die Aufnahmen
+ aufnahmen?: Aufnahmen;
+ ordner?: string; // Ordner der Aufnahmen in public/
+};
+
+const schaetze = (z: Zeile) => {
+ if (z.dauer !== undefined) return z.dauer;
+ if (!z.text) return 1;
+ // Kinderfernsehen: ruhig gesprochen, ca. 13–14 Zeichen pro Sekunde
+ const tempo = z.wer === 'ERZ' ? 13 : 14;
+ return 0.6 + z.text.length / tempo;
+};
+
+// Lücke zwischen zwei Zeilen
+const LUECKE = 0.35;
+
+export const plane = (zeilen: Zeile[], {vorlauf = 0.5, nachlauf = 0.8, szene, aufnahmen, ordner}: PlanOptionen = {}) => {
+ let t = vorlauf * FPS;
+ const liste: Geplant[] = [];
+ for (const z of zeilen) {
+ // Aufnahme nur verwenden, wenn sie zum aktuellen Text passt
+ const a = szene && z.text ? aufnahmen?.[`${szene}/${z.id}`] : undefined;
+ const passt = a && a.text === z.text;
+ const d = Math.round((passt ? a.dauer + 0.15 : schaetze(z)) * FPS);
+ const start = Math.round(t);
+ const ende = start + d;
+ const frei = ende + Math.round((z.pause ?? 0) * FPS);
+ liste.push({...z, start, ende, frei, audio: passt && ordner ? `${ordner}/${a.datei}` : undefined});
+ t = frei + LUECKE * FPS;
+ }
+ const dauer = Math.round(t + nachlauf * FPS);
+ const z = Object.fromEntries(liste.map((l) => [l.id, l])) as Record
;
+ return {liste, z, dauer, szene};
+};
+
+export type Plan = ReturnType;
diff --git a/remotion/src/folge02/Folge02.tsx b/remotion/src/folge02/Folge02.tsx
index da24e66..9b2999a 100644
--- a/remotion/src/folge02/Folge02.tsx
+++ b/remotion/src/folge02/Folge02.tsx
@@ -1,7 +1,8 @@
import React from 'react';
-import {AbsoluteFill, Series} from 'remotion';
-import {INTRO, Intro, OUTRO, Outro} from './buch';
+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 {S2, Szene2} from './szene2';
import {S3, Szene3} from './szene3';
import {S4, Szene4} from './szene4';
@@ -9,31 +10,21 @@ import {S5, Szene5} from './szene5';
import {S6, Szene6} from './szene6';
import {S7, Szene7} from './szene7';
-// Folge 2 „Pino ist müde“ – komplett per Code aus der Figuren-Bibliothek,
-// noch ohne Ton (Untertitel statt Stimmen).
+// Folge 2 „Pino ist müde“ – komplett per Code aus der Figuren-Bibliothek.
-export const SZENEN: {name: string; dauer: number; K: React.FC}[] = [
- {name: 'Intro', dauer: INTRO.dauer, K: Intro},
- {name: 'Gähnen überall', dauer: S2.dauer, K: Szene2},
- {name: 'Lulu sieht es von oben', dauer: S3.dauer, K: Szene3},
- {name: 'Ein Bett aus Blättern', dauer: S4.dauer, K: Szene4},
- {name: 'Lied', dauer: LIED.dauer, K: Lied},
- {name: 'Pino will nicht schlafen', dauer: S5.dauer, K: Szene5},
- {name: 'Noas Idee', dauer: S6.dauer, K: Szene6},
- {name: 'Gute Nacht, Pino', dauer: S7.dauer, K: Szene7},
- {name: 'Outro', dauer: OUTRO.dauer, K: Outro},
+const TITEL = 'Pino ist müde';
+const INTRO = plane('intro', introZeilen(TITEL), INTRO_OPTIONEN);
+
+export const SZENEN: SzenenEintrag[] = [
+ {name: 'Intro', datei: 'Folge02.tsx', plan: INTRO, K: () => },
+ {name: 'Gähnen überall', datei: 'szene2.tsx', plan: S2, K: Szene2},
+ {name: 'Lulu sieht es von oben', datei: 'szene3.tsx', plan: S3, K: Szene3},
+ {name: 'Ein Bett aus Blättern', datei: 'szene4.tsx', plan: S4, K: Szene4},
+ {name: 'Lied', datei: 'lied.tsx', plan: LIED, K: Lied},
+ {name: 'Pino will nicht schlafen', datei: 'szene5.tsx', plan: S5, K: Szene5},
+ {name: 'Noas Idee', datei: 'szene6.tsx', plan: S6, K: Szene6},
+ {name: 'Gute Nacht, Pino', datei: 'szene7.tsx', plan: S7, K: Szene7},
+ {name: 'Outro', datei: 'Folge02.tsx', plan: OUTRO, K: () => },
];
-export const FOLGE02_DAUER = SZENEN.reduce((s, x) => s + x.dauer, 0);
-
-export const Folge02: React.FC = () => (
-
-
- {SZENEN.map(({name, dauer, K}) => (
-
-
-
- ))}
-
-
-);
+export const F02 = baueFolge('F02', SZENEN);
diff --git a/remotion/src/folge02/lied.tsx b/remotion/src/folge02/lied.tsx
index cd9f0ab..51fb0a0 100644
--- a/remotion/src/folge02/lied.tsx
+++ b/remotion/src/folge02/lied.tsx
@@ -1,25 +1,23 @@
import React from 'react';
import {random, useCurrentFrame} from 'remotion';
-import {Figur, Kamera} from './figur';
-import {Blende, Vignette} from './helfer';
-import {Ort} from './orte';
-import {BODEN} from './szene2';
-import {SCHRIFT, Untertitel} from './untertitel';
-import {plane} from './zeit';
+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';
// Refrain „Einer klein und einer groß“ (aus Folge 1), einmal. Die drei wippen im Takt.
const TAKT = 19; // Frames pro Schlag (ca. 95 bpm)
-export const LIED = plane(
+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},
],
- 1.0,
- 1.0,
+ {vorlauf: 1.0, nachlauf: 1.0},
);
const Note: React.FC<{x: number; y: number; s: number; farbe: string}> = ({x, y, s, farbe}) => (
diff --git a/remotion/src/folge02/plan.ts b/remotion/src/folge02/plan.ts
new file mode 100644
index 0000000..4bc310d
--- /dev/null
+++ b/remotion/src/folge02/plan.ts
@@ -0,0 +1,7 @@
+import {plane as basis, type PlanOptionen, type Zeile} from '../baukasten/zeit';
+import AUFNAHMEN from './stimmen.json';
+
+// Zeitplan einer Szene dieser Folge; nimmt vorhandene Sprachaufnahmen
+// (public/stimmen/folge02/, erzeugt von werkzeuge/stimmen.ts) automatisch mit.
+export const plane = (szene: string, zeilen: Zeile[], opt: PlanOptionen = {}) =>
+ basis(zeilen, {...opt, szene, aufnahmen: AUFNAHMEN, ordner: 'stimmen/folge02'});
diff --git a/remotion/src/folge02/stimmen.json b/remotion/src/folge02/stimmen.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/remotion/src/folge02/stimmen.json
@@ -0,0 +1 @@
+{}
diff --git a/remotion/src/folge02/szene2.tsx b/remotion/src/folge02/szene2.tsx
index a9213b8..83784a6 100644
--- a/remotion/src/folge02/szene2.tsx
+++ b/remotion/src/folge02/szene2.tsx
@@ -1,20 +1,19 @@
import React from 'react';
import {useCurrentFrame} from 'remotion';
import {kurve} from '../bewegung';
-import {Figur, Kamera} from './figur';
-import {Blende, Vignette, atmen, gehPose, gehWippen, hopfStauch, huepfer, kamFahrt, ruck} from './helfer';
-import {Blaetterwind, Zzz} from './natur';
-import {Ort} from './orte';
-import {Untertitel} from './untertitel';
-import {plane} from './zeit';
+import {Figur, Geher, Kamera} from '../baukasten/figur';
+import {Blende, Vignette, atmen, hopfStauch, huepfer, kamFahrt, ruck} from '../baukasten/helfer';
+import {Blaetterwind, Zzz} from '../baukasten/natur';
+import {BODEN, Ort} from '../baukasten/orte';
+import {Untertitel} from '../baukasten/untertitel';
+import {plane} from './plan';
// Szene 2: Gähnen überall. Noas Garten am grauen Morgen.
-export const BODEN = 935;
export const NOA_X = 760;
export const PINO_X = 1060;
-export const S2 = plane([
+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.'},
@@ -71,7 +70,7 @@ export const Szene2: React.FC = () => {
atmen(f, 1, sinken > 0.5 ? 0.02 : 0.008) *
hopfStauch(f, schreck);
const pinoDreh = sinken * 5 * (1 + 0.25 * Math.sin(f * 0.06)) + ruck(f, schreck, 4);
- const pinoY = BODEN + huepfer(f, schreck, 36, 12) + (pinoGeht ? gehWippen(f) : 0);
+ const pinoY = BODEN + huepfer(f, schreck, 36, 12);
// Noa kommt von links
const noaAn = z.n1.start - 4;
@@ -95,7 +94,7 @@ export const Szene2: React.FC = () => {
];
// Kichern: schnelles kleines Hüpfen
const kichern = f > z.bild2.start + 15 && f < z.bild2.ende ? -Math.abs(Math.sin(f * 0.5)) * 6 : 0;
- const noaY = BODEN + (noaGeht ? gehWippen(f) : 0) + kichern + huepfer(f, z.n1.start, 18, 10);
+ const noaY = BODEN + kichern + huepfer(f, z.n1.start, 18, 10);
const kam = kamFahrt(f, [
[0, 960, 540, 1.02],
@@ -115,8 +114,8 @@ export const Szene2: React.FC = () => {
<>
-
-
+ {noaGeht ? : }
+ {pinoGeht ? : }
diff --git a/remotion/src/folge02/szene3.tsx b/remotion/src/folge02/szene3.tsx
index 00a73c8..bbf6cca 100644
--- a/remotion/src/folge02/szene3.tsx
+++ b/remotion/src/folge02/szene3.tsx
@@ -1,19 +1,19 @@
import React from 'react';
import {useCurrentFrame} from 'remotion';
import {kurve} from '../bewegung';
-import {ClipFigur, Figur, Kamera} from './figur';
-import {Blende, Vignette, atmen, gehPose, gehWippen, hopfStauch, huepfer, kamFahrt} from './helfer';
-import {Blaetterwind, Blatt, Voegel} from './natur';
-import {Ort} from './orte';
-import {BODEN, NOA_X, PINO_X} from './szene2';
-import {Untertitel} from './untertitel';
-import {plane} from './zeit';
+import {ClipFigur, Figur, Geher, Kamera, NoaHocke} from '../baukasten/figur';
+import {Blende, Vignette, atmen, hopfStauch, huepfer, kamFahrt} from '../baukasten/helfer';
+import {Blaetterwind, Blatt, Voegel} from '../baukasten/natur';
+import {BODEN, Ort} from '../baukasten/orte';
+import {NOA_X, PINO_X} from './szene2';
+import {Untertitel} from '../baukasten/untertitel';
+import {plane} from './plan';
// Szene 3: Lulu sieht es von oben. Winterschlaf wird erklärt, Pino ist traurig.
export const LULU_X = 1520;
-export const S3 = plane([
+export const S3 = plane('s3', [
{id: 'bild0', dauer: 3.2},
{id: 'bild1', dauer: 2.4},
{id: 'l1', wer: 'LULU', text: 'Guten Morgen, ihr zwei!'},
@@ -55,7 +55,7 @@ export const Szene3: React.FC = () => {
let lulu: React.ReactNode;
const luluY = BODEN - 10;
if (luluGeht) {
- lulu = ;
+ lulu = ;
} else if (f < halsHoch) {
lulu = ;
} else if (f < halsHoch + 110) {
@@ -82,6 +82,13 @@ export const Szene3: React.FC = () => {
[aufsBlatt, PINO_X],
[aufsBlatt + 24, GROSSES_BLATT_X],
]);
+ // Das große Blatt segelt vom Apfelbaum herunter und landet neben Pino
+ const blattFaellt = aufsBlatt - 80;
+ const u = Math.min(1, Math.max(0, (f - blattFaellt) / 70));
+ const schaukeln = Math.sin(u * 8) * (1 - u);
+ const blattX = GROSSES_BLATT_X - 180 * (1 - u) + schaukeln * 70;
+ const blattY = BODEN - 8 - 560 * (1 - u);
+ const blattR = 88 + schaukeln * 45;
const traurig = kurve(f, [
[aufsBlatt + 20, 0],
[aufsBlatt + 50, 1],
@@ -109,8 +116,8 @@ export const Szene3: React.FC = () => {
// Noa kniet sich zu Pino
const knien = z.bild3.start + 10;
const noaX = kurve(f, [
- [knien, NOA_X + 20],
- [knien + 30, NOA_X + 140],
+ [knien - 36, NOA_X + 20],
+ [knien, NOA_X + 140],
]);
const noaPose: [number, string][] = [
[0, 'noa_A6_blick-rechts'],
@@ -118,13 +125,8 @@ export const Szene3: React.FC = () => {
[z.l1.start + 15, 'noa_A3_lachen'],
[z.e1.start, 'noa_A6_blick-rechts'],
[z.bild2.start + 30, 'noa_B1_traurig'],
- [knien + 18, 'noa_B_hocken'],
];
- const noaStauch = kurve(f, [
- [knien + 8, 1],
- [knien + 18, 0.94],
- [knien + 30, 1],
- ]);
+ const noaGeht = f >= knien - 36 && f < knien;
const kam = kamFahrt(f, [
[0, 1150, 820, 1.7],
@@ -147,13 +149,13 @@ export const Szene3: React.FC = () => {
{imOben ? (
-
+
) : (
-
- {f > aufsBlatt - 30 && }
+ {noaGeht ? : f >= knien ? : }
+ {f > blattFaellt && }
{lulu}
diff --git a/remotion/src/folge02/szene4.tsx b/remotion/src/folge02/szene4.tsx
index e047e79..f3ffbd9 100644
--- a/remotion/src/folge02/szene4.tsx
+++ b/remotion/src/folge02/szene4.tsx
@@ -1,23 +1,22 @@
import React from 'react';
import {useCurrentFrame} from 'remotion';
import {kurve} from '../bewegung';
-import {Figur, Kamera} from './figur';
-import {Blende, LULU_MAUL_LINKS, Vignette, atmen, gehPose, gehWippen, hopfStauch, huepfer, kamFahrt, punkt, ruck} from './helfer';
-import {Blaetterregen, Blatt, LAUB, Nest, Ruf} from './natur';
-import {Ort} from './orte';
-import {BODEN, NOA_X, PINO_X} from './szene2';
-import {Untertitel} from './untertitel';
-import {plane} from './zeit';
+import {Figur, Geher, Kamera, NOA_HOCKE_FRAMES, NoaHocke} from '../baukasten/figur';
+import {Blende, LULU_MAUL_LINKS, Vignette, atmen, hopfStauch, huepfer, kamFahrt, punkt, ruck} from '../baukasten/helfer';
+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 {Untertitel} from '../baukasten/untertitel';
+import {plane} from './plan';
// Szene 4: Ein Bett aus Blättern. Zählen bis 5, Lulu niest, das Nest entsteht.
-export const NEST_X = 1480;
const NOA_ZAEHL_X = 1170;
const PINO_ZAEHL_X = 1790;
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([
+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.'},
@@ -60,16 +59,24 @@ export const Szene4: React.FC = () => {
]);
const noaGeht = f > noaGehtVon && f < noaGehtBis;
const noaPose: [number, string][] = [
- [0, 'noa_B_hocken'],
- [z.n1.start + 30, 'noa_A3_lachen'],
+ [0, 'noa_A3_lachen'],
[noaGehtBis, 'noa_A6_blick-rechts'],
- [z.n2.start, 'noa_B_hocken'],
[z.c5.ende, 'noa_A3_lachen'],
[z.bild3.start + 4, 'noa_B2_erschrocken'],
[z.l2.ende, 'noa_A3_lachen'],
- [zurueck, 'noa_B_hocken'],
[z.e4.start, 'noa_A3_lachen'],
];
+ // Hocke: Clip vorwärts (runter) oder rückwärts (aufstehen); sonst Standbilder
+ const hocke = (() => {
+ const w = (start: number, auf: boolean) => ({start, auf});
+ if (f < z.n1.start + 30) return w(-1000, false);
+ if (f < z.n1.start + 30 + NOA_HOCKE_FRAMES) return w(z.n1.start + 30, true);
+ if (f >= z.n2.start - 15 && f < z.c5.ende) return w(z.n2.start - 15, false);
+ if (f >= z.c5.ende && f < z.c5.ende + NOA_HOCKE_FRAMES) return w(z.c5.ende, true);
+ if (f >= zurueck && f < z.e4.start) return w(-1000, false);
+ if (f >= z.e4.start && f < z.e4.start + NOA_HOCKE_FRAMES) return w(z.e4.start, true);
+ return null;
+ })();
// beim Hinlegen jedes Blatts kurz nach vorn beugen
const beugen = zaehlen.reduce((s, c) => s + Math.max(0, Math.sin(Math.min(1, Math.max(0, (f - c + 8) / 16)) * Math.PI)), 0);
// beim Nestbau schieben (vor und zurück)
@@ -181,17 +188,24 @@ export const Szene4: React.FC = () => {
f > zaehlen[i] - 6 ? zurueck ? 1 - (f - zurueck) / 40 : 1} /> : null,
)}
-
+ {noaGeht ? (
+
+ ) : hocke ? (
+
+ ) : (
+
+ )}
{imNest ? (
+ ) : pinoGeht ? (
+
) : (
)}
diff --git a/remotion/src/folge02/szene5.tsx b/remotion/src/folge02/szene5.tsx
index afdfdf5..786e4ae 100644
--- a/remotion/src/folge02/szene5.tsx
+++ b/remotion/src/folge02/szene5.tsx
@@ -1,14 +1,12 @@
import React from 'react';
import {useCurrentFrame} from 'remotion';
import {kurve} from '../bewegung';
-import {ClipFigur, Figur, Kamera} from './figur';
-import {Blende, Vignette, atmen, hopfStauch, huepfer, kamFahrt, ruck} from './helfer';
-import {Funkeln, Nest} from './natur';
-import {Ort} from './orte';
-import {BODEN} from './szene2';
-import {NEST_X} from './szene4';
-import {SCHRIFT, Untertitel} from './untertitel';
-import {plane} from './zeit';
+import {ClipFigur, Figur, Kamera} from '../baukasten/figur';
+import {Blende, Vignette, atmen, hopfStauch, huepfer, kamFahrt, ruck} from '../baukasten/helfer';
+import {Funkeln, Nest} from '../baukasten/natur';
+import {BODEN, NEST_X, Ort} from '../baukasten/orte';
+import {SCHRIFT, Untertitel} from '../baukasten/untertitel';
+import {plane} from './plan';
// Szene 5: Pino will nicht schlafen. Noa hat eine Idee.
@@ -16,7 +14,7 @@ const NOA_X5 = 1150;
const PINO_RAUS_X = 1370;
const LULU_X5 = 1790;
-export const S5 = plane([
+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!'},
diff --git a/remotion/src/folge02/szene6.tsx b/remotion/src/folge02/szene6.tsx
index aecc1b3..3892e64 100644
--- a/remotion/src/folge02/szene6.tsx
+++ b/remotion/src/folge02/szene6.tsx
@@ -1,12 +1,12 @@
import React from 'react';
import {useCurrentFrame} from 'remotion';
import {kurve} from '../bewegung';
-import {ClipFigur, Figur, Kamera} from './figur';
-import {Blende, Vignette, atmen, huepfer, kamFahrt} from './helfer';
-import {Funkeln} from './natur';
-import {KALENDER_TUER1, Ort} from './orte';
-import {Untertitel} from './untertitel';
-import {plane} from './zeit';
+import {ClipFigur, Figur, Kamera} from '../baukasten/figur';
+import {Blende, Vignette, atmen, huepfer, kamFahrt} from '../baukasten/helfer';
+import {Funkeln} from '../baukasten/natur';
+import {KALENDER_TUER1, KalenderMalen, Ort} from '../baukasten/orte';
+import {Untertitel} from '../baukasten/untertitel';
+import {plane} from './plan';
// Szene 6: Noas Idee. Abends in Noas Zimmer, Noa malt den Kalender.
@@ -16,8 +16,9 @@ const PINO_X6 = 1090;
const TISCH_Y = 802;
const LULU_X6 = 1440;
-export const S6 = plane([
+export const S6 = plane('s6', [
{id: 'bild0', dauer: 3.0},
+ {id: 'malen', dauer: 4.0},
{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.'},
@@ -29,27 +30,16 @@ export const S6 = plane([
{id: 'bild2', dauer: 4.2},
]);
-// Kritzel auf dem Papier, wächst mit `t` (0…1)
-const Kritzel: React.FC<{t: number}> = ({t}) => (
-
-);
-
export const Szene6: React.FC = () => {
const f = useCurrentFrame();
const {z, dauer} = S6;
- const malen = f < z.n1.ende ? Math.min(1, f / (z.n1.ende - 10)) : 1;
- const stiftX = 600 + malen * 250 + Math.sin(f * 0.6) * 10;
- const stiftY = 796 + Math.cos(f * 0.6) * 5;
+ // Noa malt: Blick von oben aufs Blatt, während „malen“ und während sie antwortet (n1)
+ const malP = kurve(f, [
+ [z.malen.start, 0],
+ [z.n1.ende, 1],
+ ]);
+ const malSicht = (f >= z.malen.start && f < z.l1.start) || (f >= z.n1.start && f < z.n1.ende + 6);
const malt = f < z.n1.ende;
const luluX = kurve(f, [
@@ -81,6 +71,19 @@ export const Szene6: React.FC = () => {
[dauer, KALENDER_TUER1.x - 40, KALENDER_TUER1.y, 1.6],
]);
+ if (malSicht) {
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+ }
+
if (kalender) {
return (
<>
@@ -141,8 +144,6 @@ export const Szene6: React.FC = () => {
schatten={false}
/>
-
- {malt && }
{imSprung ? (
) : (
diff --git a/remotion/src/folge02/szene7.tsx b/remotion/src/folge02/szene7.tsx
index aac673d..3081ab9 100644
--- a/remotion/src/folge02/szene7.tsx
+++ b/remotion/src/folge02/szene7.tsx
@@ -1,13 +1,11 @@
import React from 'react';
import {useCurrentFrame} from 'remotion';
-import {ClipFigur, Figur, Kamera} from './figur';
-import {Blende, Vignette, atmen, kamFahrt} from './helfer';
-import {Nest, Schnee, Zzz} from './natur';
-import {Ort} from './orte';
-import {BODEN} from './szene2';
-import {NEST_X} from './szene4';
-import {Untertitel} from './untertitel';
-import {plane} from './zeit';
+import {ClipFigur, Figur, Kamera} from '../baukasten/figur';
+import {Blende, Vignette, atmen, kamFahrt} from '../baukasten/helfer';
+import {Nest, Schnee, Zzz} from '../baukasten/natur';
+import {BODEN, NEST_X, Ort} from '../baukasten/orte';
+import {Untertitel} from '../baukasten/untertitel';
+import {plane} from './plan';
// Szene 7: Gute Nacht, Pino. Nacht im Garten, die erste Schneeflocke fällt.
@@ -15,7 +13,7 @@ const NOA_X7 = 1140;
const LULU_X7 = 1800;
const LATERNE = {x: 1290, y: BODEN + 10};
-export const S7 = plane(
+export const S7 = plane('s7',
[
{id: 'bild0', dauer: 3.4},
{id: 'n1', wer: 'NOA', text: 'Schlaf gut, Pino. Nur bis morgen.', leise: true},
@@ -24,8 +22,7 @@ export const S7 = plane(
{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},
],
- 0.5,
- 1.6,
+ {vorlauf: 0.5, nachlauf: 1.6},
);
const Laterne: React.FC<{frame: number}> = ({frame}) => {
diff --git a/remotion/src/folge02/zeit.ts b/remotion/src/folge02/zeit.ts
deleted file mode 100644
index 7baa066..0000000
--- a/remotion/src/folge02/zeit.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-// Zeitplan einer Szene aus ihren Sprechzeilen.
-//
-// Solange es keine Sprachaufnahmen gibt, wird die Dauer jeder Zeile aus der
-// Textlänge geschätzt. Kommen später die ElevenLabs-Dateien, trägt man die
-// echte Länge als `dauer` ein (oder liest sie aus), und alle Bewegungen, die
-// sich auf die Zeilen beziehen, rutschen automatisch mit.
-
-export const FPS = 30;
-
-export type Sprecher = 'ERZ' | 'PINO' | 'NOA' | 'LULU' | 'ALLE' | 'NOA_LULU';
-
-export type Zeile = {
- id: string;
- wer?: Sprecher; // leer = nur Bild (Handlung ohne Text)
- text?: string;
- dauer?: number; // Sekunden; ohne Angabe aus der Textlänge geschätzt
- pause?: number; // Sekunden Stille danach (Mitmach-Pause = 3)
- leise?: boolean; // geflüstert / gemurmelt
-};
-
-export type Geplant = Zeile & {start: number; ende: number; frei: number};
-
-const schaetze = (z: Zeile) => {
- if (z.dauer !== undefined) return z.dauer;
- if (!z.text) return 1;
- // Kinderfernsehen: ruhig gesprochen, ca. 13–14 Zeichen pro Sekunde
- const tempo = z.wer === 'ERZ' ? 13 : 14;
- return 0.6 + z.text.length / tempo;
-};
-
-// Lücke zwischen zwei Zeilen
-const LUECKE = 0.35;
-
-export const plane = (zeilen: Zeile[], vorlauf = 0.5, nachlauf = 0.8) => {
- let t = vorlauf * FPS;
- const liste: Geplant[] = [];
- for (const z of zeilen) {
- const d = Math.round(schaetze(z) * FPS);
- const start = Math.round(t);
- const ende = start + d;
- const frei = ende + Math.round((z.pause ?? 0) * FPS);
- liste.push({...z, start, ende, frei});
- t = frei + LUECKE * FPS;
- }
- const dauer = Math.round(t + nachlauf * FPS);
- const z = Object.fromEntries(liste.map((l) => [l.id, l])) as Record;
- return {liste, z, dauer};
-};
diff --git a/remotion/werkzeuge/posen.py b/remotion/werkzeuge/posen.py
index d49211f..098b601 100644
--- a/remotion/werkzeuge/posen.py
+++ b/remotion/werkzeuge/posen.py
@@ -4,7 +4,7 @@
# Bibliothek), z. B. aus frei.py. Alle Bilder einer Figur werden mit demselben
# Rahmen zugeschnitten, damit Posen beim Überblenden deckungsgleich bleiben.
#
-# Ausgabe: public/folge02/figuren/.webp und src/folge02/posen.ts
+# Ausgabe: public/bibliothek/figuren/.webp und src/folge02/posen.ts
# python3 werkzeuge/posen.py []
import json, os, sys, glob
from PIL import Image
@@ -12,8 +12,8 @@ from PIL import Image
QUELLE = sys.argv[1]
CLIPS = sys.argv[2] if len(sys.argv) > 2 else None
HIER = os.path.dirname(os.path.abspath(__file__))
-ZIEL = os.path.join(HIER, "..", "public", "folge02", "figuren")
-TS = os.path.join(HIER, "..", "src", "folge02", "posen.ts")
+ZIEL = os.path.join(HIER, "..", "public", "bibliothek", "figuren")
+TS = os.path.join(HIER, "..", "src", "baukasten", "posen.ts")
os.makedirs(ZIEL, exist_ok=True)
# Referenzpose je Figur: deren Höhe gilt als „Figurhöhe“, deren Fußpunkt als Anker.
@@ -31,7 +31,7 @@ for figur in REF:
rahmen = [max(0, rahmen[0]), max(0, rahmen[1]), min(1376, rahmen[2]), min(768, rahmen[3])]
for n, b in bilder.items():
b.crop(rahmen).save(f"{ZIEL}/{n}.webp", quality=90, method=5)
- posen[n] = {"figur": figur, "datei": f"folge02/figuren/{n}.webp", "x": rahmen[0], "y": rahmen[1],
+ posen[n] = {"figur": figur, "datei": f"bibliothek/figuren/{n}.webp", "x": rahmen[0], "y": rahmen[1],
"b": rahmen[2] - rahmen[0], "h": rahmen[3] - rahmen[1], "box": boxen[n]}
# A5 (Blick links) ist eine Spiegelung von A6 an der Bildmitte: zurückschieben,
# damit die Figur beim Wechsel A6 ↔ A5 nicht seitlich springt.
@@ -52,7 +52,7 @@ if CLIPS:
n = os.path.basename(j)[:-5]
d = json.load(open(j))
bx = d["box"]
- clips[n] = {"figur": n.split("_")[0], "datei": f"folge02/clips/{n}.webm",
+ clips[n] = {"figur": n.split("_")[0], "datei": f"bibliothek/clips/{n}.webm",
"x": bx[0] * K_CLIP, "y": bx[1] * K_CLIP, "b": (bx[2] - bx[0]) * K_CLIP,
"h": (bx[3] - bx[1]) * K_CLIP, "frames": d["frames"]}
diff --git a/remotion/werkzeuge/render.mjs b/remotion/werkzeuge/render.mjs
new file mode 100644
index 0000000..2e457c3
--- /dev/null
+++ b/remotion/werkzeuge/render.mjs
@@ -0,0 +1,116 @@
+// Rendert eine Folge Szene für Szene und klebt sie zusammen.
+// Szenen, an denen sich nichts geändert hat, werden nicht neu gerendert.
+//
+// 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 --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//
+
+import {spawnSync} from 'node:child_process';
+import {createHash} from 'node:crypto';
+import fs from 'node:fs';
+import os from 'node:os';
+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) {
+ console.error('Bitte Folge angeben, z. B.: node werkzeuge/render.mjs F02');
+ process.exit(1);
+}
+const wert = (name) => {
+ const i = args.indexOf(name);
+ return i >= 0 ? args[i + 1] : undefined;
+};
+const entwurf = args.includes('--entwurf');
+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 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'});
+ if (r.status !== 0) {
+ if (opt.still) console.error(r.stdout, r.stderr);
+ throw new Error(`Fehlgeschlagen: ${cmd} ${a.join(' ')}`);
+ }
+ return r.stdout;
+};
+
+// Szenenliste aus dem Code der Folge holen
+const liste = JSON.parse(
+ lauf(npx, ['tsx', 'werkzeuge/szenenliste.mts', `./${ordner}/Folge${nummer}.tsx`, kuerzel], {still: true})
+ .split('\n')
+ .filter((z) => z.startsWith('['))
+ .pop(),
+);
+
+// Fingerabdruck: gemeinsamer Code + Bilder/Clips/Stimmen + eigene Szenendatei
+const hashDatei = (h, p) => {
+ const st = fs.statSync(p);
+ if (st.isDirectory()) {
+ for (const n of fs.readdirSync(p).sort()) hashDatei(h, path.join(p, n));
+ } else {
+ h.update(p.replaceAll('\\', '/'));
+ h.update(fs.readFileSync(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);
+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);
+}
+const basis = gemeinsam.digest('hex');
+
+const qualitaet = entwurf ? 'entwurf' : 'voll';
+const aus = path.join('out', kuerzel, qualitaet);
+fs.mkdirSync(aus, {recursive: true});
+const propsDatei = path.join(aus, 'props.json');
+fs.writeFileSync(propsDatei, JSON.stringify({entwurf}));
+const gl = process.platform === 'linux' ? 'swangle' : 'angle';
+
+const teile = [];
+const start = Date.now();
+for (const [i, s] of liste.entries()) {
+ const h = createHash('sha256');
+ h.update(basis + qualitaet + s.dauer);
+ hashDatei(h, path.join(ordner, s.datei));
+ const fp = h.digest('hex');
+ const datei = path.join(aus, `${s.id}.mp4`);
+ const fpDatei = datei + '.fingerabdruck';
+ const aktuell = fs.existsSync(datei) && fs.existsSync(fpDatei) && fs.readFileSync(fpDatei, 'utf8') === fp;
+ const gewaehlt = nurSzenen ? nurSzenen.includes(i + 1) : !aktuell || neu;
+ teile.push(datei);
+ if (!gewaehlt) {
+ console.log(`= Szene ${i + 1} „${s.name}“ unverändert`);
+ continue;
+ }
+ console.log(`▶ Szene ${i + 1} „${s.name}“ (${(s.dauer / 30).toFixed(1)} s) …`);
+ const t0 = Date.now();
+ const a = ['remotion', 'render', 'src/index.ts', s.id, datei, `--concurrency=${kerne}`, `--gl=${gl}`, `--props=${propsDatei}`, '--log=error'];
+ if (entwurf) a.push('--scale=0.5');
+ lauf(npx, a);
+ fs.writeFileSync(fpDatei, fp);
+ const sek = (Date.now() - t0) / 1000;
+ console.log(` fertig in ${sek.toFixed(0)} s (${(s.dauer / sek).toFixed(1)} Bilder/s)`);
+}
+
+// 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`);
+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.config.json b/remotion/werkzeuge/stimmen.config.json
new file mode 100644
index 0000000..93f5910
--- /dev/null
+++ b/remotion/werkzeuge/stimmen.config.json
@@ -0,0 +1,13 @@
+{
+ "modell": "eleven_multilingual_v2",
+ "format": "mp3_44100_128",
+ "einstellungen": {"stability": 0.5, "similarity_boost": 0.75, "style": 0.2},
+ "stimmen": {
+ "ERZ": "",
+ "PINO": "",
+ "NOA": "",
+ "LULU": "",
+ "ALLE": "ERZ",
+ "NOA_LULU": "NOA"
+ }
+}
diff --git a/remotion/werkzeuge/stimmen.mts b/remotion/werkzeuge/stimmen.mts
new file mode 100644
index 0000000..3218b6c
--- /dev/null
+++ b/remotion/werkzeuge/stimmen.mts
@@ -0,0 +1,102 @@
+// 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
+// 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)
+//
+// 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
+// Link nach /shared/youtube/stimmen/). Nur Zeilen, deren Text sich geändert
+// hat oder deren Datei fehlt, werden neu erzeugt.
+
+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('--'));
+const wert = (n: string) => (args.includes(n) ? args[args.indexOf(n) + 1] : undefined);
+const nurSzene = wert('--szene');
+const neu = args.includes('--neu');
+const probe = args.includes('--probe');
+if (!kuerzel) throw new Error('Folge angeben, z. B. F02');
+
+const nummer = kuerzel.replace(/\D/g, '');
+const cfg = JSON.parse(fs.readFileSync('werkzeuge/stimmen.config.json', 'utf8')) as {
+ modell: string;
+ format: string;
+ stimmen: 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 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 aufnahmen: Record = JSON.parse(fs.readFileSync(jsonDatei, 'utf8'));
+const ordner = `public/stimmen/folge${nummer}`;
+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];
+ if (!v) return undefined;
+ return v in cfg.stimmen ? stimmeVon(v) : v;
+};
+
+const laenge = (datei: string) => {
+ const r = spawnSync(npx, ['remotion', 'ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', datei], {
+ encoding: 'utf8',
+ shell: process.platform === 'win32',
+ });
+ return Math.round(parseFloat(r.stdout.trim()) * 100) / 100;
+};
+
+let zeichen = 0;
+let erzeugt = 0;
+for (const s of szenen) {
+ if (!s.szene || (nurSzene && s.szene !== nurSzene)) continue;
+ for (const z of s.zeilen) {
+ const schl = `${s.szene}/${z.id}`;
+ const datei = `${s.szene}-${z.id}.mp3`;
+ const pfad = path.join(ordner, datei);
+ const alt = aufnahmen[schl];
+ if (!neu && alt && alt.text === z.text && fs.existsSync(pfad)) continue;
+ const stimme = z.wer && stimmeVon(z.wer);
+ if (!stimme) {
+ console.warn(`! ${schl}: keine Stimme für „${z.wer}“ in stimmen.config.json`);
+ continue;
+ }
+ zeichen += z.text.length;
+ if (probe) {
+ console.log(`~ ${schl} (${z.wer}): ${z.text}`);
+ continue;
+ }
+ const antwort = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${stimme}?output_format=${cfg.format}`, {
+ method: 'POST',
+ headers: {'xi-api-key': schluessel!, 'Content-Type': 'application/json', Accept: 'audio/mpeg'},
+ body: JSON.stringify({text: z.text, model_id: cfg.modell, voice_settings: cfg.einstellungen}),
+ });
+ if (!antwort.ok) throw new Error(`${schl}: ElevenLabs antwortet ${antwort.status} ${await antwort.text()}`);
+ fs.writeFileSync(pfad, Buffer.from(await antwort.arrayBuffer()));
+ aufnahmen[schl] = {datei, dauer: laenge(pfad), text: z.text};
+ fs.writeFileSync(jsonDatei, JSON.stringify(aufnahmen, null, '\t') + '\n');
+ erzeugt++;
+ console.log(`✓ ${schl} ${aufnahmen[schl].dauer} s`);
+ }
+}
+console.log(`\n${probe ? 'Würde erzeugen' : 'Erzeugt'}: ${probe ? '' : erzeugt + ' Zeilen, '}${zeichen} Zeichen`);
diff --git a/remotion/werkzeuge/szenenliste.mts b/remotion/werkzeuge/szenenliste.mts
new file mode 100644
index 0000000..3d8781f
--- /dev/null
+++ b/remotion/werkzeuge/szenenliste.mts
@@ -0,0 +1,21 @@
+// Gibt die Szenen einer Folge als JSON aus (für werkzeuge/render.mjs und stimmen.ts).
+// npx tsx werkzeuge/szenenliste.mts ./src/folge02/Folge02.tsx F02
+import path from 'node:path';
+import {pathToFileURL} from 'node:url';
+
+const [modul, kuerzel] = process.argv.slice(2);
+const m = await import(pathToFileURL(path.resolve(modul)).href);
+const folge = m[kuerzel];
+const szenen = m.SZENEN as {name: string; datei: string; plan: {dauer: number; szene?: string; liste: {id: string; wer?: string; text?: string; leise?: boolean}[]}}[];
+console.log(
+ JSON.stringify(
+ folge.einzeln.map((e: {id: string; name: string; datei: string; dauer: number}, i: number) => ({
+ id: e.id,
+ name: e.name,
+ datei: e.datei,
+ dauer: e.dauer,
+ szene: szenen[i].plan.szene,
+ zeilen: szenen[i].plan.liste.filter((l) => l.text).map((l) => ({id: l.id, wer: l.wer, text: l.text, leise: l.leise})),
+ })),
+ ),
+);