Folge 2 „Pino ist müde“ komplett in Remotion (ohne Ton, ohne Higgsfield)

Szenen aus der Figuren-Bibliothek: freigestellte Standbilder mit
Posenwechsel, freigestellte Clips (WebM mit Alpha in /shared), Orte als
vorberechnete Aquarell-SVGs, Untertitel statt Stimmen. Zeitplan aus den
Sprechzeilen, damit ElevenLabs-Aufnahmen später nur die Dauer setzen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pascal Scholz 2026-10-29 02:09:06 +00:00
parent e0e4feeabe
commit 0b326e6e9c
93 changed files with 3384 additions and 0 deletions

1
remotion/.gitignore vendored
View file

@ -1,2 +1,3 @@
node_modules/ node_modules/
out/ out/
public/folge02/clips

View file

@ -61,6 +61,44 @@ python3 werkzeuge/rig.py # braucht numpy, scipy, pillow
und `uebersicht.png` prüfen. In einer Szene: `gelenk('lulu', 'hals', grad)` und `uebersicht.png` prüfen. In einer Szene: `gelenk('lulu', 'hals', grad)`
(siehe `src/szenen/VerformTest.tsx`). (siehe `src/szenen/VerformTest.tsx`).
## Folge 2 „Pino ist müde“ (`Folge02`)
Komplette Folge (ca. 5:20) nur mit Code, Figuren-Bibliothek und ohne Ton:
Untertitel ersetzen vorerst die Stimmen, Mitmach-Pausen zeigen ein „?“.
| Pfad | Inhalt |
| --- | --- |
| `src/folge02/Folge02.tsx` | Reihenfolge der Szenen |
| `src/folge02/szene2.tsx` … `szene7.tsx`, `lied.tsx`, `buch.tsx` | die Szenen; jede hat oben ihre Sprechzeilen (`plane([...])`) |
| `src/folge02/zeit.ts` | Zeitplan aus den Zeilen; Dauer wird aus der Textlänge geschätzt |
| `src/folge02/figur.tsx` | `Figur` (Standbilder mit Posenwechsel), `ClipFigur` (freigestellte Clips), `Kamera` |
| `src/folge02/orte.tsx` | Orte als SVG; vorberechnet nach `public/folge02/orte/` |
| `src/folge02/natur.tsx` | Blätter, Nest, Vögel, Schnee, Zzz, Funkeln, große Rufe |
| `src/folge02/posen.ts` | automatisch erzeugt von `werkzeuge/posen.py` |
| `public/folge02/figuren/` | freigestellte Standbilder (WebP, im Git) |
| `public/folge02/clips` | Link auf `/shared/youtube/folge02/clips_frei/` (WebM mit Alpha, nicht im Git) |
Orte neu vorberechnen (nach Änderungen in `orte.tsx`):
```bash
npx remotion still src/index.ts F2Ort-garten public/folge02/orte/garten.webp --image-format=webp
```
Rendern:
```bash
npx remotion render src/index.ts Folge02 /shared/youtube/folge02/folge02-vorschau.mp4 --gl=swangle
```
Auf einem anderen Rechner fehlen die Clips: den Ordner
`/shared/youtube/folge02/clips_frei/` nach `public/folge02/clips/` kopieren.
Fehlt ein Clip, zeigt `ClipFigur` ein Standbild der Figur.
**Ton nachrüsten (ElevenLabs):** Je Zeile die Audiodatei erzeugen, ihre
Länge als `dauer` in die Zeile eintragen und die Datei als `<Audio>` am
`start` der Zeile einbinden. Alle Bewegungen hängen an den Zeilen und
verschieben sich mit.
**Neue Standbilder/Clips:** Standbilder mit `rembg` freistellen, ohne
Zuschneiden (gleiche Leinwand), dann
`python3 werkzeuge/posen.py <ordner_freigestellt> /shared/youtube/folge02/clips_frei`.
## Bekannte Grenzen ## Bekannte Grenzen
- Die Figuren haben nur die Pose ihres Bildes. Neue Posen brauchen ein neues - Die Figuren haben nur die Pose ihres Bildes. Neue Posen brauchen ein neues
Bild (z. B. aus Gemini). Bild (z. B. aus Gemini).

View file

@ -7,6 +7,7 @@
"name": "pino-remotion", "name": "pino-remotion",
"dependencies": { "dependencies": {
"@remotion/cli": "^4.0.300", "@remotion/cli": "^4.0.300",
"@remotion/google-fonts": "^4.0.300",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"remotion": "^4.0.300", "remotion": "^4.0.300",
@ -672,6 +673,15 @@
"win32" "win32"
] ]
}, },
"node_modules/@remotion/google-fonts": {
"version": "4.0.300",
"resolved": "https://registry.npmjs.org/@remotion/google-fonts/-/google-fonts-4.0.300.tgz",
"integrity": "sha512-lH2TpMyPrgfxnwtLZLbGZ3QyCNPrIySC0clhz3k9THs9gMnol5I5uVUk3+eXU0+ApQZJNhhWSdsn7wcQti9nMQ==",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"remotion": "4.0.300"
}
},
"node_modules/@remotion/media-parser": { "node_modules/@remotion/media-parser": {
"version": "4.0.300", "version": "4.0.300",
"resolved": "https://registry.npmjs.org/@remotion/media-parser/-/media-parser-4.0.300.tgz", "resolved": "https://registry.npmjs.org/@remotion/media-parser/-/media-parser-4.0.300.tgz",

View file

@ -7,6 +7,7 @@
}, },
"dependencies": { "dependencies": {
"@remotion/cli": "^4.0.300", "@remotion/cli": "^4.0.300",
"@remotion/google-fonts": "^4.0.300",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"remotion": "^4.0.300", "remotion": "^4.0.300",

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

View file

@ -3,6 +3,8 @@ import {Composition} from 'remotion';
import {HintergrundAbend, PapierStruktur, VordergrundAbend} from './szenen/Hintergruende'; import {HintergrundAbend, PapierStruktur, VordergrundAbend} from './szenen/Hintergruende';
import {LaterneTest} from './szenen/LaterneTest'; import {LaterneTest} from './szenen/LaterneTest';
import {VerformTest} from './szenen/VerformTest'; import {VerformTest} from './szenen/VerformTest';
import {ORTE, OrtStill} from './folge02/orte';
import {FOLGE02_DAUER, Folge02} from './folge02/Folge02';
const BILD = {fps: 30, width: 1920, height: 1080} as const; const BILD = {fps: 30, width: 1920, height: 1080} as const;
@ -13,5 +15,9 @@ export const Root: React.FC = () => (
<Composition id="HintergrundAbend" component={HintergrundAbend} durationInFrames={1} {...BILD} /> <Composition id="HintergrundAbend" component={HintergrundAbend} durationInFrames={1} {...BILD} />
<Composition id="VordergrundAbend" component={VordergrundAbend} durationInFrames={1} {...BILD} /> <Composition id="VordergrundAbend" component={VordergrundAbend} durationInFrames={1} {...BILD} />
<Composition id="PapierStruktur" component={PapierStruktur} durationInFrames={1} {...BILD} /> <Composition id="PapierStruktur" component={PapierStruktur} durationInFrames={1} {...BILD} />
<Composition id="Folge02" component={Folge02} durationInFrames={FOLGE02_DAUER} {...BILD} />
{Object.keys(ORTE).map((name) => (
<Composition key={name} id={`F2Ort-${name.replace('_', '-')}`} component={OrtStill} defaultProps={{name}} durationInFrames={1} {...BILD} />
))}
</> </>
); );

View file

@ -0,0 +1,39 @@
import React from 'react';
import {AbsoluteFill, Series} from 'remotion';
import {INTRO, Intro, OUTRO, Outro} from './buch';
import {LIED, Lied} from './lied';
import {S2, Szene2} from './szene2';
import {S3, Szene3} from './szene3';
import {S4, Szene4} from './szene4';
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).
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},
];
export const FOLGE02_DAUER = SZENEN.reduce((s, x) => s + x.dauer, 0);
export const Folge02: React.FC = () => (
<AbsoluteFill style={{background: '#f4ead8'}}>
<Series>
{SZENEN.map(({name, dauer, K}) => (
<Series.Sequence key={name} durationInFrames={dauer} name={name}>
<K />
</Series.Sequence>
))}
</Series>
</AbsoluteFill>
);

View file

@ -0,0 +1,163 @@
import React from 'react';
import {spring, useCurrentFrame, useVideoConfig} from 'remotion';
import {kurve} from '../bewegung';
import {Figur, Kamera} from './figur';
import {Blende, Vignette, kamFahrt} from './helfer';
import {Blatt} from './natur';
import {SCHRIFT, Untertitel} from './untertitel';
import {plane} 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.
const OBEN = 190;
const HOEHE = 700;
const BREITE = 700;
const Seite: React.FC<{links: number; children?: React.ReactNode}> = ({links, children}) => (
<div
style={{
position: 'absolute',
left: links,
top: OBEN,
width: BREITE,
height: HOEHE,
background: 'linear-gradient(90deg, #efe3c9 0%, #fbf4e4 12%, #fbf4e4 88%, #f1e6cd 100%)',
boxShadow: '0 10px 40px rgba(60,40,20,0.25)',
overflow: 'hidden',
}}
>
{children}
</div>
);
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>
);
// Deckel: dreht sich um den Buchrücken (0° = zu, -180° = offen)
const Deckel: React.FC<{winkel: number}> = ({winkel}) => (
<div style={{position: 'absolute', left: 960, top: OBEN - 14, width: BREITE + 16, height: HOEHE + 28, transformOrigin: '0 50%', transform: `perspective(2600px) rotateY(${winkel}deg)`, transformStyle: 'preserve-3d'}}>
<div
style={{
position: 'absolute',
inset: 0,
backfaceVisibility: 'hidden',
borderRadius: '6px 18px 18px 6px',
background: 'radial-gradient(ellipse at 40% 30%, #74a05c, #4f7a3e 70%, #3f6532)',
boxShadow: 'inset 14px 0 18px rgba(0,0,0,0.25), 0 14px 40px rgba(40,30,20,0.35)',
}}
>
<div style={{position: 'absolute', left: 40, right: 40, top: 40, bottom: 40, border: '6px solid #e9c97a', borderRadius: 14}} />
<Titel text="Pino, Noa & Lulu" groesse={84} farbe="#f6dc95" y={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} />
<Figur pose="lulu_A3_lachen" x={520} y={330} hoehe={330} schatten={false} />
</div>
</div>
<div style={{position: 'absolute', inset: 0, backfaceVisibility: 'hidden', transform: 'rotateY(180deg)', background: '#f1e6cd', borderRadius: '18px 6px 6px 18px'}}>
<div style={{position: 'absolute', inset: 14, background: 'linear-gradient(270deg, #efe3c9 0%, #fbf4e4 12%)', overflow: 'hidden'}}>
{[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="Pino, Noa & Lulu" groesse={70} y={230} />
<Titel text="Eine Geschichte aus Noas Garten" groesse={34} farbe="#8a6a4a" y={340} />
</div>
</div>
</div>
);
const Innenseiten: React.FC<{frame: number; auf: number; titel: string; posen: [string, string, string]}> = ({frame, auf, titel, posen}) => {
const {fps} = useVideoConfig();
const pop = (i: number) => spring({frame: frame - auf - 8 - i * 6, fps, config: {damping: 11, stiffness: 120}});
return (
<>
<Seite links={960}>
<Titel text={titel} groesse={68} y={60} farbe="#3f5a8a" />
{posen.map((p, i) => {
const s = pop(i);
const x = [180, 340, 520][i];
const h = [96, 230, 440][i];
return <Figur key={p} pose={p} x={x} y={650} hoehe={h} stauchY={s} stauchX={0.6 + 0.4 * s} deckkraft={Math.min(1, s * 2)} />;
})}
</Seite>
</>
);
};
const Tisch: React.FC = () => (
<div style={{position: 'absolute', inset: -400, background: 'radial-gradient(ellipse at 50% 45%, #e9cfa4 0%, #c99a63 60%, #a87a4a 100%)'}} />
);
// 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,
);
export const Intro: React.FC = () => {
const frame = useCurrentFrame();
const {z, dauer} = INTRO;
const auf = 30;
const winkel = kurve(frame, [
[auf, 0],
[auf + 40, -180],
]);
const kam = kamFahrt(frame, [
[0, 1310, 540, 1.25],
[auf, 1310, 540, 1.25],
[auf + 45, 960, 540, 1.05],
[z.t2.ende, 960, 540, 1.08],
[dauer, 1310, 520, 2.6],
]);
return (
<>
<Kamera {...kam} frei>
<Tisch />
{winkel < -90 && <Innenseiten frame={frame} auf={auf + 30} titel="Pino ist müde" posen={['pino_A2_augen-halb', 'noa_A3_lachen', 'lulu_A3_lachen']} />}
{winkel >= -90 && <Seite links={960} />}
<Deckel winkel={winkel} />
</Kamera>
<Vignette />
<Untertitel zeilen={INTRO.liste} />
<Blende dauer={dauer} rein={12} raus={14} />
</>
);
};
// Outro: Seite mit winkenden Figuren, Buch klappt zu.
export const OUTRO = plane([{id: 'buch', dauer: 4.5}], 0.2, 0.6);
export const Outro: React.FC = () => {
const frame = useCurrentFrame();
const {dauer} = OUTRO;
const zu = 60;
const winkel = kurve(frame, [
[zu, -180],
[zu + 40, 0],
]);
const kam = kamFahrt(frame, [
[0, 1310, 520, 2.4],
[25, 960, 540, 1.05],
[zu + 20, 1150, 540, 1.1],
[dauer, 1310, 540, 1.2],
]);
return (
<>
<Kamera {...kam} frei>
<Tisch />
{winkel < -90 && <Innenseiten frame={frame} auf={-60} titel="Bis bald!" posen={['pino_B3_schlafen', 'noa_A_arm-hoch-winken', 'lulu_A3_lachen']} />}
{winkel >= -90 && <Seite links={960} />}
<Deckel winkel={winkel} />
</Kamera>
<Vignette />
<Blende dauer={dauer} rein={10} raus={20} farbe="#1c1a24" />
</>
);
};

View file

@ -0,0 +1,152 @@
import React from 'react';
import {Freeze, Img, OffthreadVideo, staticFile, useCurrentFrame, useVideoConfig} from 'remotion';
import {CLIPS, FIGUREN, POSEN} from './posen';
// Figuren aus der Bibliothek (freigestellte Standbilder und Clips).
// Alle Bilder einer Figur liegen in derselben Leinwand (1376 × 768); deshalb
// passen Posen beim Überblenden aufeinander. Verankert wird an den Füßen der
// Referenzpose: (x, y) ist der Fußpunkt auf dem Bildschirm, `hoehe` die Höhe
// der Referenzpose in Bildschirm-Pixeln.
// Größenverhältnis wie in figuren/groessen.jpg (Noa = 1)
export const GROESSE = {pino: 0.4, noa: 1, lulu: 2.05} as const;
export type FigurName = keyof typeof GROESSE;
// Posenfolge: [[frame, pose], ...]. Liefert die aktuelle Pose und, während
// eines Wechsels, die vorige Pose samt Mischanteil.
export type Posenfolge = [number, string][];
export const posenStand = (frame: number, folge: Posenfolge, blende = 5) => {
let i = 0;
while (i + 1 < folge.length && frame >= folge[i + 1][0]) i++;
const [start, pose] = folge[i];
const mix = i > 0 ? Math.min(1, (frame - start) / blende) : 1;
return {pose, von: mix < 1 ? folge[i - 1][1] : undefined, mix};
};
type Lage = {
x: number;
y: number;
hoehe: number;
spiegeln?: boolean;
drehung?: number; // Grad, um den Fußpunkt
stauchX?: number;
stauchY?: number;
deckkraft?: number;
schatten?: boolean;
children: React.ReactNode;
figur: FigurName;
};
// Gemeinsamer Rahmen: Leinwand der Figur so legen, dass der Fußpunkt auf (x, y) liegt.
const Rahmen: React.FC<Lage> = ({x, y, hoehe, spiegeln, drehung = 0, stauchX = 1, stauchY = 1, deckkraft = 1, schatten = true, figur, children}) => {
const f = FIGUREN[figur];
const s = hoehe / f.refHoehe;
return (
<div style={{position: 'absolute', left: x, top: y, width: 0, height: 0, opacity: deckkraft}}>
{schatten && (
<div
style={{
position: 'absolute',
left: -hoehe * 0.32 * (figur === 'lulu' ? 0.7 : 1),
top: -hoehe * 0.025,
width: hoehe * 0.64 * (figur === 'lulu' ? 0.7 : 1),
height: hoehe * 0.05,
borderRadius: '50%',
background: 'radial-gradient(closest-side, rgba(60,45,30,0.32), rgba(60,45,30,0))',
}}
/>
)}
<div style={{position: 'absolute', transform: `rotate(${drehung}deg) scale(${(spiegeln ? -1 : 1) * stauchX}, ${stauchY})`, transformOrigin: '0 0'}}>
<div style={{position: 'absolute', left: -f.fussX * s, top: -f.fussY * s, transform: `scale(${s})`, transformOrigin: '0 0'}}>
{children}
</div>
</div>
</div>
);
};
const PosenBild: React.FC<{name: string; deckkraft?: number}> = ({name, deckkraft = 1}) => {
const p = POSEN[name];
if (!p) throw new Error(`Pose fehlt: ${name}`);
return <Img src={staticFile(p.datei)} style={{position: 'absolute', left: p.x, top: p.y, width: p.b, height: p.h, opacity: deckkraft}} />;
};
type FigurProps = Omit<Lage, 'children' | 'figur' | 'hoehe'> & {
pose: string | Posenfolge;
hoehe?: number; // Bildschirmhöhe der Referenzpose; Standard: Größenverhältnis × NOA
frame?: number; // eigener Zeitbezug für Posenfolgen (Standard: aktueller Frame)
blende?: number;
};
export const NOA_HOEHE = 340;
export const Figur: React.FC<FigurProps> = ({pose, hoehe, frame, blende, ...lage}) => {
const jetzt = useCurrentFrame();
const folge: Posenfolge = typeof pose === 'string' ? [[0, pose]] : pose;
const st = posenStand(frame ?? jetzt, folge, blende);
const figur = POSEN[st.pose].figur as FigurName;
return (
<Rahmen {...lage} figur={figur} hoehe={hoehe ?? NOA_HOEHE * GROESSE[figur]}>
{st.von && <PosenBild name={st.von} />}
<PosenBild name={st.pose} deckkraft={st.mix} />
</Rahmen>
);
};
// Bewegungsclip (freigestellt, WebM mit Alpha). `start` = Frame, an dem der Clip
// beginnt; davor steht das erste Bild, danach das letzte. `schleife` wiederholt ihn.
type ClipProps = Omit<Lage, 'children' | 'figur' | 'hoehe'> & {
name: string;
start: number;
hoehe?: number;
tempo?: number;
schleife?: boolean;
bis?: number; // nur bis zu dieser Clip-Sekunde abspielen, dann anhalten
rueckwaerts?: boolean;
ersatz?: string; // Standbild, falls der Clip fehlt
};
export const ClipFigur: React.FC<ClipProps> = ({name, start, hoehe, tempo = 1, schleife = false, bis, rueckwaerts = false, ersatz, ...lage}) => {
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
const c = CLIPS[name];
if (!c) {
// Clip noch nicht freigestellt: Standbild der Figur als Ersatz
const figurName = name.split('_')[0] as FigurName;
return <Figur pose={ersatz ?? ({pino: 'pino_ref_sitzend', noa: 'noa_ref_stehend', lulu: 'lulu_A6_blick-rechts'} as const)[figurName]} hoehe={hoehe} {...lage} />;
}
const figur = c.figur as FigurName;
const laenge = (bis ?? c.frames / 24) * fps; // in Frames der Komposition bei Tempo 1
let t = Math.max(0, (frame - start) * tempo);
t = schleife ? t % (laenge - 1) : Math.min(t, laenge - 1);
if (rueckwaerts) t = laenge - 1 - t;
return (
<Rahmen {...lage} figur={figur} hoehe={hoehe ?? NOA_HOEHE * GROESSE[figur]}>
<div style={{position: 'absolute', left: c.x, top: c.y, width: c.b, height: c.h}}>
<Freeze frame={Math.floor(t)}>
<OffthreadVideo src={staticFile(c.datei)} transparent muted style={{width: '100%', height: '100%'}} />
</Freeze>
</div>
</Rahmen>
);
};
// 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}) => {
const x = frei ? kx : Math.min(1920 - 960 / zoom, Math.max(960 / zoom, kx));
const y = frei ? ky : Math.min(1080 - 540 / zoom, Math.max(540 / zoom, ky));
return (
<div
style={{
position: 'absolute',
inset: 0,
overflow: 'hidden',
}}
>
<div style={{position: 'absolute', width: 1920, height: 1080, transform: `translate(960px, 540px) scale(${zoom}) translate(${-x}px, ${-y}px)`, transformOrigin: '0 0'}}>
{children}
</div>
</div>
);
};

View file

@ -0,0 +1,72 @@
import React from 'react';
import {interpolate, useCurrentFrame} from 'remotion';
import {kurve} from '../bewegung';
import {FIGUREN} from './posen';
import {GROESSE, NOA_HOEHE, type FigurName} from './figur';
// Kamerafahrt: [[frame, x, y, zoom], ...] mit weichen Übergängen.
export const kamFahrt = (frame: number, punkte: [number, number, number, number][]) => ({
x: kurve(frame, punkte.map(([f, x]) => [f, x])),
y: kurve(frame, punkte.map(([f, , y]) => [f, y])),
zoom: kurve(frame, punkte.map(([f, , , z]) => [f, z])),
});
// Leichtes Atmen: Stauchung in y, je Figur etwas anders getaktet.
export const atmen = (frame: number, phase = 0, staerke = 0.008) => 1 + Math.sin(frame * 0.07 + phase) * staerke;
// Gehzyklus aus den vier Geh-Standbildern (Blick nach links; nach rechts spiegeln).
export const gehPose = (figur: FigurName, frame: number, bilderProSek = 6) => `${figur}_A7-${1 + (Math.floor((frame * bilderProSek) / 30) % 4)}_gehen`;
// Wippen beim Gehen (Höhe in px)
export const gehWippen = (frame: number, bilderProSek = 6, hoehe = 6) => -Math.abs(Math.sin((frame * bilderProSek * Math.PI) / 60)) * hoehe;
// Bildschirmpunkt eines Punktes (cx, cy) aus der Bild-Leinwand der Figur.
export const punkt = (figur: FigurName, x: number, y: number, cx: number, cy: number, hoehe = NOA_HOEHE * GROESSE[figur], spiegeln = false) => {
const f = FIGUREN[figur];
const s = hoehe / f.refHoehe;
return {x: x + (cx - f.fussX) * s * (spiegeln ? -1 : 1), y: y + (cy - f.fussY) * s};
};
// Bekannte Punkte in der Leinwand (gemessen an den Standbildern)
export const LULU_MAUL_LINKS = {cx: 690, cy: 140}; // lulu_A5 (Blick links)
export const LULU_KOPF = {cx: 714, cy: 95};
export const PINO_KOPF = {cx: 640, cy: 260};
// Weiche Blende am Anfang und Ende einer Szene (cremefarbenes Papier).
export const Blende: React.FC<{dauer: number; rein?: number; raus?: number; farbe?: string}> = ({dauer, rein = 10, raus = 10, farbe = '#f4ead8'}) => {
const frame = useCurrentFrame();
const o = Math.max(
rein ? interpolate(frame, [0, rein], [1, 0], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}) : 0,
raus ? interpolate(frame, [dauer - raus, dauer], [0, 1], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}) : 0,
);
return o > 0 ? <div style={{position: 'absolute', inset: 0, background: farbe, opacity: o}} /> : null;
};
// Vignette und Papierstruktur über allem
export const Vignette: React.FC<{staerke?: number}> = ({staerke = 0.35}) => (
<div style={{position: 'absolute', inset: 0, background: `radial-gradient(ellipse at 50% 50%, rgba(0,0,0,0) 60%, rgba(40,30,20,${staerke}) 100%)`}} />
);
// Wackeln (Kamera bei Schreck/Niesen): abklingend ab `start`
export const ruck = (frame: number, start: number, staerke = 10, dauer = 18) => {
const t = frame - start;
if (t < 0 || t > dauer) return 0;
return Math.sin(t * 2.2) * staerke * (1 - t / dauer);
};
// Kurzer Hüpfer: 0 → -hoehe → 0 zwischen start und start+dauer
export const huepfer = (frame: number, start: number, hoehe = 30, dauer = 12) => {
const t = (frame - start) / dauer;
if (t < 0 || t > 1) return 0;
return -Math.sin(t * Math.PI) * hoehe;
};
// Stauchung beim Landen/Abspringen um einen Hüpfer herum
export const hopfStauch = (frame: number, start: number, dauer = 12) => {
const vor = (frame - (start - 5)) / 5;
const nach = (frame - (start + dauer)) / 7;
if (vor >= 0 && vor < 1) return 1 - 0.07 * Math.sin(vor * Math.PI);
if (nach >= 0 && nach < 1) return 1 - 0.06 * Math.sin(nach * Math.PI);
if (frame >= start && frame < start + dauer) return 1.04;
return 1;
};

View file

@ -0,0 +1,68 @@
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';
// 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(
[
{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,
);
const Note: React.FC<{x: number; y: number; s: number; farbe: string}> = ({x, y, s, farbe}) => (
<svg width={60} height={80} viewBox="0 0 60 80" style={{position: 'absolute', left: x, top: y, transform: `scale(${s})`}}>
<ellipse cx={18} cy={64} rx={15} ry={11} fill={farbe} transform="rotate(-20 18 64)" />
<rect x={30} y={8} width={6} height={58} fill={farbe} />
<path d="M 36 8 C 50 18, 56 30, 46 44 C 50 30, 44 24, 36 22 Z" fill={farbe} />
</svg>
);
export const Lied: React.FC = () => {
const f = useCurrentFrame();
const {z, dauer} = LIED;
const schlag = Math.abs(Math.sin((f * Math.PI) / TAKT));
const wipp = (phase: number, h: number) => -Math.abs(Math.sin((f * Math.PI) / TAKT + phase)) * h;
const schwank = (phase: number, g: number) => Math.sin((f * Math.PI) / (2 * TAKT) + phase) * g;
return (
<>
<Kamera x={980} y={600} zoom={1.3}>
<Ort name="garten" />
<Figur pose="lulu_A3_lachen" x={1240} y={BODEN} drehung={schwank(0.5, 2.2)} stauchY={1 - 0.012 * schlag} />
<Figur
pose={[
[0, 'noa_A3_lachen'],
[z.r4.start, 'noa_A_arme-hoch'],
]}
x={960}
y={BODEN + wipp(0.3, 10)}
drehung={schwank(0, 3)}
/>
<Figur pose="pino_A3_lachen" x={720} y={BODEN + wipp(0, 16)} drehung={schwank(1, 4)} stauchY={1 - 0.03 * schlag} />
</Kamera>
{new Array(8).fill(0).map((_, i) => {
const t = ((f + i * 25) % 200) / 200;
const x = 200 + random(`n${i}`) * 1500 + Math.sin(t * 6 + i) * 30;
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ß ♫
</div>
<Vignette />
<Untertitel zeilen={LIED.liste} />
<Blende dauer={dauer} rein={12} raus={12} />
</>
);
};

View file

@ -0,0 +1,248 @@
import React from 'react';
import {random, useCurrentFrame} from 'remotion';
import {SCHRIFT} from './untertitel';
// Kleine, per Code gezeichnete Dinge: Blätter, Vögel, Schnee, Nest, Zzz, Funkeln.
export const LAUB = ['#c8642c', '#d99a2b', '#a8452a', '#8f5a2e', '#e0b04a', '#b7792f'];
// Ein Blatt, Spitze nach oben, ca. 70 px lang bei s = 1.
export const Blatt: React.FC<{x: number; y: number; r?: number; s?: number; farbe: string; deckkraft?: number}> = ({x, y, r = 0, s = 1, farbe, deckkraft = 1}) => (
<svg
width={80}
height={90}
viewBox="-40 -55 80 90"
style={{position: 'absolute', left: x - 40, top: y - 45, transform: `rotate(${r}deg) scale(${s})`, overflow: 'visible', opacity: deckkraft}}
>
<path d="M0,-50 C 24,-36 26,-4 0,22 C -26,-4 -24,-36 0,-50 Z" fill={farbe} stroke="rgba(80,40,20,0.35)" strokeWidth={2} />
<path d="M0,-42 L0,32" stroke="rgba(90,45,20,0.45)" strokeWidth={2.5} strokeLinecap="round" />
<path d="M0,-18 L12,-28 M0,-4 L14,-14 M0,-12 L-12,-22 M0,2 L-13,-8" stroke="rgba(90,45,20,0.3)" strokeWidth={1.6} />
</svg>
);
// Blätter, die der Wind durchs Bild weht (von links nach rechts).
export const Blaetterwind: React.FC<{anzahl?: number; seed?: string; tempo?: number; yBereich?: [number, number]}> = ({
anzahl = 7,
seed = 'wind',
tempo = 1,
yBereich = [250, 850],
}) => {
const frame = useCurrentFrame();
return (
<>
{new Array(anzahl).fill(0).map((_, i) => {
const r = (k: string) => random(`${seed}${k}${i}`);
const periode = 260 + r('p') * 200;
const t = ((frame * tempo + r('o') * periode) % periode) / periode;
const x = -120 + t * 2160;
const y = yBereich[0] + r('y') * (yBereich[1] - yBereich[0]) + Math.sin(t * 9 + r('w') * 6) * 60 + t * 120;
return <Blatt key={i} x={x} y={y} r={frame * (3 + r('d') * 4) + r('a') * 360} s={0.5 + r('s') * 0.4} farbe={LAUB[i % LAUB.length]} />;
})}
</>
);
};
// Blätterregen nach dem Niesen: Blätter fliegen ab `start` aus (x0, y0) und landen auf `boden`.
export const Blaetterregen: React.FC<{start: number; x0: number; y0: number; boden: number; anzahl?: number; aus?: number}> = ({start, x0, y0, boden, anzahl = 34, aus = 1e9}) => {
const frame = useCurrentFrame();
const t = frame - start;
const deck = Math.max(0, 1 - (frame - aus) / 60);
if (t < 0 || deck <= 0) return null;
return (
<>
{new Array(anzahl).fill(0).map((_, i) => {
const r = (k: string) => random(`regen${k}${i}`);
const winkel = -Math.PI * (0.05 + 0.9 * r('w'));
const v = 14 + r('v') * 22;
// erst schneller Ausstoß (abgebremst), dann langsames Herabschaukeln
const raus = 1 - Math.exp(-t / 9);
const fall = Math.max(0, t - 12);
let x = x0 + Math.cos(winkel) * v * 9 * raus + Math.sin(fall * 0.09 + r('s') * 6) * 50;
let y = y0 + Math.sin(winkel) * v * 9 * raus + fall * (2.2 + r('f') * 1.6);
const ziel = boden + r('b') * 70 - 20;
const gelandet = y >= ziel;
if (gelandet) y = ziel;
x = Math.min(1900, Math.max(20, x));
return <Blatt key={i} x={x} y={y} r={gelandet ? r('l') * 160 - 80 : t * (5 + r('d') * 6) + r('a') * 360} s={0.55 + r('g') * 0.45} farbe={LAUB[i % LAUB.length]} deckkraft={Math.min(1, deck)} />;
})}
</>
);
};
// Ein Vogelschwarm zieht nach rechts oben davon.
export const Voegel: React.FC<{start: number; anzahl?: number; skala?: number}> = ({start, anzahl = 11, skala = 1}) => {
const frame = useCurrentFrame();
const t = frame - start;
return (
<svg viewBox="0 0 1920 1080" style={{position: 'absolute', inset: 0, width: '100%', height: '100%'}}>
{new Array(anzahl).fill(0).map((_, i) => {
const r = (k: string) => random(`vogel${k}${i}`);
const reihe = Math.abs(i - (anzahl - 1) / 2);
const x = 100 + t * 6 - reihe * 70 * skala + r('x') * 30;
const y = 560 - t * 1.6 + reihe * 38 * skala + r('y') * 20;
const schlag = Math.sin(t * 0.45 + r('p') * 6) * 12 * skala;
const g = (16 + r('g') * 6) * skala;
return <path key={i} d={`M ${x - g} ${y - schlag} Q ${x - g / 2} ${y - 4} ${x} ${y} Q ${x + g / 2} ${y - 4} ${x + g} ${y - schlag}`} stroke="#4a4646" strokeWidth={3.5 * skala} fill="none" strokeLinecap="round" />;
})}
</svg>
);
};
// Schneeflocken; `anzahl` = 1 für die erste Flocke, die auf `ziel` landet.
export const Schnee: React.FC<{start: number; ziel?: [number, number]; anzahl?: number}> = ({start, ziel, anzahl = 1}) => {
const frame = useCurrentFrame();
return (
<>
{new Array(anzahl).fill(0).map((_, i) => {
const t = frame - start - i * 40;
if (t < 0) return null;
const r = (k: string) => random(`schnee${k}${i}`);
let x: number;
let y: number;
if (ziel && i === 0) {
const dauer = 240;
const u = Math.min(1, t / dauer);
x = ziel[0] + Math.sin(u * 7) * 40 * (1 - u);
y = -30 + (ziel[1] + 30) * u;
} else {
x = r('x') * 1920 + Math.sin(t * 0.03 + r('p') * 6) * 40;
y = -30 + t * (1.3 + r('v'));
if (y > 1100) return null;
}
const g = 7 + r('g') * 5;
return (
<div
key={i}
style={{
position: 'absolute',
left: x - g,
top: y - g,
width: g * 2,
height: g * 2,
borderRadius: '50%',
background: 'radial-gradient(circle, #ffffff 0%, #f4f8ff 45%, rgba(240,246,255,0) 72%)',
}}
/>
);
})}
</>
);
};
// Blätternest: ein runder Laubhaufen. `voll` 0…1 lässt ihn wachsen.
// Hinten und vorne getrennt, damit Pino „im“ Nest sitzen kann.
const nestBlaetter = (seite: 'hinten' | 'vorne') =>
new Array(seite === 'hinten' ? 46 : 30).fill(0).map((_, i) => {
const r = (k: string) => random(`nest${seite}${k}${i}`);
const w = Math.PI * r('w');
const rad = seite === 'hinten' ? 0.55 + r('r') * 0.5 : 0.75 + r('r') * 0.3;
return {
x: Math.cos(w) * 150 * rad * (seite === 'vorne' ? 1.05 : 1),
y: seite === 'hinten' ? -Math.sin(w) * 95 * rad : -Math.sin(w) * 38 * rad + 8,
r: r('d') * 360,
s: 0.75 + r('s') * 0.45,
farbe: LAUB[Math.floor(r('f') * LAUB.length)],
reihe: r('o'),
};
});
const NEST = {hinten: nestBlaetter('hinten'), vorne: nestBlaetter('vorne')};
export const Nest: React.FC<{x: number; y: number; voll?: number; seite: 'hinten' | 'vorne'; s?: number}> = ({x, y, voll = 1, seite, s = 1}) => (
<div style={{position: 'absolute', left: x, top: y, transform: `scale(${s})`, transformOrigin: '0 0'}}>
{seite === 'hinten' && (
<div style={{position: 'absolute', left: -170, top: -30, width: 340, height: 60, borderRadius: '50%', background: 'radial-gradient(closest-side, rgba(70,45,25,0.45), rgba(70,45,25,0))'}} />
)}
{NEST[seite].map((b, i) =>
b.reihe < voll ? <Blatt key={i} x={b.x * (0.5 + 0.5 * voll)} y={b.y * (0.4 + 0.6 * voll)} r={b.r} s={b.s} farbe={b.farbe} /> : null,
)}
</div>
);
// Zzz, die aufsteigen und verblassen.
export const Zzz: React.FC<{x: number; y: number; start: number; ende?: number; s?: number}> = ({x, y, start, ende = 1e9, s = 1}) => {
const frame = useCurrentFrame();
if (frame < start || frame > ende + 60) return null;
return (
<>
{[0, 1, 2].map((i) => {
const t = ((frame - start + i * 30) % 90) / 90;
const aus = frame > ende ? Math.max(0, 1 - (frame - ende) / 30) : 1;
return (
<div
key={i}
style={{
position: 'absolute',
left: x + t * 70 * s + Math.sin(t * 6) * 10,
top: y - t * 120 * s,
fontFamily: SCHRIFT,
fontWeight: 600,
fontSize: (26 + t * 26) * s,
color: '#6d7fb0',
opacity: Math.sin(t * Math.PI) * aus,
}}
>
z
</div>
);
})}
</>
);
};
// Funkeln (Idee, Vorfreude): vier kleine Sterne um (x, y).
export const Funkeln: React.FC<{x: number; y: number; start: number; dauer?: number; s?: number}> = ({x, y, start, dauer = 40, s = 1}) => {
const frame = useCurrentFrame();
const t = (frame - start) / dauer;
if (t < 0 || t > 1) return null;
return (
<>
{[0, 1, 2, 3].map((i) => {
const w = (i / 4) * Math.PI * 2 + 0.4;
const d = (40 + 50 * t) * s;
const g = 30 * s * Math.sin(Math.PI * Math.min(1, t * 1.4));
return (
<svg key={i} width={g * 2} height={g * 2} viewBox="-10 -10 20 20" style={{position: 'absolute', left: x + Math.cos(w) * d - g, top: y + Math.sin(w) * d - g}}>
<path d="M0,-10 Q1.5,-1.5 10,0 Q1.5,1.5 0,10 Q-1.5,1.5 -10,0 Q-1.5,-1.5 0,-10 Z" fill="#ffd45e" />
</svg>
);
})}
</>
);
};
// Großer Comic-Text (Hatschi!, Zahlen beim Zählen).
export const Ruf: React.FC<{text: string; x: number; y: number; start: number; dauer?: number; groesse?: number; farbe?: string}> = ({
text,
x,
y,
start,
dauer = 30,
groesse = 120,
farbe = '#e07a3c',
}) => {
const frame = useCurrentFrame();
const t = (frame - start) / dauer;
if (t < 0 || t > 1) return null;
const auf = Math.min(1, t * 6);
const s = 0.4 + 0.6 * auf + 0.12 * Math.sin(Math.min(1, t * 3) * Math.PI);
return (
<div
style={{
position: 'absolute',
left: x,
top: y,
transform: `translate(-50%, -50%) scale(${s})`,
fontFamily: SCHRIFT,
fontWeight: 600,
fontSize: groesse,
color: farbe,
opacity: t > 0.8 ? (1 - t) / 0.2 : 1,
WebkitTextStroke: '6px #fff8ea',
paintOrder: 'stroke fill',
whiteSpace: 'nowrap',
}}
>
{text}
</div>
);
};

View file

@ -0,0 +1,372 @@
import React from 'react';
import {AbsoluteFill, Img, random, staticFile} from 'remotion';
import './untertitel'; // lädt die Schrift für den Kalender
// Orte der Folge 2 in Aquarell-Optik. Werden einmal als PNG vorberechnet
// (Kompositionen „F2Ort…“, siehe README) und in den Szenen als Bild benutzt.
const B = 1920;
const H = 1080;
const Filter: React.FC = () => (
<defs>
<filter id="aq" x="-10%" y="-10%" width="120%" height="120%">
<feTurbulence type="fractalNoise" baseFrequency="0.012" numOctaves="2" seed="7" result="r" />
<feDisplacementMap in="SourceGraphic" in2="r" scale="10" xChannelSelector="R" yChannelSelector="G" result="rand" />
<feMorphology in="rand" operator="erode" radius="4" result="innen" />
<feGaussianBlur in="innen" stdDeviation="5" result="iw" />
<feComposite in="rand" in2="iw" operator="out" result="saum" />
<feColorMatrix in="saum" type="matrix" values="0.75 0 0 0 0 0 0.72 0 0 0 0 0 0.7 0 0 0 0 0 0.45 0" result="sd" />
<feTurbulence type="fractalNoise" baseFrequency="0.004 0.007" numOctaves="2" seed="11" result="fl" />
<feColorMatrix in="fl" type="matrix" values="0 0 0 0 1 0 0 0 0 0.97 0 0 0 0 0.9 0 0 0 -0.4 0.2" result="hell" />
<feComposite in="hell" in2="rand" operator="in" result="wolken" />
<feMerge>
<feMergeNode in="rand" />
<feMergeNode in="wolken" />
<feMergeNode in="sd" />
</feMerge>
</filter>
<filter id="stift">
<feTurbulence type="fractalNoise" baseFrequency="0.04" numOctaves="2" seed="2" result="r" />
<feDisplacementMap in="SourceGraphic" in2="r" scale="4" xChannelSelector="R" yChannelSelector="G" />
</filter>
<filter id="papierkorn">
<feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" seed="3" />
<feColorMatrix type="matrix" values="0 0 0 0 0.5 0 0 0 0 0.45 0 0 0 0 0.4 0 0 0 0.5 0" />
</filter>
<filter id="weich6">
<feGaussianBlur stdDeviation="6" />
</filter>
</defs>
);
const Korn: React.FC<{staerke?: number}> = ({staerke = 0.18}) => (
<rect width={B} height={H} filter="url(#papierkorn)" style={{mixBlendMode: 'multiply'}} opacity={staerke} />
);
// Kahler Ast als Pinselstrich (rekursiv verzweigt).
const Ast: React.FC<{x: number; y: number; w: number; l: number; d: number; tiefe: number; farbe: string; seed: string}> = ({x, y, w, l, d, tiefe, farbe, seed}) => {
const rad = (w * Math.PI) / 180;
const x2 = x + Math.cos(rad) * l;
const y2 = y + Math.sin(rad) * l;
const knick = (random(seed + 'k') - 0.5) * l * 0.25;
return (
<g>
<path
d={`M ${x} ${y} Q ${(x + x2) / 2 + knick} ${(y + y2) / 2 - knick} ${x2} ${y2}`}
stroke={farbe}
strokeWidth={d}
strokeLinecap="round"
fill="none"
/>
{tiefe > 0 &&
[-1, 1, 0].slice(0, tiefe > 2 ? 3 : 2).map((s, i) => (
<Ast
key={i}
x={x2}
y={y2}
w={w + s * (18 + random(seed + i) * 22)}
l={l * (0.62 + random(seed + 'l' + i) * 0.15)}
d={d * 0.62}
tiefe={tiefe - 1}
farbe={farbe}
seed={seed + i}
/>
))}
</g>
);
};
const Apfelbaum: React.FC<{x: number; y: number; s?: number; farbe: string; blaetter?: boolean}> = ({x, y, s = 1, farbe, blaetter = true}) => (
<g transform={`translate(${x} ${y}) scale(${s})`}>
<path d="M -34 0 C -26 -120, -30 -260, -14 -380 L 14 -380 C 26 -260, 22 -120, 38 0 Z" fill={farbe} filter="url(#aq)" />
<Ast x={0} y={-370} w={-90} l={150} d={22} tiefe={4} farbe={farbe} seed="a" />
<Ast x={-6} y={-330} w={-150} l={170} d={20} tiefe={4} farbe={farbe} seed="b" />
<Ast x={6} y={-320} w={-35} l={180} d={20} tiefe={4} farbe={farbe} seed="c" />
<Ast x={0} y={-250} w={-175} l={120} d={14} tiefe={3} farbe={farbe} seed="d" />
<Ast x={4} y={-240} w={-10} l={130} d={14} tiefe={3} farbe={farbe} seed="e" />
{blaetter &&
new Array(9).fill(0).map((_, i) => {
const bx = -260 + random('bl' + i) * 520;
const by = -560 + random('bly' + i) * 240;
return <ellipse key={i} cx={bx} cy={by} rx={11} ry={7} transform={`rotate(${random('br' + i) * 180} ${bx} ${by})`} fill={['#c8642c', '#d99a2b', '#a8452a'][i % 3]} />;
})}
</g>
);
const Zaun: React.FC<{y: number; farbe: string; dunkel: string}> = ({y, farbe, dunkel}) => (
<g filter="url(#aq)">
<rect x={-20} y={y + 30} width={B + 40} height={16} fill={dunkel} />
<rect x={-20} y={y + 95} width={B + 40} height={16} fill={dunkel} />
{new Array(26).fill(0).map((_, i) => {
const x = -10 + i * 78 + (random('z' + i) - 0.5) * 6;
const h = 150 + random('zh' + i) * 14;
return <path key={i} d={`M ${x} ${y + 160} L ${x} ${y + 160 - h + 18} L ${x + 22} ${y + 160 - h} L ${x + 44} ${y + 160 - h + 18} L ${x + 44} ${y + 160} Z`} fill={farbe} />;
})}
</g>
);
const Holzstapel: React.FC<{x: number; y: number; nacht?: boolean}> = ({x, y, nacht}) => {
const holz = nacht ? '#5b4a52' : '#a67a52';
const ring = nacht ? '#7a6670' : '#d9b48a';
const reihen = [7, 6, 5, 4];
return (
<g transform={`translate(${x} ${y})`}>
<rect x={-20} y={-300} width={480} height={300} fill={nacht ? '#3e3644' : '#7d5c3e'} filter="url(#aq)" />
{reihen.map((n, r) =>
new Array(n).fill(0).map((_, i) => {
const cx = 34 + i * 64 + r * 32 + (random(`h${r}${i}`) - 0.5) * 6;
const cy = -34 - r * 60;
const rr = 30 + random(`hr${r}${i}`) * 4;
return (
<g key={`${r}${i}`}>
<circle cx={cx} cy={cy} r={rr} fill={holz} />
<circle cx={cx} cy={cy} r={rr * 0.78} fill={ring} />
<circle cx={cx} cy={cy} r={rr * 0.45} fill="none" stroke={holz} strokeWidth={2} opacity={0.6} />
<circle cx={cx} cy={cy} r={rr * 0.18} fill={holz} opacity={0.6} />
</g>
);
}),
)}
{/* Dach aus einem Brett */}
<path d="M -40 -262 L 470 -262 L 450 -240 L -20 -240 Z" fill={nacht ? '#4a3e48' : '#8a6544'} filter="url(#aq)" />
</g>
);
};
const Wiese: React.FC<{nacht?: boolean; laub?: number}> = ({nacht, laub = 40}) => (
<g>
<path d="M -50 790 C 400 760, 900 780, 1300 770 S 1800 760, 1990 780 L 1990 1100 L -50 1100 Z" fill={nacht ? '#2f4148' : '#a3a873'} filter="url(#aq)" />
<path d="M -50 900 C 500 880, 1100 900, 1990 880 L 1990 1100 L -50 1100 Z" fill={nacht ? '#283840' : '#959b66'} filter="url(#aq)" opacity={0.8} />
{new Array(laub).fill(0).map((_, i) => {
const bx = random('wl' + i) * B;
const by = 820 + random('wly' + i) * 250;
return (
<ellipse
key={i}
cx={bx}
cy={by}
rx={13 + random('wr' + i) * 6}
ry={6}
transform={`rotate(${random('wd' + i) * 50 - 25} ${bx} ${by})`}
fill={nacht ? '#4a4048' : ['#c8642c', '#d99a2b', '#a8452a', '#8f5a2e'][i % 4]}
opacity={0.85}
/>
);
})}
</g>
);
export const GartenSVG: React.FC<{nacht?: boolean}> = ({nacht = false}) => (
<svg viewBox={`0 0 ${B} ${H}`} style={{position: 'absolute', inset: 0, width: '100%', height: '100%'}}>
<Filter />
<defs>
<linearGradient id="himmelG" x1="0" y1="0" x2="0" y2="1">
{nacht ? (
<>
<stop offset="0" stopColor="#1d2547" />
<stop offset="0.7" stopColor="#36406b" />
<stop offset="1" stopColor="#4f5a80" />
</>
) : (
<>
<stop offset="0" stopColor="#c4ccd3" />
<stop offset="0.6" stopColor="#dfdcd2" />
<stop offset="1" stopColor="#efe6d4" />
</>
)}
</linearGradient>
</defs>
<rect width={B} height={H} fill="url(#himmelG)" />
{nacht &&
new Array(50).fill(0).map((_, i) => (
<circle key={i} cx={random('st' + i) * B} cy={random('sty' + i) * 450} r={1.2 + random('str' + i) * 2.2} fill="#fffbe8" opacity={0.5 + random('sto' + i) * 0.5} />
))}
{nacht && <circle cx={1580} cy={170} r={54} fill="#fbf1d2" filter="url(#aq)" />}
{/* ferne Hügel */}
<path d="M -50 640 C 300 560, 700 600, 1000 620 S 1600 560, 1990 600 L 1990 900 L -50 900 Z" fill={nacht ? '#3a4566' : '#bcc2bc'} filter="url(#aq)" />
<path d="M -50 700 C 400 650, 800 690, 1200 680 S 1700 650, 1990 690 L 1990 900 L -50 900 Z" fill={nacht ? '#34405a' : '#aab3a2'} filter="url(#aq)" />
{/* ferne kahle Bäume */}
{[160, 820, 1000, 1760].map((x, i) => (
<Ast key={i} x={x} y={690} w={-90} l={70} d={7} tiefe={3} farbe={nacht ? '#2c3550' : '#8f8f86'} seed={'f' + i} />
))}
<Zaun y={600} farbe={nacht ? '#4a4a5c' : '#c9a57a'} dunkel={nacht ? '#3a3a4c' : '#a8835a'} />
<Holzstapel x={1260} y={880} nacht={nacht} />
<Wiese nacht={nacht} />
<Apfelbaum x={330} y={900} s={1.05} farbe={nacht ? '#2e2a36' : '#6e5038'} blaetter={!nacht} />
<Korn staerke={nacht ? 0.12 : 0.2} />
</svg>
);
// Blick von oben über den Zaun: kahle Baumkronen, weites Land, grauer Himmel.
export const ObenSVG: React.FC = () => (
<svg viewBox={`0 0 ${B} ${H}`} style={{position: 'absolute', inset: 0, width: '100%', height: '100%'}}>
<Filter />
<defs>
<linearGradient id="himmelO" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#b9c3cc" />
<stop offset="1" stopColor="#e8e2d6" />
</linearGradient>
</defs>
<rect width={B} height={H} fill="url(#himmelO)" />
<path d="M -50 560 C 400 520, 900 540, 1300 530 S 1800 520, 1990 540 L 1990 1100 L -50 1100 Z" fill="#c2c6b6" filter="url(#aq)" />
<path d="M -50 640 C 500 600, 1000 630, 1990 610 L 1990 1100 L -50 1100 Z" fill="#b1b79c" filter="url(#aq)" />
{/* Felder */}
<path d="M 300 700 L 900 660 L 1100 760 L 420 820 Z" fill="#c9b98e" filter="url(#aq)" opacity={0.8} />
<path d="M 1100 660 L 1700 640 L 1820 740 L 1150 760 Z" fill="#a9a57c" filter="url(#aq)" opacity={0.8} />
{new Array(9).fill(0).map((_, i) => (
<Ast key={i} x={80 + i * 220 + random('ob' + i) * 60} y={1120} w={-90 + (random('obw' + i) - 0.5) * 20} l={170 + random('obl' + i) * 60} d={18} tiefe={4} farbe="#6e5a48" seed={'o' + i} />
))}
<rect x={-20} y={1010} width={B + 40} height={20} fill="#a8835a" filter="url(#aq)" />
<Korn />
</svg>
);
// Noas Zimmer am Abend: zwei Ebenen, damit Lulus Kopf im Fenster erscheinen kann.
const FENSTER = {x: 1180, y: 150, b: 520, h: 470};
export const ZimmerHimmelSVG: React.FC = () => (
<svg viewBox={`0 0 ${B} ${H}`} style={{position: 'absolute', inset: 0, width: '100%', height: '100%'}}>
<Filter />
<defs>
<linearGradient id="abendZ" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#27315a" />
<stop offset="1" stopColor="#6a6390" />
</linearGradient>
</defs>
<rect width={B} height={H} fill="url(#abendZ)" />
<circle cx={1560} cy={250} r={40} fill="#fbf1d2" filter="url(#aq)" />
{new Array(18).fill(0).map((_, i) => (
<circle key={i} cx={FENSTER.x + random('zs' + i) * FENSTER.b} cy={FENSTER.y + random('zsy' + i) * 260} r={1.5 + random('zsr' + i) * 2} fill="#fffbe8" />
))}
<path d="M 1100 560 C 1300 520, 1500 540, 1800 530 L 1800 1100 L 1100 1100 Z" fill="#3a4566" filter="url(#aq)" />
</svg>
);
export const ZimmerWandSVG: React.FC = () => (
<svg viewBox={`0 0 ${B} ${H}`} style={{position: 'absolute', inset: 0, width: '100%', height: '100%'}}>
<Filter />
<defs>
<mask id="loch">
<rect width={B} height={H} fill="white" />
<rect x={FENSTER.x} y={FENSTER.y} width={FENSTER.b} height={FENSTER.h} rx={8} fill="black" />
</mask>
<radialGradient id="lampenlicht" cx="0.18" cy="0.35" r="0.75">
<stop offset="0" stopColor="#ffe2a8" stopOpacity="0.75" />
<stop offset="1" stopColor="#ffe2a8" stopOpacity="0" />
</radialGradient>
</defs>
<g mask="url(#loch)">
<rect width={B} height={H} fill="#e9cfa4" />
{/* Tapete mit kleinen Sternen */}
{new Array(70).fill(0).map((_, i) => (
<circle key={i} cx={(i % 10) * 200 + ((Math.floor(i / 10) % 2) * 100) + 40} cy={Math.floor(i / 10) * 120 + 60} r={5} fill="#d8b98a" />
))}
<rect width={B} height={H} fill="url(#lampenlicht)" />
{/* Boden */}
<path d="M -20 820 L 1940 820 L 1940 1100 L -20 1100 Z" fill="#b98a5a" filter="url(#aq)" />
{new Array(8).fill(0).map((_, i) => (
<line key={i} x1={-20} y1={850 + i * 32} x2={1940} y2={850 + i * 32} stroke="#a47549" strokeWidth={3} />
))}
{/* Fensterrahmen und Vorhänge */}
<rect x={FENSTER.x - 26} y={FENSTER.y - 26} width={FENSTER.b + 52} height={FENSTER.h + 52} rx={12} fill="#fbf3e4" filter="url(#aq)" />
<rect x={FENSTER.x - 50} y={FENSTER.y + FENSTER.h + 10} width={FENSTER.b + 100} height={30} rx={6} fill="#f1e2c8" filter="url(#aq)" />
<path d={`M ${FENSTER.x - 120} 90 C ${FENSTER.x - 80} 300, ${FENSTER.x - 110} 500, ${FENSTER.x - 60} 700 L ${FENSTER.x - 10} 700 C ${FENSTER.x - 40} 500, ${FENSTER.x - 20} 300, ${FENSTER.x - 10} 90 Z`} fill="#c66b5a" filter="url(#aq)" />
<path d={`M ${FENSTER.x + FENSTER.b + 120} 90 C ${FENSTER.x + FENSTER.b + 80} 300, ${FENSTER.x + FENSTER.b + 110} 500, ${FENSTER.x + FENSTER.b + 60} 700 L ${FENSTER.x + FENSTER.b + 10} 700 C ${FENSTER.x + FENSTER.b + 40} 500, ${FENSTER.x + FENSTER.b + 20} 300, ${FENSTER.x + FENSTER.b + 10} 90 Z`} fill="#c66b5a" filter="url(#aq)" />
<rect x={FENSTER.x - 150} y={80} width={FENSTER.b + 300} height={18} rx={9} fill="#8a6544" />
{/* Stehlampe links */}
<rect x={236} y={330} width={12} height={500} fill="#6e5038" />
<ellipse cx={242} cy={828} rx={70} ry={14} fill="#6e5038" />
<path d="M 160 330 L 324 330 L 290 200 L 194 200 Z" fill="#f6d58e" filter="url(#aq)" />
{/* Bild an der Wand */}
<rect x={560} y={210} width={240} height={180} rx={6} fill="#fbf3e4" stroke="#a47549" strokeWidth={10} />
<path d="M 590 360 C 640 300, 700 340, 770 290" stroke="#5f8a4a" strokeWidth={8} fill="none" />
<circle cx={720} cy={260} r={22} fill="#e0b04a" />
</g>
{/* Fensterkreuz */}
<rect x={FENSTER.x + FENSTER.b / 2 - 7} y={FENSTER.y} width={14} height={FENSTER.h} fill="#fbf3e4" />
<rect x={FENSTER.x} y={FENSTER.y + 150} width={FENSTER.b / 2} height={14} fill="#fbf3e4" />
<Korn staerke={0.14} />
</svg>
);
// Niedriger Tisch (vor Noa) – eigene Ebene, damit Noas Beine dahinter verschwinden.
export const TischSVG: React.FC = () => (
<svg viewBox={`0 0 ${B} ${H}`} style={{position: 'absolute', inset: 0, width: '100%', height: '100%'}}>
<Filter />
<path d="M 420 780 L 1260 780 L 1300 830 L 380 830 Z" fill="#c99a63" filter="url(#aq)" />
<rect x={400} y={828} width={880} height={36} fill="#a87a4a" filter="url(#aq)" />
<rect x={440} y={860} width={34} height={150} fill="#8a6544" />
<rect x={1206} y={860} width={34} height={150} fill="#8a6544" />
{/* Papierbogen und Buntstifte */}
<path d="M 560 790 L 900 784 L 930 822 L 540 828 Z" fill="#fbf6ea" />
{['#d9534f', '#e0b04a', '#5f8a4a', '#3f5a8a'].map((f, i) => (
<rect key={i} x={950 + i * 26} y={800 - i * 3} width={80} height={10} rx={4} fill={f} transform={`rotate(${-8 + i * 5} ${990 + i * 26} ${805})`} />
))}
</svg>
);
// 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];
return (
<svg viewBox={`0 0 ${B} ${H}`} style={{position: 'absolute', inset: 0, width: '100%', height: '100%'}}>
<Filter />
<rect width={B} height={H} fill="#c99a63" />
{new Array(12).fill(0).map((_, i) => (
<path key={i} d={`M -20 ${i * 95 + 30} C 600 ${i * 95 + 10}, 1300 ${i * 95 + 50}, 1940 ${i * 95 + 25}`} stroke="#b88a55" strokeWidth={4} fill="none" />
))}
<g transform="rotate(-2 960 540)">
<rect x={250} y={90} width={1420} height={900} fill="#fdf8ec" filter="url(#aq)" />
<g filter="url(#stift)">
<text x={960} y={190} textAnchor="middle" fontSize={78} fontFamily="Fredoka, sans-serif" fontWeight={600} fill="#c8642c">
Pinos Winterfest-Kalender
</text>
{nummern.map((n, i) => {
const x = 330 + (i % 6) * 215;
const y = 250 + Math.floor(i / 6) * 180;
const f = farben[i % farben.length];
return (
<g key={i}>
<rect x={x} y={y} width={180} height={150} rx={14} fill="none" stroke={f} strokeWidth={7} />
<circle cx={x + 158} cy={y + 75} r={7} fill={f} />
<text x={x + 82} y={y + 102} textAnchor="middle" fontSize={74} fontFamily="Fredoka, sans-serif" fontWeight={600} fill={f}>
{n}
</text>
</g>
);
})}
{/* kleine Kritzeleien */}
<path d="M 300 150 l 10 25 l 26 2 l -20 16 l 7 26 l -23 -14 l -23 14 l 7 -26 l -20 -16 l 26 -2 Z" fill="#e0b04a" />
<path d="M 1610 140 l 10 25 l 26 2 l -20 16 l 7 26 l -23 -14 l -23 14 l 7 -26 l -20 -16 l 26 -2 Z" fill="#e0b04a" />
</g>
</g>
<Korn staerke={0.15} />
</svg>
);
};
export const KALENDER_TUER1 = {x: 330 + 4 * 215 + 90, y: 250 + 1 * 180 + 75}; // Türchen „1“ (Index 10)
// Vorberechnete Bilder einbinden
export const Ort: React.FC<{name: string}> = ({name}) => <Img src={staticFile(`folge02/orte/${name}.webp`)} style={{position: 'absolute', inset: 0, width: B, height: H}} />;
// Kompositionen zum Vorberechnen
export const ORTE: Record<string, React.FC> = {
garten: () => <GartenSVG />,
garten_nacht: () => <GartenSVG nacht />,
oben: () => <ObenSVG />,
zimmer_himmel: () => <ZimmerHimmelSVG />,
zimmer_wand: () => <ZimmerWandSVG />,
zimmer_tisch: () => <TischSVG />,
kalender: () => <KalenderSVG />,
};
export const OrtStill: React.FC<{name: string}> = ({name}) => {
const K = ORTE[name];
return (
<AbsoluteFill>
<K />
</AbsoluteFill>
);
};
export {FENSTER};

View file

@ -0,0 +1,942 @@
// Automatisch erzeugt von werkzeuge/posen.py – nicht von Hand ändern.
// Koordinaten in der Bild-Leinwand der Figur (1376 × 768).
export type Pose = {figur: string; datei: string; x: number; y: number; b: number; h: number; box: number[]};
export type ClipDaten = {figur: string; datei: string; x: number; y: number; b: number; h: number; frames: number};
export const FIGUREN = {
"pino": {
"refHoehe": 571,
"fussX": 633.5,
"fussY": 679
},
"noa": {
"refHoehe": 535,
"fussX": 675.5,
"fussY": 700
},
"lulu": {
"refHoehe": 700,
"fussX": 714.0,
"fussY": 734
}
} as const;
export const POSEN: Record<string, Pose> = {
"pino_A1_augen-zu": {
"figur": "pino",
"datei": "folge02/figuren/pino_A1_augen-zu.webp",
"x": 391,
"y": 69,
"b": 623,
"h": 631,
"box": [
399,
105,
883,
682
]
},
"pino_A2_augen-halb": {
"figur": "pino",
"datei": "folge02/figuren/pino_A2_augen-halb.webp",
"x": 391,
"y": 69,
"b": 623,
"h": 631,
"box": [
399,
105,
883,
684
]
},
"pino_A3_lachen": {
"figur": "pino",
"datei": "folge02/figuren/pino_A3_lachen.webp",
"x": 391,
"y": 69,
"b": 623,
"h": 631,
"box": [
398,
105,
880,
682
]
},
"pino_A4_staunen": {
"figur": "pino",
"datei": "folge02/figuren/pino_A4_staunen.webp",
"x": 391,
"y": 69,
"b": 623,
"h": 631,
"box": [
398,
106,
876,
681
]
},
"pino_A5_blick-links": {
"figur": "pino",
"datei": "folge02/figuren/pino_A5_blick-links.webp",
"x": 297.5,
"y": 69,
"b": 623,
"h": 631,
"box": [
493,
99,
976,
683
]
},
"pino_A6_blick-rechts": {
"figur": "pino",
"datei": "folge02/figuren/pino_A6_blick-rechts.webp",
"x": 391,
"y": 69,
"b": 623,
"h": 631,
"box": [
399,
99,
883,
683
]
},
"pino_A7-1_gehen": {
"figur": "pino",
"datei": "folge02/figuren/pino_A7-1_gehen.webp",
"x": 391,
"y": 69,
"b": 623,
"h": 631,
"box": [
482,
75,
940,
683
]
},
"pino_A7-2_gehen": {
"figur": "pino",
"datei": "folge02/figuren/pino_A7-2_gehen.webp",
"x": 391,
"y": 69,
"b": 623,
"h": 631,
"box": [
473,
73,
934,
685
]
},
"pino_A7-3_gehen": {
"figur": "pino",
"datei": "folge02/figuren/pino_A7-3_gehen.webp",
"x": 391,
"y": 69,
"b": 623,
"h": 631,
"box": [
475,
74,
934,
685
]
},
"pino_A7-4_gehen": {
"figur": "pino",
"datei": "folge02/figuren/pino_A7-4_gehen.webp",
"x": 391,
"y": 69,
"b": 623,
"h": 631,
"box": [
461,
74,
932,
686
]
},
"pino_A8_rueckansicht": {
"figur": "pino",
"datei": "folge02/figuren/pino_A8_rueckansicht.webp",
"x": 391,
"y": 69,
"b": 623,
"h": 631,
"box": [
461,
79,
921,
686
]
},
"pino_A_hinterbeine": {
"figur": "pino",
"datei": "folge02/figuren/pino_A_hinterbeine.webp",
"x": 391,
"y": 69,
"b": 623,
"h": 631,
"box": [
462,
76,
914,
696
]
},
"pino_A_kugel": {
"figur": "pino",
"datei": "folge02/figuren/pino_A_kugel.webp",
"x": 391,
"y": 69,
"b": 623,
"h": 631,
"box": [
441,
106,
916,
653
]
},
"pino_A_laufen-1": {
"figur": "pino",
"datei": "folge02/figuren/pino_A_laufen-1.webp",
"x": 391,
"y": 69,
"b": 623,
"h": 631,
"box": [
423,
103,
979,
667
]
},
"pino_A_laufen-2": {
"figur": "pino",
"datei": "folge02/figuren/pino_A_laufen-2.webp",
"x": 391,
"y": 69,
"b": 623,
"h": 631,
"box": [
413,
100,
973,
669
]
},
"pino_A_laufen-3": {
"figur": "pino",
"datei": "folge02/figuren/pino_A_laufen-3.webp",
"x": 391,
"y": 69,
"b": 623,
"h": 631,
"box": [
418,
103,
973,
664
]
},
"pino_A_laufen-4": {
"figur": "pino",
"datei": "folge02/figuren/pino_A_laufen-4.webp",
"x": 391,
"y": 69,
"b": 623,
"h": 631,
"box": [
415,
102,
972,
666
]
},
"pino_B1_traurig": {
"figur": "pino",
"datei": "folge02/figuren/pino_B1_traurig.webp",
"x": 391,
"y": 69,
"b": 623,
"h": 631,
"box": [
399,
127,
891,
681
]
},
"pino_B2_erschrocken": {
"figur": "pino",
"datei": "folge02/figuren/pino_B2_erschrocken.webp",
"x": 391,
"y": 69,
"b": 623,
"h": 631,
"box": [
399,
104,
899,
681
]
},
"pino_B3_schlafen": {
"figur": "pino",
"datei": "folge02/figuren/pino_B3_schlafen.webp",
"x": 391,
"y": 69,
"b": 623,
"h": 631,
"box": [
399,
105,
883,
683
]
},
"pino_B_blick-nach-oben": {
"figur": "pino",
"datei": "folge02/figuren/pino_B_blick-nach-oben.webp",
"x": 391,
"y": 69,
"b": 623,
"h": 631,
"box": [
400,
110,
883,
684
]
},
"pino_B_schlaeft-eingerollt": {
"figur": "pino",
"datei": "folge02/figuren/pino_B_schlaeft-eingerollt.webp",
"x": 391,
"y": 69,
"b": 623,
"h": 631,
"box": [
402,
173,
1010,
651
]
},
"pino_ref_sitzend": {
"figur": "pino",
"datei": "folge02/figuren/pino_ref_sitzend.webp",
"x": 391,
"y": 69,
"b": 623,
"h": 631,
"box": [
395,
108,
872,
679
]
},
"noa_A1_augen-zu": {
"figur": "noa",
"datei": "folge02/figuren/noa_A1_augen-zu.webp",
"x": 339,
"y": 156,
"b": 715,
"h": 551,
"box": [
541,
164,
810,
702
]
},
"noa_A2_augen-halb": {
"figur": "noa",
"datei": "folge02/figuren/noa_A2_augen-halb.webp",
"x": 339,
"y": 156,
"b": 715,
"h": 551,
"box": [
541,
164,
810,
701
]
},
"noa_A3_lachen": {
"figur": "noa",
"datei": "folge02/figuren/noa_A3_lachen.webp",
"x": 339,
"y": 156,
"b": 715,
"h": 551,
"box": [
541,
164,
810,
701
]
},
"noa_A4_staunen": {
"figur": "noa",
"datei": "folge02/figuren/noa_A4_staunen.webp",
"x": 339,
"y": 156,
"b": 715,
"h": 551,
"box": [
541,
164,
809,
700
]
},
"noa_A5_blick-links": {
"figur": "noa",
"datei": "folge02/figuren/noa_A5_blick-links.webp",
"x": 314.0,
"y": 156,
"b": 715,
"h": 551,
"box": [
566,
164,
835,
701
]
},
"noa_A6_blick-rechts": {
"figur": "noa",
"datei": "folge02/figuren/noa_A6_blick-rechts.webp",
"x": 339,
"y": 156,
"b": 715,
"h": 551,
"box": [
541,
164,
810,
700
]
},
"noa_A7-1_gehen": {
"figur": "noa",
"datei": "folge02/figuren/noa_A7-1_gehen.webp",
"x": 339,
"y": 156,
"b": 715,
"h": 551,
"box": [
545,
166,
828,
697
]
},
"noa_A7-2_gehen": {
"figur": "noa",
"datei": "folge02/figuren/noa_A7-2_gehen.webp",
"x": 339,
"y": 156,
"b": 715,
"h": 551,
"box": [
526,
166,
839,
703
]
},
"noa_A7-3_gehen": {
"figur": "noa",
"datei": "folge02/figuren/noa_A7-3_gehen.webp",
"x": 339,
"y": 156,
"b": 715,
"h": 551,
"box": [
559,
165,
818,
692
]
},
"noa_A7-4_gehen": {
"figur": "noa",
"datei": "folge02/figuren/noa_A7-4_gehen.webp",
"x": 339,
"y": 156,
"b": 715,
"h": 551,
"box": [
547,
166,
823,
697
]
},
"noa_A8_rueckansicht": {
"figur": "noa",
"datei": "folge02/figuren/noa_A8_rueckansicht.webp",
"x": 339,
"y": 156,
"b": 715,
"h": 551,
"box": [
556,
168,
822,
699
]
},
"noa_A_arm-hoch-winken": {
"figur": "noa",
"datei": "folge02/figuren/noa_A_arm-hoch-winken.webp",
"x": 339,
"y": 156,
"b": 715,
"h": 551,
"box": [
544,
163,
860,
701
]
},
"noa_A_arme-hoch": {
"figur": "noa",
"datei": "folge02/figuren/noa_A_arme-hoch.webp",
"x": 339,
"y": 156,
"b": 715,
"h": 551,
"box": [
504,
163,
852,
701
]
},
"noa_A_laterne": {
"figur": "noa",
"datei": "folge02/figuren/noa_A_laterne.webp",
"x": 339,
"y": 156,
"b": 715,
"h": 551,
"box": [
343,
165,
835,
700
]
},
"noa_B1_traurig": {
"figur": "noa",
"datei": "folge02/figuren/noa_B1_traurig.webp",
"x": 339,
"y": 156,
"b": 715,
"h": 551,
"box": [
541,
170,
812,
700
]
},
"noa_B2_erschrocken": {
"figur": "noa",
"datei": "folge02/figuren/noa_B2_erschrocken.webp",
"x": 339,
"y": 156,
"b": 715,
"h": 551,
"box": [
546,
164,
814,
700
]
},
"noa_B3_schlafen": {
"figur": "noa",
"datei": "folge02/figuren/noa_B3_schlafen.webp",
"x": 339,
"y": 156,
"b": 715,
"h": 551,
"box": [
358,
267,
1050,
602
]
},
"noa_B_hocken": {
"figur": "noa",
"datei": "folge02/figuren/noa_B_hocken.webp",
"x": 339,
"y": 156,
"b": 715,
"h": 551,
"box": [
544,
160,
826,
703
]
},
"noa_B_sitzt": {
"figur": "noa",
"datei": "folge02/figuren/noa_B_sitzt.webp",
"x": 339,
"y": 156,
"b": 715,
"h": 551,
"box": [
536,
165,
956,
695
]
},
"noa_B_zeigen": {
"figur": "noa",
"datei": "folge02/figuren/noa_B_zeigen.webp",
"x": 339,
"y": 156,
"b": 715,
"h": 551,
"box": [
541,
165,
910,
700
]
},
"noa_ref_stehend": {
"figur": "noa",
"datei": "folge02/figuren/noa_ref_stehend.webp",
"x": 339,
"y": 156,
"b": 715,
"h": 551,
"box": [
541,
165,
810,
700
]
},
"lulu_A1_augen-zu": {
"figur": "lulu",
"datei": "folge02/figuren/lulu_A1_augen-zu.webp",
"x": 322,
"y": 23,
"b": 698,
"h": 736,
"box": [
540,
27,
873,
731
]
},
"lulu_A2_augen-halb": {
"figur": "lulu",
"datei": "folge02/figuren/lulu_A2_augen-halb.webp",
"x": 322,
"y": 23,
"b": 698,
"h": 736,
"box": [
540,
28,
872,
731
]
},
"lulu_A3_lachen": {
"figur": "lulu",
"datei": "folge02/figuren/lulu_A3_lachen.webp",
"x": 322,
"y": 23,
"b": 698,
"h": 736,
"box": [
540,
27,
872,
735
]
},
"lulu_A4_staunen": {
"figur": "lulu",
"datei": "folge02/figuren/lulu_A4_staunen.webp",
"x": 322,
"y": 23,
"b": 698,
"h": 736,
"box": [
540,
28,
872,
739
]
},
"lulu_A5_blick-links": {
"figur": "lulu",
"datei": "folge02/figuren/lulu_A5_blick-links.webp",
"x": 373.5,
"y": 23,
"b": 698,
"h": 736,
"box": [
504,
34,
821,
734
]
},
"lulu_A6_blick-rechts": {
"figur": "lulu",
"datei": "folge02/figuren/lulu_A6_blick-rechts.webp",
"x": 322,
"y": 23,
"b": 698,
"h": 736,
"box": [
555,
34,
873,
734
]
},
"lulu_A7-1_gehen": {
"figur": "lulu",
"datei": "folge02/figuren/lulu_A7-1_gehen.webp",
"x": 322,
"y": 23,
"b": 698,
"h": 736,
"box": [
494,
36,
897,
734
]
},
"lulu_A7-2_gehen": {
"figur": "lulu",
"datei": "folge02/figuren/lulu_A7-2_gehen.webp",
"x": 322,
"y": 23,
"b": 698,
"h": 736,
"box": [
506,
34,
890,
736
]
},
"lulu_A7-3_gehen": {
"figur": "lulu",
"datei": "folge02/figuren/lulu_A7-3_gehen.webp",
"x": 322,
"y": 23,
"b": 698,
"h": 736,
"box": [
504,
33,
891,
738
]
},
"lulu_A7-4_gehen": {
"figur": "lulu",
"datei": "folge02/figuren/lulu_A7-4_gehen.webp",
"x": 322,
"y": 23,
"b": 698,
"h": 736,
"box": [
512,
33,
931,
736
]
},
"lulu_A8_rueckansicht": {
"figur": "lulu",
"datei": "folge02/figuren/lulu_A8_rueckansicht.webp",
"x": 322,
"y": 23,
"b": 698,
"h": 736,
"box": [
561,
37,
813,
755
]
},
"lulu_A_apfel": {
"figur": "lulu",
"datei": "folge02/figuren/lulu_A_apfel.webp",
"x": 322,
"y": 23,
"b": 698,
"h": 736,
"box": [
540,
37,
872,
738
]
},
"lulu_A_hals-unten": {
"figur": "lulu",
"datei": "folge02/figuren/lulu_A_hals-unten.webp",
"x": 322,
"y": 23,
"b": 698,
"h": 736,
"box": [
326,
143,
992,
737
]
},
"lulu_B1_traurig": {
"figur": "lulu",
"datei": "folge02/figuren/lulu_B1_traurig.webp",
"x": 322,
"y": 23,
"b": 698,
"h": 736,
"box": [
494,
48,
873,
728
]
},
"lulu_B2_erschrocken": {
"figur": "lulu",
"datei": "folge02/figuren/lulu_B2_erschrocken.webp",
"x": 322,
"y": 23,
"b": 698,
"h": 736,
"box": [
549,
32,
872,
730
]
},
"lulu_B3_schlafen": {
"figur": "lulu",
"datei": "folge02/figuren/lulu_B3_schlafen.webp",
"x": 322,
"y": 23,
"b": 698,
"h": 736,
"box": [
407,
112,
1016,
724
]
},
"lulu_B_kopf-profil": {
"figur": "lulu",
"datei": "folge02/figuren/lulu_B_kopf-profil.webp",
"x": 322,
"y": 23,
"b": 698,
"h": 736,
"box": [
509,
41,
851,
737
]
},
"lulu_B_liegt": {
"figur": "lulu",
"datei": "folge02/figuren/lulu_B_liegt.webp",
"x": 322,
"y": 23,
"b": 698,
"h": 736,
"box": [
441,
49,
924,
723
]
}
};
export const CLIPS: Record<string, ClipDaten> = {
"lulu_C9_hals-runter": {
"figur": "lulu",
"datei": "folge02/clips/lulu_C9_hals-runter.webm",
"x": 294.70404984423675,
"y": 24.64797507788162,
"b": 709.4330218068535,
"h": 715.8629283489096,
"frames": 121
},
"noa_C4_winken": {
"figur": "noa",
"datei": "folge02/clips/noa_C4_winken.webm",
"x": 529.3956386292834,
"y": 158.6043613707165,
"b": 413.6573208722741,
"h": 546.5420560747663,
"frames": 121
},
"pino_C3_freudensprung": {
"figur": "pino",
"datei": "folge02/clips/pino_C3_freudensprung.webm",
"x": 382.5794392523364,
"y": 0.0,
"b": 512.2492211838006,
"h": 681.5700934579438,
"frames": 121
},
"pino_C7_einrollen": {
"figur": "pino",
"datei": "folge02/clips/pino_C7_einrollen.webm",
"x": 390.08099688473516,
"y": 101.80685358255451,
"b": 552.9719626168223,
"h": 582.9781931464174,
"frames": 121
},
"pino_C8_aufrichten": {
"figur": "pino",
"datei": "folge02/clips/pino_C8_aufrichten.webm",
"x": 387.9376947040498,
"y": 62.155763239875384,
"b": 563.6884735202492,
"h": 636.5607476635513,
"frames": 121
}
};

View file

@ -0,0 +1,128 @@
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';
// 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([
{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: '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: 'bild2', dauer: 3.2},
{id: 'n2', wer: 'NOA', text: 'Pino? Pino!', 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},
]);
export const Szene2: React.FC = () => {
const f = useCurrentFrame();
const {z, dauer} = S2;
// Pino kommt von rechts getippelt und setzt sich
const pinoAn = z.bild1.ende + 6;
const pinoX = kurve(f, [
[z.bild1.start - 10, 1560],
[pinoAn, PINO_X],
]);
const pinoGeht = f < pinoAn;
const schreck = z.bild3.start;
const pinoPose: [number, string][] = [
[0, 'pino_A2_augen-halb'],
[z.p1.start + 4, 'pino_A4_staunen'],
[z.p1.ende - 10, 'pino_A2_augen-halb'],
[z.n1.start + 8, 'pino_A5_blick-links'],
[z.p2.start, 'pino_A3_lachen'],
[z.p2.start + 55, 'pino_A2_augen-halb'],
[z.p2.ende - 30, 'pino_A1_augen-zu'],
[schreck, 'pino_B2_erschrocken'],
[z.p3.start + 20, 'pino_ref_sitzend'],
[z.e3.start + 10, 'pino_A2_augen-halb'],
[z.e4.ende - 5, 'pino_A4_staunen'],
[z.e4.ende + 45, 'pino_A2_augen-halb'],
];
// Gähnen: strecken; Einschlafen: zusammensinken
const gaehn = (s: number) => kurve(f, [[s, 1], [s + 12, 1.07], [s + 45, 1.07], [s + 60, 1]]);
const sinken = kurve(f, [
[z.p2.ende - 40, 0],
[z.p2.ende + 10, 1],
[schreck, 1],
[schreck + 4, 0],
]);
const pinoStauchY =
(f > z.p1.start && f < z.p1.ende ? gaehn(z.p1.start) : 1) *
(f > z.e4.ende - 10 ? gaehn(z.e4.ende - 5) : 1) *
(1 - 0.05 * sinken) *
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);
// Noa kommt von links
const noaAn = z.n1.start - 4;
const noaX = kurve(f, [
[z.bild1.start + 10, -180],
[noaAn, NOA_X],
[z.n2.start, NOA_X],
[z.n2.start + 20, NOA_X + 60],
[z.p3.ende, NOA_X + 60],
[z.p3.ende + 30, NOA_X + 20],
]);
const noaGeht = f > z.bild1.start + 10 && f < noaAn;
const noaPose: [number, string][] = [
[0, 'noa_A6_blick-rechts'],
[z.n1.start, 'noa_A3_lachen'],
[z.p2.start + 20, 'noa_A6_blick-rechts'],
[z.bild2.start + 15, 'noa_A3_lachen'],
[z.n2.start, 'noa_A4_staunen'],
[z.p3.start + 10, 'noa_A3_lachen'],
[z.e4.start, 'noa_A6_blick-rechts'],
];
// 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 kam = kamFahrt(f, [
[0, 960, 540, 1.02],
[z.e2.ende, 900, 600, 1.15],
[z.bild1.start + 20, 1100, 700, 1.5],
[z.n1.start, 920, 740, 1.65],
[z.p2.start + 10, 1000, 790, 2.1],
[z.p2.ende, 1050, 820, 2.5],
[z.bild2.start + 30, 930, 770, 1.8],
[z.p3.start, 930, 770, 1.8],
[z.e4.start, 1040, 820, 2.3],
[dauer, 1050, 830, 2.5],
]);
kam.x += ruck(f, schreck, 6);
return (
<>
<Kamera {...kam}>
<Ort name="garten" />
<Figur pose={noaGeht ? gehPose('noa', f) : noaPose} spiegeln={noaGeht} x={noaX} y={noaY} stauchY={atmen(f, 2)} />
<Figur pose={pinoGeht ? gehPose('pino', f, 8) : pinoPose} x={pinoX} y={pinoY} stauchY={pinoStauchY} drehung={pinoDreh} blende={4} />
<Zzz x={PINO_X + 40} y={BODEN - 150} start={z.p2.ende} ende={schreck} s={0.7} />
</Kamera>
<Blaetterwind anzahl={6} />
<Vignette />
<Untertitel zeilen={S2.liste} />
<Blende dauer={dauer} rein={12} raus={0} />
</>
);
};

View file

@ -0,0 +1,167 @@
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';
// Szene 3: Lulu sieht es von oben. Winterschlaf wird erklärt, Pino ist traurig.
export const LULU_X = 1520;
export const S3 = plane([
{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: '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: 'bild3', dauer: 2.6},
{id: 'n1', wer: 'NOA', text: 'Ach, Pino. Bis zum Winterfest ist es doch noch ganz lange hin.'},
]);
// Das große Blatt, auf das Pino sich setzt
const GROSSES_BLATT_X = PINO_X + 50;
export const Szene3: React.FC = () => {
const f = useCurrentFrame();
const {z, dauer} = S3;
// Lulu stakst von rechts herein, beugt dann den Hals herunter (Clip),
// hebt ihn für „von hier oben“ wieder (Clip rückwärts).
const luluAn = z.bild0.ende;
const luluX = kurve(f, [
[0, 2250],
[luluAn, LULU_X],
]);
const luluGeht = f < luluAn;
const halsRunter = z.bild1.start;
const halsHoch = z.l2.start - 10;
const obenStart = z.l2.start + 50;
const obenEnde = z.e1.ende + 5;
const imOben = f >= obenStart && f < obenEnde;
let lulu: React.ReactNode;
const luluY = BODEN - 10;
if (luluGeht) {
lulu = <Figur pose={gehPose('lulu', f, 5)} x={luluX} y={luluY + gehWippen(f, 5, 8)} />;
} else if (f < halsHoch) {
lulu = <ClipFigur name="lulu_C9_hals-runter" start={halsRunter} tempo={1.25} x={LULU_X} y={luluY} />;
} else if (f < halsHoch + 110) {
lulu = <ClipFigur name="lulu_C9_hals-runter" start={halsHoch} tempo={1.5} rueckwaerts x={LULU_X} y={luluY} />;
} else {
lulu = (
<Figur
pose={[
[0, 'lulu_A5_blick-links'],
[z.bild2.start, 'lulu_B1_traurig'],
[z.bild3.start, 'lulu_A5_blick-links'],
]}
x={LULU_X}
y={luluY}
stauchY={atmen(f, 3, 0.006)}
/>
);
}
// Pino
const pinoSchreck = z.p1.start;
const aufsBlatt = z.bild2.start + 10;
const pinoX = kurve(f, [
[aufsBlatt, PINO_X],
[aufsBlatt + 24, GROSSES_BLATT_X],
]);
const traurig = kurve(f, [
[aufsBlatt + 20, 0],
[aufsBlatt + 50, 1],
]);
const anlehnen = kurve(f, [
[z.bild3.start + 40, 0],
[z.bild3.start + 70, 1],
]);
const pinoPose: [number, string][] = [
[0, 'pino_A2_augen-halb'],
[z.bild0.start + 40, 'pino_A6_blick-rechts'],
[z.bild1.start + 20, 'pino_B_blick-nach-oben'],
[z.l1.start + 20, 'pino_A3_lachen'],
[z.l2.start, 'pino_B_blick-nach-oben'],
[z.e2.start, 'pino_A2_augen-halb'],
[z.e3.start + 30, 'pino_A4_staunen'],
[pinoSchreck, 'pino_B2_erschrocken'],
[aufsBlatt + 20, 'pino_B1_traurig'],
[z.bild3.start + 50, 'pino_A1_augen-zu'],
];
const pinoY = BODEN + huepfer(f, pinoSchreck, 34, 12) + huepfer(f, aufsBlatt, 14, 22) - (f > aufsBlatt + 20 ? 6 : 0);
const pinoStauchY = atmen(f, 1) * hopfStauch(f, pinoSchreck) * (1 - 0.06 * traurig + 0.06 * anlehnen * 0);
const pinoDreh = -9 * anlehnen + 3 * traurig * (1 - anlehnen);
// Noa kniet sich zu Pino
const knien = z.bild3.start + 10;
const noaX = kurve(f, [
[knien, NOA_X + 20],
[knien + 30, NOA_X + 140],
]);
const noaPose: [number, string][] = [
[0, 'noa_A6_blick-rechts'],
[z.bild1.start + 20, 'noa_A4_staunen'],
[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 kam = kamFahrt(f, [
[0, 1150, 820, 1.7],
[z.bild0.ende - 10, 1150, 820, 1.7],
[z.bild1.start + 30, 1150, 640, 1.15],
[z.l1.ende, 1120, 700, 1.3],
[z.l2.start, 1250, 520, 1.1],
[obenStart, 1300, 420, 1.2],
[obenEnde, 1050, 800, 2.2],
[z.e3.ende, 1060, 820, 2.4],
[z.p1.start + 10, 1050, 810, 2.1],
[z.bild2.start + 20, 1090, 820, 2.5],
[z.e6.ende, 1100, 830, 2.7],
[z.bild3.start + 30, 960, 790, 1.9],
[dauer, 950, 790, 2.0],
]);
return (
<>
{imOben ? (
<Kamera x={kurve(f, [[obenStart, 900], [obenEnde, 1060]])} y={540} zoom={1.05}>
<Ort name="oben" />
<Voegel start={obenStart - 40} skala={1.9} />
</Kamera>
) : (
<Kamera {...kam}>
<Ort name="garten" />
<Figur pose={noaPose} x={noaX} y={BODEN} stauchY={atmen(f, 2) * noaStauch} />
{f > aufsBlatt - 30 && <Blatt x={GROSSES_BLATT_X} y={BODEN - 8} r={88} s={2.6} farbe="#c8642c" deckkraft={Math.min(1, (f - aufsBlatt + 30) / 12)} />}
<Figur pose={pinoPose} x={pinoX} y={pinoY} stauchY={pinoStauchY} drehung={pinoDreh} blende={6} />
{lulu}
</Kamera>
)}
{!imOben && <Blaetterwind anzahl={4} seed="w3" />}
<Vignette />
<Untertitel zeilen={S3.liste} />
<Blende dauer={dauer} rein={0} raus={0} />
</>
);
};

View file

@ -0,0 +1,212 @@
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';
// 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([
{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: 'bild1', dauer: 2.6},
{id: 'l1', wer: 'LULU', text: 'Hier oben hängen noch welche! Die erreicht sonst keiner.'},
{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: '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.'},
]);
export const Szene4: React.FC = () => {
const f = useCurrentFrame();
const {z, dauer} = S4;
// Abschnitte (Schnitte)
const zumStapel = z.e1.start; // Noa und Pino gehen zum Holzstapel
const amBaum = z.bild1.start; // Schnitt zu Lulu am Apfelbaum
const niesen = z.bild3.start; // Hatschi!
const zurueck = z.bild4.start; // Schnitt zum Nestbau
const zaehlen = [z.c1, z.c2, z.c3, z.c4, z.c5].map((c) => c.start);
// Noa
const noaGehtVon = zumStapel + 20;
const noaGehtBis = noaGehtVon + 90;
const noaX = kurve(f, [
[noaGehtVon, NOA_X + 140],
[noaGehtBis, NOA_ZAEHL_X],
]);
const noaGeht = f > noaGehtVon && f < noaGehtBis;
const noaPose: [number, string][] = [
[0, 'noa_B_hocken'],
[z.n1.start + 30, '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'],
];
// 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)
const schieben = f > zurueck && f < z.bild4.ende ? Math.sin((f - zurueck) * 0.22) : 0;
// Pino
const pinoGehtVon = zumStapel + 40;
const pinoGehtBis = pinoGehtVon + 110;
const pinoX = kurve(f, [
[pinoGehtVon, PINO_X + 50],
[pinoGehtBis, PINO_ZAEHL_X],
[zurueck + 10, PINO_ZAEHL_X],
[zurueck + 60, NEST_X + 150],
[z.bild4.ende - 20, NEST_X + 110],
[z.bild5.start, NEST_X],
]);
const pinoGeht = (f > pinoGehtVon && f < pinoGehtBis) || (f > zurueck + 10 && f < zurueck + 60);
const pinoSpiegel = f > pinoGehtVon && f < pinoGehtBis; // nach rechts
const imNest = f >= z.bild5.start;
const pinoPose: [number, string][] = [
[0, 'pino_A1_augen-zu'],
[z.n1.start + 20, 'pino_A2_augen-halb'],
[pinoGehtBis, 'pino_A5_blick-links'],
...zaehlen.map((c) => [c, 'pino_A4_staunen'] as [number, string]),
[z.c5.ende, 'pino_A3_lachen'],
[niesen + 4, 'pino_B2_erschrocken'],
[z.n3.start + 20, 'pino_A3_lachen'],
[z.bild4.ende - 30, 'pino_A2_augen-halb'],
[z.bild5.start, 'pino_A6_blick-rechts'],
[z.e4.start, 'pino_A3_lachen'],
[z.e5.start + 60, 'pino_A2_augen-halb'],
];
const zaehlHuepfer = zaehlen.reduce((s, c) => s + huepfer(f, c + 4, 10, 10), 0);
const nestVoll = kurve(f, [
[zurueck + 20, 0.15],
[z.bild4.ende, 1],
]);
const pinoImNestY = BODEN + 2 - huepfer(f, z.bild5.start, -10, 20);
// Lulu am Baum
const strecken = kurve(f, [
[amBaum + 10, 0],
[amBaum + 50, 1],
]);
const kitzeln = z.bild2.start;
const zurueckLehnen = kurve(f, [
[kitzeln + 10, 0],
[niesen - 4, 1],
[niesen, -0.6],
[niesen + 20, 0],
]);
const luluPose: [number, string][] = [
[0, 'lulu_A5_blick-links'],
[z.l1.start + 10, 'lulu_A3_lachen'],
[kitzeln + 10, 'lulu_A1_augen-zu'],
[niesen + 18, 'lulu_B2_erschrocken'],
[z.n3.start + 20, 'lulu_A3_lachen'],
];
const luluY = BODEN - 10 - strecken * 26;
const maul = punkt('lulu', LULU_BAUM_X, luluY, LULU_MAUL_LINKS.cx, LULU_MAUL_LINKS.cy);
const buendel = f > amBaum + 50 && f < niesen;
// Blätter fliegen vom Ast ins Maul
const zupfen = [0, 1, 2, 3].map((i) => {
const s = amBaum + 55 + i * 14;
const t = Math.min(1, Math.max(0, (f - s) / 16));
return {x: maul.x - 160 + i * 50 + (maul.x - (maul.x - 160 + i * 50)) * t, y: maul.y - 120 + 120 * t, sichtbar: f > s - 20 && t < 1};
});
// Kamera
const kam = kamFahrt(f, [
[0, 950, 790, 2.0],
[z.n1.ende, 1000, 760, 1.6],
[z.e1.start + 60, 1380, 720, 1.35],
[z.e3.ende, 1450, 780, 1.6],
[z.n2.start, 1480, 820, 1.85],
[z.c5.ende, 1480, 820, 1.85],
[amBaum, 470, 400, 1.85],
[z.l1.ende, 500, 380, 1.9],
[niesen - 5, 560, 370, 2.0],
[niesen + 12, 960, 540, 1.0],
[z.n3.ende, 960, 560, 1.05],
[zurueck, 1480, 800, 1.7],
[z.bild5.start, 1480, 830, 2.4],
[z.e5.start, 1400, 760, 1.5],
[dauer, 1350, 740, 1.4],
]);
kam.y += ruck(f, niesen, 9, 22);
const lulu = <Figur pose={luluPose} x={LULU_BAUM_X} y={luluY} stauchY={atmen(f, 3, 0.006) * (1 + 0.02 * strecken)} drehung={zurueckLehnen * 4} />;
const luluBeimNest = f >= zurueck && <Figur pose="lulu_A_hals-unten" x={2030} y={BODEN - 4} drehung={schieben * 1.2} />;
return (
<>
<Kamera {...kam}>
<Ort name="garten" />
{f >= amBaum && f < zurueck && lulu}
{buendel && (
<div style={{position: 'absolute', left: 0, top: 0}}>
{LAUB.slice(0, 5).map((c, i) => (
<Blatt key={i} x={maul.x - 6 + (i - 2) * 9} y={maul.y + 14 + Math.abs(i - 2) * 4} r={(i - 2) * 30 + 180} s={0.42} farbe={c} />
))}
</div>
)}
{zupfen.map((b, i) => (b.sichtbar ? <Blatt key={i} x={b.x} y={b.y} r={f * 8 + i * 70} s={0.42} farbe={LAUB[i]} /> : null))}
{/* Reihe aus fünf Blättern vor dem Holzstapel */}
{f < zurueck + 40 &&
REIHE.map((p, i) =>
f > zaehlen[i] - 6 ? <Blatt key={i} x={p.x} y={p.y - Math.max(0, 20 - (f - zaehlen[i] + 6) * 2)} r={90 + i * 10} s={0.8} farbe={LAUB[i]} deckkraft={f > zurueck ? 1 - (f - zurueck) / 40 : 1} /> : null,
)}
<Nest x={NEST_X} y={BODEN} seite="hinten" voll={f < zurueck ? 0 : nestVoll} />
<Figur pose={noaGeht ? gehPose('noa', f) : noaPose} spiegeln={noaGeht} x={noaX + schieben * 10} y={BODEN + (noaGeht ? gehWippen(f) : 0)} stauchY={atmen(f, 2) * (1 - 0.03 * beugen)} drehung={beugen * 3 + schieben * 2} />
{imNest ? (
<div style={{position: 'absolute', inset: 0, clipPath: `inset(0 0 ${1080 - BODEN - 4}px 0)`}}>
<Figur pose={pinoPose} x={NEST_X} y={pinoImNestY} schatten={false} />
</div>
) : (
<Figur
pose={pinoGeht ? gehPose('pino', f, 8) : pinoPose}
spiegeln={pinoSpiegel}
x={pinoX}
y={BODEN + zaehlHuepfer + huepfer(f, niesen + 2, 30, 12) + (pinoGeht ? gehWippen(f, 8, 4) : 0)}
stauchY={atmen(f, 1) * hopfStauch(f, niesen + 2)}
/>
)}
<Nest x={NEST_X} y={BODEN} seite="vorne" voll={f < zurueck ? 0 : nestVoll} />
{luluBeimNest}
<Blaetterregen start={niesen} x0={maul.x} y0={maul.y} boden={BODEN + 20} aus={zurueck + 20} />
</Kamera>
{/* Zahlen beim Zählen */}
{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} />
<Vignette />
<Untertitel zeilen={S4.liste} />
<Blende dauer={dauer} rein={0} raus={0} />
</>
);
};

View file

@ -0,0 +1,165 @@
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';
// Szene 5: Pino will nicht schlafen. Noa hat eine Idee.
const NOA_X5 = 1150;
const PINO_RAUS_X = 1370;
const LULU_X5 = 1790;
export const S5 = plane([
{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: '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: 'bild4', dauer: 1.2},
{id: 'n1', wer: 'NOA', text: 'Ich hab eine Idee!'},
]);
const Denkblase: React.FC<{x: number; y: number; start: number; ende: number}> = ({x, y, start, ende}) => {
const f = useCurrentFrame();
if (f < start || f > ende) return null;
const auf = Math.min(1, (f - start) / 12) * Math.min(1, (ende - f) / 8);
const punkte = Math.floor((f - start) / 14) % 4;
return (
<div style={{position: 'absolute', left: x, top: y, opacity: auf, transform: `scale(${0.6 + 0.4 * auf})`}}>
<div style={{position: 'absolute', left: -14, top: 70, width: 16, height: 16, borderRadius: '50%', background: '#fff8ea'}} />
<div style={{position: 'absolute', left: 4, top: 40, width: 26, height: 26, borderRadius: '50%', background: '#fff8ea'}} />
<div
style={{
position: 'absolute',
left: 20,
top: -60,
width: 140,
height: 90,
borderRadius: '50%',
background: '#fff8ea',
boxShadow: '0 4px 14px rgba(60,40,20,0.2)',
fontFamily: SCHRIFT,
fontSize: 60,
fontWeight: 600,
color: '#8a6a4a',
textAlign: 'center',
lineHeight: '70px',
}}
>
{'.'.repeat(punkte)}
</div>
</div>
);
};
export const Szene5: React.FC = () => {
const f = useCurrentFrame();
const {z, dauer} = S5;
const aufwachen = z.bild2.start;
const raus = aufwachen + 22;
const imNest = f < raus;
const stampfen = [z.p1.start + 2, z.p1.start + 40, z.p1.start + 78];
const pinoNestPose: [number, string][] = [
[0, 'pino_A2_augen-halb'],
[20, 'pino_A4_staunen'],
[60, 'pino_A2_augen-halb'],
[z.bild1.ende - 25, 'pino_A1_augen-zu'],
[aufwachen, 'pino_A4_staunen'],
];
const pinoPose: [number, string][] = [
[0, 'pino_A_hinterbeine'],
[z.l1.start + 10, 'pino_A5_blick-links'],
[z.bild3.start + 10, 'pino_B_blick-nach-oben'],
[z.bild4.start + 10, 'pino_A4_staunen'],
];
const pinoNestY = kurve(f, [
[0, BODEN + 12],
[25, BODEN + 2],
[z.bild1.ende - 20, BODEN + 10],
[aufwachen, BODEN + 6],
[aufwachen + 6, BODEN - 6],
]);
const pinoX = kurve(f, [
[raus, NEST_X - 40],
[raus + 20, PINO_RAUS_X],
]);
const stampfY = stampfen.reduce((s, t) => s + huepfer(f, t, 22, 9), 0) + huepfer(f, raus, 40, 16);
const stampfS = stampfen.reduce((s, t) => s * hopfStauch(f, t, 9), 1) * hopfStauch(f, raus, 16);
const lulu =
f < z.bild3.start + 10 ? (
<Figur
pose={[
[0, 'lulu_A6_blick-rechts'],
[z.bild2.start + 10, 'lulu_A5_blick-links'],
[z.l1.start, 'lulu_A4_staunen'],
]}
x={LULU_X5}
y={BODEN - 10}
stauchY={atmen(f, 3, 0.006)}
/>
) : (
<ClipFigur name="lulu_C9_hals-runter" start={z.bild3.start + 10} tempo={1.4} bis={3.6} x={LULU_X5} y={BODEN - 10} />
);
const ideeStart = z.bild4.start;
const noaPose: [number, string][] = [
[0, 'noa_A6_blick-rechts'],
[aufwachen + 10, 'noa_A4_staunen'],
[z.l1.start, 'noa_A6_blick-rechts'],
[z.bild3.start, 'noa_A5_blick-links'],
[z.bild3.start + 40, 'noa_A6_blick-rechts'],
[z.e1.start + 30, 'noa_A5_blick-links'],
[z.e2.start, 'noa_A6_blick-rechts'],
[ideeStart, 'noa_A_arm-hoch-winken'],
[z.n1.start + 4, 'noa_A3_lachen'],
];
const noaKopfNeigen = f > z.bild3.start && f < ideeStart ? Math.sin((f - z.bild3.start) * 0.05) * 2.5 : 0;
const kam = kamFahrt(f, [
[0, NEST_X, 860, 2.6],
[aufwachen, NEST_X, 860, 2.6],
[raus + 20, 1380, 820, 2.0],
[z.p1.ende, 1380, 800, 1.9],
[z.l1.start + 10, 1300, 620, 1.3],
[z.bild3.start + 40, 1300, 660, 1.35],
[z.e2.ende, 1280, 760, 1.6],
[ideeStart + 10, NOA_X5 + 40, 760, 1.9],
[dauer, NOA_X5 + 40, 760, 2.0],
]);
kam.x += stampfen.reduce((s, t) => s + ruck(f, t, 3, 10), 0);
return (
<>
<Kamera {...kam}>
<Ort name="garten" />
<Figur pose={noaPose} x={NOA_X5} y={BODEN + huepfer(f, ideeStart, 26, 14)} stauchY={atmen(f, 2) * hopfStauch(f, ideeStart, 14)} drehung={noaKopfNeigen} />
<Nest x={NEST_X} y={BODEN} seite="hinten" />
{imNest && (
<div style={{position: 'absolute', inset: 0, clipPath: `inset(0 0 ${1080 - BODEN - 4}px 0)`}}>
<Figur pose={pinoNestPose} x={NEST_X} y={pinoNestY} schatten={false} stauchY={atmen(f, 1, 0.012)} />
</div>
)}
<Nest x={NEST_X} y={BODEN} seite="vorne" />
{!imNest && <Figur pose={pinoPose} x={pinoX} y={BODEN + stampfY} stauchY={stampfS * atmen(f, 1)} />}
{lulu}
<Denkblase x={NOA_X5 + 70} y={BODEN - 420} start={z.bild3.start + 5} ende={ideeStart} />
<Funkeln x={NOA_X5 + 20} y={BODEN - 330} start={ideeStart + 2} dauer={36} s={1.1} />
</Kamera>
<Vignette />
<Untertitel zeilen={S5.liste} />
<Blende dauer={dauer} rein={0} raus={12} />
</>
);
};

View file

@ -0,0 +1,172 @@
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';
// Szene 6: Noas Idee. Abends in Noas Zimmer, Noa malt den Kalender.
const N = 420; // Noas Größe im Zimmer (näher an der Kamera)
const NOA_X6 = 800;
const PINO_X6 = 1090;
const TISCH_Y = 802;
const LULU_X6 = 1440;
export const S6 = plane([
{id: 'bild0', dauer: 3.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.'},
{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: 'bild1', dauer: 2.0},
{id: 'p2', wer: 'PINO', text: 'Meine Stacheln kribbeln!'},
{id: 'bild2', dauer: 4.2},
]);
// Kritzel auf dem Papier, wächst mit `t` (0…1)
const Kritzel: React.FC<{t: number}> = ({t}) => (
<svg viewBox="0 0 1920 1080" style={{position: 'absolute', inset: 0, width: 1920, height: 1080}}>
<path
d="M 600 806 l 30 0 l 0 14 l -30 0 z M 645 804 l 30 0 l 0 14 l -30 0 z M 690 803 l 30 0 l 0 14 l -30 0 z M 735 802 l 30 0 l 0 14 l -30 0 z M 780 801 l 30 0 l 0 14 l -30 0 z M 825 800 l 30 0 l 0 14 l -30 0 z"
stroke="#d9534f"
strokeWidth={3}
fill="none"
strokeDasharray={1000}
strokeDashoffset={1000 * (1 - t)}
/>
</svg>
);
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;
const malt = f < z.n1.ende;
const luluX = kurve(f, [
[20, 1900],
[z.bild0.ende, LULU_X6],
]);
const sprung = z.bild1.start;
const imSprung = f >= sprung && f < sprung + 58;
const kribbeln = f > z.p2.start - 5 && f < z.p2.ende + 10;
const zitter = kribbeln ? Math.sin(f * 2.3) * 3 : 0;
const kalender = f >= z.bild2.start;
const tippen = [z.bild2.start + 45, z.bild2.start + 75];
const tippY = tippen.reduce((s, t) => s + Math.max(0, Math.sin(Math.min(1, Math.max(0, (f - t) / 14)) * Math.PI)) * 30, 0);
const leuchten = kalender ? 0.5 + 0.5 * Math.sin((f - z.bild2.start) * 0.15) : 0;
const kam = kamFahrt(f, [
[0, 960, 540, 1.0],
[z.bild0.ende, 1060, 560, 1.15],
[z.n1.start, 900, 640, 1.45],
[z.p1.start, 980, 650, 1.6],
[z.n4.ende, 960, 640, 1.5],
[sprung + 10, 1050, 700, 2.0],
[z.p2.ende, 1060, 710, 2.1],
]);
const kalKam = kamFahrt(f, [
[z.bild2.start, 960, 540, 1.0],
[dauer, KALENDER_TUER1.x - 40, KALENDER_TUER1.y, 1.6],
]);
if (kalender) {
return (
<>
<Kamera {...kalKam}>
<Ort name="kalender" />
<div
style={{
position: 'absolute',
left: KALENDER_TUER1.x - 110,
top: KALENDER_TUER1.y - 95,
width: 220,
height: 190,
borderRadius: 26,
boxShadow: `0 0 ${30 + 30 * leuchten}px ${10 + 10 * leuchten}px rgba(255, 210, 90, ${0.35 + 0.35 * leuchten})`,
transform: 'rotate(-2deg)',
}}
/>
{/* Buntstift tippt auf Türchen 1 */}
<div
style={{
position: 'absolute',
left: KALENDER_TUER1.x + 10,
top: KALENDER_TUER1.y - 330 - tippY + 30,
width: 36,
height: 300,
transform: 'rotate(18deg)',
transformOrigin: '50% 100%',
}}
>
<div style={{position: 'absolute', bottom: 0, left: 6, width: 0, height: 0, borderLeft: '12px solid transparent', borderRight: '12px solid transparent', borderTop: '0', borderBottom: '40px solid #f1d2a6', transform: 'rotate(180deg)'}} />
<div style={{position: 'absolute', bottom: 40, left: 0, width: 36, height: 260, borderRadius: 6, background: 'linear-gradient(90deg, #b93f3b, #d9534f 40%, #b93f3b)'}} />
</div>
<Funkeln x={KALENDER_TUER1.x} y={KALENDER_TUER1.y} start={tippen[1] + 6} dauer={40} s={1.3} />
</Kamera>
<Vignette />
<Untertitel zeilen={S6.liste} />
<Blende dauer={dauer} rein={0} raus={14} />
</>
);
}
return (
<>
<Kamera {...kam}>
<Ort name="zimmer_himmel" />
<Figur pose={[[0, 'lulu_A5_blick-links'], [z.n2.start, 'lulu_A3_lachen'], [z.p2.start, 'lulu_A3_lachen']]} x={luluX} y={1180} hoehe={N * 2.05} schatten={false} />
<Ort name="zimmer_wand" />
<Figur
pose={[
[0, 'noa_B_sitzt'],
[z.n1.start, 'noa_B_sitzt'],
]}
x={NOA_X6}
y={1000}
hoehe={N}
stauchY={atmen(f, 2)}
drehung={malt ? Math.sin(f * 0.3) * 0.8 : 0}
schatten={false}
/>
<Ort name="zimmer_tisch" />
<Kritzel t={malen} />
{malt && <div style={{position: 'absolute', left: stiftX, top: stiftY - 70, width: 14, height: 80, borderRadius: 4, background: '#d9534f', transform: 'rotate(25deg)'}} />}
{imSprung ? (
<ClipFigur name="pino_C3_freudensprung" start={sprung} tempo={1.4} x={PINO_X6} y={TISCH_Y} hoehe={N * 0.4} />
) : (
<Figur
pose={[
[0, 'pino_A5_blick-links'],
[z.l1.start + 10, 'pino_A6_blick-rechts'],
[z.n1.start + 10, 'pino_A5_blick-links'],
[z.p1.start, 'pino_A4_staunen'],
[z.n3.start + 20, 'pino_A5_blick-links'],
[z.n4.start + 30, 'pino_A3_lachen'],
]}
x={PINO_X6 + zitter}
y={TISCH_Y + (kribbeln ? huepfer(f, z.p2.start, 12, 10) + huepfer(f, z.p2.start + 14, 12, 10) : 0)}
hoehe={N * 0.4}
drehung={zitter}
stauchY={atmen(f, 1)}
/>
)}
{kribbeln && <Funkeln x={PINO_X6} y={TISCH_Y - 120} start={z.p2.start} dauer={40} s={0.8} />}
</Kamera>
<Vignette />
<Untertitel zeilen={S6.liste} />
<Blende dauer={dauer} rein={14} raus={0} />
</>
);
};

View file

@ -0,0 +1,127 @@
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';
// Szene 7: Gute Nacht, Pino. Nacht im Garten, die erste Schneeflocke fällt.
const NOA_X7 = 1140;
const LULU_X7 = 1800;
const LATERNE = {x: 1290, y: BODEN + 10};
export const S7 = plane(
[
{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},
],
0.5,
1.6,
);
const Laterne: React.FC<{frame: number}> = ({frame}) => {
const flackern = 0.92 + 0.08 * Math.sin(frame * 0.5) * Math.sin(frame * 0.17);
return (
<>
<div style={{position: 'absolute', left: LATERNE.x - 18, top: LATERNE.y - 92, width: 36, height: 8, borderRadius: 3, background: '#3a3036'}} />
<div style={{position: 'absolute', left: LATERNE.x - 3, top: LATERNE.y - 112, width: 6, height: 22, background: '#3a3036'}} />
<div
style={{
position: 'absolute',
left: LATERNE.x - 24,
top: LATERNE.y - 84,
width: 48,
height: 70,
borderRadius: 10,
background: `radial-gradient(circle at 50% 55%, #fff4c2 0%, #ffc95e ${40 * flackern}%, #e08a3c 100%)`,
border: '5px solid #3a3036',
}}
/>
<div style={{position: 'absolute', left: LATERNE.x - 30, top: LATERNE.y - 16, width: 60, height: 12, borderRadius: 4, background: '#3a3036'}} />
</>
);
};
export const Szene7: React.FC = () => {
const f = useCurrentFrame();
const {z, dauer} = S7;
const flackern = 0.92 + 0.08 * Math.sin(f * 0.5) * Math.sin(f * 0.17);
const winken = z.t1.start - 6;
const kam = kamFahrt(f, [
[0, 1300, 600, 1.15],
[z.bild0.ende, 1350, 700, 1.4],
[z.n1.ende, NEST_X, 820, 2.0],
[z.e2.start, 1380, 700, 1.45],
[z.t1.start, 1400, 680, 1.4],
[z.p1.start, NEST_X, 850, 2.6],
[dauer, NEST_X, 860, 2.9],
]);
return (
<>
<Kamera {...kam}>
<Ort name="garten_nacht" />
<div style={{position: 'absolute', inset: 0, filter: 'brightness(0.62) saturate(0.8) hue-rotate(-8deg)'}}>
{f < winken ? (
<Figur
pose={[
[0, 'noa_A6_blick-rechts'],
[z.bild0.ende - 20, 'noa_A2_augen-halb'],
[z.e1.start, 'noa_A3_lachen'],
]}
x={NOA_X7}
y={BODEN}
stauchY={atmen(f, 2)}
/>
) : (
<ClipFigur name="noa_C4_winken" start={winken} tempo={1.2} x={NOA_X7} y={BODEN} />
)}
<Nest x={NEST_X} y={BODEN} seite="hinten" />
<div style={{position: 'absolute', inset: 0, clipPath: `inset(0 0 ${1080 - BODEN - 4}px 0)`}}>
<Figur pose="pino_B_schlaeft-eingerollt" x={NEST_X + 10} y={BODEN - 34} stauchY={atmen(f, 1, 0.02)} schatten={false} />
</div>
<Nest x={NEST_X} y={BODEN} seite="vorne" />
<Figur
pose={[
[0, 'lulu_A5_blick-links'],
[z.bild0.ende, 'lulu_A2_augen-halb'],
[z.t1.start, 'lulu_A3_lachen'],
]}
x={LULU_X7}
y={BODEN - 10}
stauchY={atmen(f, 3, 0.006)}
drehung={f > z.t1.start && f < z.t1.ende ? Math.sin((f - z.t1.start) * 0.25) * 2 : 0}
/>
</div>
{/* warmes Licht der Laterne */}
<div
style={{
position: 'absolute',
left: LATERNE.x - 520,
top: LATERNE.y - 560,
width: 1040,
height: 900,
background: `radial-gradient(closest-side, rgba(255, 200, 110, ${0.42 * flackern}), rgba(255, 180, 90, 0.12) 55%, rgba(255, 180, 90, 0) 100%)`,
mixBlendMode: 'screen',
}}
/>
<Laterne frame={f} />
<Zzz x={NEST_X + 50} y={BODEN - 110} start={20} s={0.7} />
<Schnee start={30} ziel={[NEST_X - 20, BODEN - 70]} />
</Kamera>
<Vignette staerke={0.5} />
<Untertitel zeilen={S7.liste} />
<Blende dauer={dauer} rein={16} raus={0} farbe="#1c1a24" />
</>
);
};

View file

@ -0,0 +1,101 @@
import React from 'react';
import {interpolate, useCurrentFrame} from 'remotion';
import {loadFont} from '@remotion/google-fonts/Fredoka';
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 FARBE: Record<Sprecher, string> = {
ERZ: '#7a6f66',
PINO: '#3f5a8a',
NOA: '#c27a12',
LULU: '#5f8a4a',
ALLE: '#a0526b',
NOA_LULU: '#a0526b',
};
// Untertitel für die Vorschau ohne Ton. Mit Sprachaufnahmen können sie aus.
export const Untertitel: React.FC<{zeilen: Geplant[]}> = ({zeilen}) => {
const frame = useCurrentFrame();
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 && (
<div
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 52,
display: 'flex',
justifyContent: 'center',
opacity: interpolate(frame, [z.start, z.start + 5, z.ende, z.ende + 6], [0, 1, 1, 0], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}),
}}
>
<div
style={{
maxWidth: 1400,
padding: '16px 34px 18px',
borderRadius: 34,
background: 'rgba(255, 250, 240, 0.9)',
boxShadow: '0 6px 24px rgba(60, 40, 20, 0.18)',
fontFamily: SCHRIFT,
textAlign: 'center',
}}
>
<div style={{fontSize: 26, fontWeight: 600, color: FARBE[z.wer], letterSpacing: 1}}>
{NAME[z.wer]}
{z.leise ? ' (leise)' : ''}
</div>
<div style={{fontSize: 46, fontWeight: 500, color: '#3b2f28', lineHeight: 1.2, fontStyle: z.leise ? 'italic' : 'normal'}}>{z.text}</div>
</div>
</div>
)}
{frage && <MitmachPause von={frage.ende} bis={frage.frei} />}
</>
);
};
// Mitmach-Pause: großes Fragezeichen, drei Punkte füllen sich nacheinander.
const MitmachPause: React.FC<{von: number; bis: number}> = ({von, bis}) => {
const frame = useCurrentFrame();
const t = (frame - von) / (bis - von);
const auf = interpolate(frame, [von, von + 8, bis - 8, bis], [0, 1, 1, 0], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'});
const wipp = Math.sin(frame * 0.25) * 4;
return (
<div style={{position: 'absolute', right: 90, top: 80, opacity: auf, transform: `scale(${0.7 + 0.3 * auf}) rotate(${wipp}deg)`}}>
<div
style={{
width: 150,
height: 150,
borderRadius: '50%',
background: '#fff6df',
boxShadow: '0 6px 20px rgba(60,40,20,0.25)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: SCHRIFT,
fontSize: 110,
fontWeight: 600,
color: '#e08a3c',
}}
>
?
</div>
<div style={{display: 'flex', gap: 14, justifyContent: 'center', marginTop: 14}}>
{[0, 1, 2].map((i) => (
<div key={i} style={{width: 22, height: 22, borderRadius: '50%', background: t > i / 3 ? '#e08a3c' : 'rgba(255,246,223,0.9)'}} />
))}
</div>
</div>
);
};

View file

@ -0,0 +1,48 @@
// 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<string, Geplant>;
return {liste, z, dauer};
};

View file

@ -0,0 +1,47 @@
# Clips freistellen: python3 werkzeuge/clip_freistellen.py <arbeitsordner> <zielordner> clip1.mp4 ...
# Clip freistellen: Frames -> Maske -> gemeinsamer Zuschnitt -> WebM (VP9 mit Alpha)
import sys, os, subprocess, json, glob
import numpy as np
from PIL import Image, ImageFilter
from scipy import ndimage
from rembg import remove, new_session
s = new_session("isnet-general-use")
work, ziel = sys.argv[1], sys.argv[2]
os.makedirs(ziel, exist_ok=True)
for src in sys.argv[3:]:
name = os.path.basename(src)[:-4]
if os.path.exists(f"{ziel}/{name}.webm"): continue
d = f"{work}/{name}"; os.makedirs(d + "/roh", exist_ok=True); os.makedirs(d + "/frei", exist_ok=True)
subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", src, f"{d}/roh/%04d.png"], check=True)
roh = sorted(glob.glob(f"{d}/roh/*.png")); boxen = []
for f in roh:
dst = f"{d}/frei/" + os.path.basename(f)
im = Image.open(f).convert("RGB")
if not os.path.exists(dst):
m = np.asarray(remove(im, session=s, only_mask=True)).astype(float)
hard = m > 110
lab, n = ndimage.label(hard)
if n:
sizes = ndimage.sum(hard, lab, range(1, n + 1)); keep = np.zeros_like(hard)
for i, sz in enumerate(sizes):
if sz > 0.02 * sizes.max(): keep |= lab == i + 1
hard = keep
holes = ndimage.binary_fill_holes(hard) & ~hard
hl, hn = ndimage.label(holes)
for i in range(1, hn + 1):
if (hl == i).sum() < 400: hard |= hl == i
hard = ndimage.binary_erosion(hard, iterations=2)
a = Image.fromarray((hard * 255).astype("uint8")).filter(ImageFilter.GaussianBlur(0.8))
o = im.convert("RGBA"); o.putalpha(a); o.save(dst)
boxen.append(Image.open(dst).getchannel("A").getbbox())
bx = [b for b in boxen if b]
box = [min(b[0] for b in bx) - 4, min(b[1] for b in bx) - 4, max(b[2] for b in bx) + 4, max(b[3] for b in bx) + 4]
box = [max(0, box[0]), max(0, box[1]), min(1284, box[2]), min(716, box[3])]
if (box[2] - box[0]) % 2: box[2] -= 1
if (box[3] - box[1]) % 2: box[3] -= 1
w, h = box[2] - box[0], box[3] - box[1]
subprocess.run(["ffmpeg", "-v", "error", "-y", "-framerate", "24", "-i", f"{d}/frei/%04d.png",
"-vf", f"crop={w}:{h}:{box[0]}:{box[1]}", "-c:v", "libvpx-vp9", "-pix_fmt", "yuva420p",
"-b:v", "0", "-crf", "28", "-auto-alt-ref", "0", f"{ziel}/{name}.webm"], check=True)
json.dump({"box": box, "boxen": boxen, "frames": len(roh)}, open(f"{ziel}/{name}.json", "w"))
print(name, box, len(roh), flush=True)

View file

@ -0,0 +1,37 @@
# Standbilder freistellen (rembg), Leinwand bleibt erhalten.
# python3 werkzeuge/freistellen.py <zielordner> bild1.png bild2.png ...
# Freistellen ohne Zuschneiden (gleiche Leinwand -> Posen deckungsgleich)
import sys, os, time
import numpy as np
from PIL import Image, ImageFilter
from scipy import ndimage
from rembg import remove, new_session
s = new_session("isnet-general-use")
out_dir = sys.argv[1]
for src in sys.argv[2:]:
dst = os.path.join(out_dir, os.path.basename(src)[:-4] + ".png")
if os.path.exists(dst): continue
t = time.time()
im = Image.open(src).convert("RGB")
m = np.asarray(remove(im, session=s, only_mask=True)).astype(float)
hard = m > 110
lab, n = ndimage.label(hard)
if n == 0: print("LEER", src); continue
sizes = ndimage.sum(hard, lab, range(1, n + 1))
keep = np.zeros_like(hard)
for i, sz in enumerate(sizes): # alle großen Teile (Laterne, Bommel) behalten
if sz > 0.02 * sizes.max(): keep |= lab == i + 1
hard = keep
holes = ndimage.binary_fill_holes(hard) & ~hard
hl, hn = ndimage.label(holes)
for i in range(1, hn + 1):
if (hl == i).sum() < 400: hard |= hl == i
rgb = np.asarray(im).astype(float)
paper = (rgb.min(-1) > 228) & (rgb.max(-1) - rgb.min(-1) < 18)
plab, pn = ndimage.label(paper & hard)
for i in range(1, pn + 1):
if (plab == i).sum() > 600: hard &= ~(plab == i)
hard = ndimage.binary_erosion(hard, iterations=2)
a = Image.fromarray((hard * 255).astype("uint8")).filter(ImageFilter.GaussianBlur(0.8))
o = im.convert("RGBA"); o.putalpha(a); o.save(dst)
print(os.path.basename(dst), round(time.time() - t, 1), flush=True)

View file

@ -0,0 +1,68 @@
# Bereitet die freigestellten Standbilder der Figuren-Bibliothek für Remotion auf.
#
# Eingabe: freigestellte PNGs in voller Leinwand (1376 × 768, wie aus der
# 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/<pose>.webp und src/folge02/posen.ts
# python3 werkzeuge/posen.py <ordner_freigestellt> [<ordner_clips_frei>]
import json, os, sys, glob
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")
os.makedirs(ZIEL, exist_ok=True)
# Referenzpose je Figur: deren Höhe gilt als „Figurhöhe“, deren Fußpunkt als Anker.
REF = {"pino": "pino_ref_sitzend", "noa": "noa_ref_stehend", "lulu": "lulu_A6_blick-rechts"}
# Clip-Leinwand (1284 × 716) ist die verkleinerte Bild-Leinwand (1376 × 768).
K_CLIP = 1376 / 1284
posen = {}
for figur in REF:
dateien = sorted(glob.glob(f"{QUELLE}/{figur}_*.png"))
bilder = {os.path.basename(d)[:-4]: Image.open(d) for d in dateien}
boxen = {n: b.getchannel("A").getbbox() for n, b in bilder.items()}
rahmen = [min(b[0] for b in boxen.values()) - 4, min(b[1] for b in boxen.values()) - 4,
max(b[2] for b in boxen.values()) + 4, max(b[3] for b in boxen.values()) + 4]
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],
"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.
for n in posen:
if n.startswith(f"{figur}_A5_"):
a6 = boxen[n.replace("A5_blick-links", "A6_blick-rechts")]
posen[n]["x"] += (a6[0] + a6[2]) / 2 - (boxen[n][0] + boxen[n][2]) / 2
print(figur, len(bilder), rahmen)
figuren = {}
for figur, ref in REF.items():
b = posen[ref]["box"]
figuren[figur] = {"refHoehe": b[3] - b[1], "fussX": (b[0] + b[2]) / 2, "fussY": b[3]}
clips = {}
if CLIPS:
for j in sorted(glob.glob(f"{CLIPS}/*.json")):
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",
"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"]}
with open(TS, "w") as f:
f.write("// Automatisch erzeugt von werkzeuge/posen.py – nicht von Hand ändern.\n")
f.write("// Koordinaten in der Bild-Leinwand der Figur (1376 × 768).\n\n")
f.write("export type Pose = {figur: string; datei: string; x: number; y: number; b: number; h: number; box: number[]};\n")
f.write("export type ClipDaten = {figur: string; datei: string; x: number; y: number; b: number; h: number; frames: number};\n\n")
j = lambda o: json.dumps(o, indent="\t")
f.write("export const FIGUREN = " + j(figuren) + " as const;\n\n")
f.write("export const POSEN: Record<string, Pose> = " + j(posen) + ";\n\n")
f.write("export const CLIPS: Record<string, ClipDaten> = " + j(clips) + ";\n")
print("posen.ts", len(posen), "Posen,", len(clips), "Clips")