Remotion: Baukasten für alle Folgen, Szenen einzeln renderbar, Stimmen-Skript

- gemeinsame Bausteine nach src/baukasten/, Bibliothek nach public/bibliothek/, Orte nach public/orte/
- je Szene eine Komposition (F02-01-…), werkzeuge/render.mjs rendert nur geänderte Szenen
  und klebt sie zusammen; --entwurf mit halber Auflösung und eingeblendeter Zeit/Zeile
- werkzeuge/stimmen.mts erzeugt ElevenLabs-Aufnahmen, Zeitplan übernimmt ihre Länge
- Folge 2: Geh- und Hinsetz-Clips statt Standbild-Wechsel, neue Vögel, fallendes Blatt,
  Noa malt den Kalender (Blick von oben)
- ANLEITUNG_PC.md für Studio und Rendern unter Windows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pascal Scholz 2026-10-29 18:43:56 +00:00
parent 0b326e6e9c
commit cbacc2fd97
101 changed files with 1452 additions and 322 deletions

3
remotion/.gitignore vendored
View file

@ -1,3 +1,4 @@
node_modules/ node_modules/
out/ out/
public/folge02/clips public/bibliothek/clips
public/stimmen

53
remotion/ANLEITUNG_PC.md Normal file
View file

@ -0,0 +1,53 @@
# Remotion auf dem eigenen PC (Windows)
Damit kannst du Folgen sofort im Browser anschauen und durchspulen, ohne zu
rendern, und schneller rendern als auf dem VPS (mehr Kerne, Grafikkarte).
## Einmalig einrichten
1. **Node.js** (LTS, z. B. 22) installieren: https://nodejs.org – Standard-
einstellungen. **Git** installieren: https://git-scm.com
2. Repo holen (in der Eingabeaufforderung `cmd`):
```cmd
cd %USERPROFILE%
git clone ssh://git@git.larpaso.de:2222/pascal/youtube.git
cd youtube\remotion
npm install
```
3. **Clips vom Server holen** (stehen nicht im Git, ca. 3 MB):
```cmd
ssh -t deploy@larpaso.de "sudo cp -r /var/lib/docker/volumes/claude_shared/_data/youtube/figuren_bibliothek/clips_frei ~/clips_frei && sudo chown -R deploy ~/clips_frei"
scp -r deploy@larpaso.de:~/clips_frei %USERPROFILE%\youtube\remotion\public\bibliothek\clips
```
Ohne Clips läuft alles trotzdem, an ihrer Stelle steht dann ein Standbild.
Sobald es Sprachaufnahmen gibt, genauso `/shared/youtube/stimmen` nach
`public\stimmen` kopieren.
## Anschauen (Studio)
```cmd
cd %USERPROFILE%\youtube\remotion
npx remotion studio
```
Es öffnet sich der Browser (sonst http://localhost:3000). Links die
Kompositionen: `F02` ist die ganze Folge, `F02-03-…` einzelne Szenen.
Mit Leertaste abspielen, mit der Zeitleiste springen. Ändert jemand den
Code (`git pull`), aktualisiert sich die Vorschau von selbst.
Tipp: Unten rechts die Vorschau-Qualität auf „Half“ stellen, wenn es ruckelt.
## Rendern
```cmd
node werkzeuge\render.mjs F02 --entwurf
node werkzeuge\render.mjs F02
node werkzeuge\render.mjs F02 --szene 3
```
Ergebnis: `out\F02\F02-entwurf.mp4` bzw. `out\F02\F02.mp4`. Schon
gerenderte, unveränderte Szenen werden übersprungen. Das Skript nutzt alle
Kerne bis auf einen und die Grafikkarte.
## Aktualisieren
```cmd
cd %USERPROFILE%\youtube
git pull
cd remotion
npm install
```

View file

@ -61,43 +61,78 @@ 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`) ## Folgen bauen (Baukasten)
Komplette Folge (ca. 5:20) nur mit Code, Figuren-Bibliothek und ohne Ton: Eine Folge besteht aus Szenen, jede Szene aus Sprechzeilen und Bewegungen,
Untertitel ersetzen vorerst die Stimmen, Mitmach-Pausen zeigen ein „?“. die an diesen Zeilen hängen. Alles Wiederverwendbare liegt in
`src/baukasten/`, jede Folge hat einen eigenen Ordner (`src/folge02/`).
Untertitel stehen für die Stimmen, solange es keine Aufnahmen gibt;
Mitmach-Pausen zeigen ein „?“.
| Pfad | Inhalt | | Pfad | Inhalt |
| --- | --- | | --- | --- |
| `src/folge02/Folge02.tsx` | Reihenfolge der Szenen | | `src/baukasten/figur.tsx` | `Figur` (Standbilder mit Posenwechsel), `ClipFigur` (freigestellte Clips), `Kamera` |
| `src/folge02/szene2.tsx` … `szene7.tsx`, `lied.tsx`, `buch.tsx` | die Szenen; jede hat oben ihre Sprechzeilen (`plane([...])`) | | `src/baukasten/helfer.tsx` | Kamerafahrt, Atmen, Gehen, Hüpfen, Wackeln, Blenden, Ankerpunkte |
| `src/folge02/zeit.ts` | Zeitplan aus den Zeilen; Dauer wird aus der Textlänge geschätzt | | `src/baukasten/natur.tsx` | Blätter, Nest, Vögel, Schnee, Zzz, Funkeln, große Rufe |
| `src/folge02/figur.tsx` | `Figur` (Standbilder mit Posenwechsel), `ClipFigur` (freigestellte Clips), `Kamera` | | `src/baukasten/orte.tsx` | Orte als SVG (Garten Tag/Nacht, Blick von oben, Noas Zimmer, Kalender); vorberechnet nach `public/orte/` |
| `src/folge02/orte.tsx` | Orte als SVG; vorberechnet nach `public/folge02/orte/` | | `src/baukasten/buch.tsx` | Bilderbuch-Intro und -Outro |
| `src/folge02/natur.tsx` | Blätter, Nest, Vögel, Schnee, Zzz, Funkeln, große Rufe | | `src/baukasten/zeit.ts` | Zeitplan aus den Zeilen (geschätzt oder aus den Aufnahmen) |
| `src/folge02/posen.ts` | automatisch erzeugt von `werkzeuge/posen.py` | | `src/baukasten/untertitel.tsx` | Untertitel, Mitmach-Pause, Stimmen abspielen |
| `public/folge02/figuren/` | freigestellte Standbilder (WebP, im Git) | | `src/baukasten/folge.tsx` | macht aus der Szenenliste die Kompositionen |
| `public/folge02/clips` | Link auf `/shared/youtube/folge02/clips_frei/` (WebM mit Alpha, nicht im Git) | | `src/baukasten/posen.ts` | automatisch erzeugt von `werkzeuge/posen.py` |
| `src/folge02/` | Folge 2: `Folge02.tsx` (Szenenliste), `szene2.tsx` … `szene7.tsx`, `lied.tsx`, `stimmen.json` |
| `public/bibliothek/figuren/` | freigestellte Standbilder (WebP, im Git) |
| `public/bibliothek/clips/` | freigestellte Clips (WebM mit Alpha, nicht im Git; VPS: Link auf `/shared/youtube/figuren_bibliothek/clips_frei/`) |
| `public/stimmen/` | Sprachaufnahmen (nicht im Git; VPS: Link auf `/shared/youtube/stimmen/`) |
**Kompositionen:** `F02` ist die ganze Folge, `F02-01-Intro`,
`F02-02-Gaehnen-ueberall` … jede Szene einzeln.
**Neue Folge:** Ordner `src/folgeNN/` anlegen, `plan.ts` und `stimmen.json`
(`{}`) aus Folge 2 kopieren und den Ordnernamen anpassen, Szenen nach dem
Muster von `szene2.tsx` schreiben (oben die Zeilen mit `plane('sX', [...])`,
darunter Posenfolgen und Kamerafahrt, die sich auf `z.<zeile>.start/ende`
beziehen), in `FolgeNN.tsx` auflisten und in `src/Root.tsx` eintragen.
### Rendern
```bash
node werkzeuge/render.mjs F02 --entwurf # schnelle Fassung zum Begutachten
node werkzeuge/render.mjs F02 # volle Qualität
node werkzeuge/render.mjs F02 --szene 3 # nur Szene 3 neu
```
Das Skript rendert jede Szene einzeln nach `out/F02/` und klebt sie
zusammen. Szenen, deren Code, Bilder oder Aufnahmen sich nicht geändert
haben, werden übersprungen. Im Entwurf stehen oben links Gesamtzeit, Szene,
Szenenzeit und Zeilen-ID – für Rückmeldungen einfach diese Angaben nennen
(z. B. „2:47, Szene 4, Zeile l1: Lulus Kopf zu weit links“).
Auf dem VPS: vorher `export LD_LIBRARY_PATH=$HOME/.local/chrome-libs/root/usr/lib/x86_64-linux-gnu`.
### Vorschau ohne Rendern
`npx remotion studio` öffnet alle Kompositionen im Browser; Änderungen am
Code sind sofort zu sehen. Anleitung für einen Windows-PC: `ANLEITUNG_PC.md`.
### Stimmen (ElevenLabs)
1. Stimmen-IDs je Sprecher in `werkzeuge/stimmen.config.json` eintragen
(`ALLE` und `NOA_LULU` verweisen auf einen anderen Sprecher).
2. `npx tsx werkzeuge/stimmen.mts F02 --probe` zeigt, welche Zeilen erzeugt
würden, und zählt die Zeichen (kostet nichts).
3. `ELEVENLABS_API_KEY=… npx tsx werkzeuge/stimmen.mts F02` erzeugt die
Aufnahmen nach `public/stimmen/folge02/` und trägt ihre Länge in
`src/folge02/stimmen.json` ein. Der Zeitplan richtet sich danach; ändert
sich ein Text, wird nur diese Zeile neu erzeugt.
Untertitel ausblenden: `--props='{"untertitel": false}'` (bzw. in
`render.mjs` die `props.json`).
### Neue Standbilder oder Clips
```bash
python3 werkzeuge/freistellen.py <zielordner> bild1.png … # ohne Zuschneiden
python3 werkzeuge/clip_freistellen.py <arbeitsordner> <clips_frei> clip1.mp4 …
python3 werkzeuge/posen.py <zielordner> <clips_frei> # Posen und Clips eintragen
```
Mit `pip install "rembg[gpu]"` nutzt `rembg` eine Nvidia-Grafikkarte
automatisch (auf dem VPS gibt es keine; dort ca. 3 s pro Bild).
Orte neu vorberechnen (nach Änderungen in `orte.tsx`): Orte neu vorberechnen (nach Änderungen in `orte.tsx`):
```bash `npx remotion still src/index.ts F2Ort-garten public/orte/garten.webp --image-format=webp`
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

View file

@ -16,7 +16,8 @@
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/three": "^0.169.0" "@types/three": "^0.169.0",
"tsx": "^4.19.2"
} }
}, },
"node_modules/@babel/parser": { "node_modules/@babel/parser": {
@ -1643,6 +1644,21 @@
"integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==", "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==",
"license": "Unlicense" "license": "Unlicense"
}, },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-stream": { "node_modules/get-stream": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
@ -1655,6 +1671,19 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/get-tsconfig": {
"version": "4.14.3",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz",
"integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/glob-to-regexp": { "node_modules/glob-to-regexp": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
@ -2387,6 +2416,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.23.2", "version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@ -2712,6 +2751,474 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tsx": {
"version": "4.19.2",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.2.tgz",
"integrity": "sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.23.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz",
"integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-arm": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz",
"integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz",
"integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz",
"integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz",
"integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/darwin-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz",
"integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz",
"integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz",
"integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-arm": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz",
"integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz",
"integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-ia32": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz",
"integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-loong64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz",
"integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz",
"integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz",
"integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz",
"integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-s390x": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz",
"integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz",
"integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz",
"integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/openbsd-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz",
"integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz",
"integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/sunos-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz",
"integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz",
"integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-ia32": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz",
"integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz",
"integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/esbuild": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz",
"integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.23.1",
"@esbuild/android-arm": "0.23.1",
"@esbuild/android-arm64": "0.23.1",
"@esbuild/android-x64": "0.23.1",
"@esbuild/darwin-arm64": "0.23.1",
"@esbuild/darwin-x64": "0.23.1",
"@esbuild/freebsd-arm64": "0.23.1",
"@esbuild/freebsd-x64": "0.23.1",
"@esbuild/linux-arm": "0.23.1",
"@esbuild/linux-arm64": "0.23.1",
"@esbuild/linux-ia32": "0.23.1",
"@esbuild/linux-loong64": "0.23.1",
"@esbuild/linux-mips64el": "0.23.1",
"@esbuild/linux-ppc64": "0.23.1",
"@esbuild/linux-riscv64": "0.23.1",
"@esbuild/linux-s390x": "0.23.1",
"@esbuild/linux-x64": "0.23.1",
"@esbuild/netbsd-x64": "0.23.1",
"@esbuild/openbsd-arm64": "0.23.1",
"@esbuild/openbsd-x64": "0.23.1",
"@esbuild/sunos-x64": "0.23.1",
"@esbuild/win32-arm64": "0.23.1",
"@esbuild/win32-ia32": "0.23.1",
"@esbuild/win32-x64": "0.23.1"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.6.3", "version": "5.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",

View file

@ -16,6 +16,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/three": "^0.169.0" "@types/three": "^0.169.0",
"tsx": "^4.19.2"
} }
} }

View file

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View file

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View file

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View file

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View file

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View file

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View file

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View file

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View file

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View file

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View file

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

View file

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View file

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

View file

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View file

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View file

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View file

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View file

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

View file

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View file

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View file

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View file

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View file

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View file

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View file

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View file

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View file

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View file

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View file

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View file

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View file

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View file

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View file

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View file

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View file

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View file

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View file

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View file

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View file

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View file

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View file

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

View file

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View file

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

View file

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

View file

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View file

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View file

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

View file

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

View file

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

View file

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

View file

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

View file

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

View file

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

View file

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

View file

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View file

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

View file

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View file

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

View file

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

View file

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View file

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

View file

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

View file

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

View file

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View file

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 131 KiB

View file

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View file

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View file

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 221 KiB

View file

@ -3,8 +3,9 @@ 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 {FolgenKompositionen} from './baukasten/folge';
import {FOLGE02_DAUER, Folge02} from './folge02/Folge02'; import {ORTE, OrtStill} from './baukasten/orte';
import {F02} from './folge02/Folge02';
const BILD = {fps: 30, width: 1920, height: 1080} as const; const BILD = {fps: 30, width: 1920, height: 1080} as const;
@ -15,7 +16,7 @@ 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} /> <FolgenKompositionen folge={F02} />
{Object.keys(ORTE).map((name) => ( {Object.keys(ORTE).map((name) => (
<Composition key={name} id={`F2Ort-${name.replace('_', '-')}`} component={OrtStill} defaultProps={{name}} durationInFrames={1} {...BILD} /> <Composition key={name} id={`F2Ort-${name.replace('_', '-')}`} component={OrtStill} defaultProps={{name}} durationInFrames={1} {...BILD} />
))} ))}

View file

@ -5,7 +5,7 @@ import {Figur, Kamera} from './figur';
import {Blende, Vignette, kamFahrt} from './helfer'; import {Blende, Vignette, kamFahrt} from './helfer';
import {Blatt} from './natur'; import {Blatt} from './natur';
import {SCHRIFT, Untertitel} from './untertitel'; import {SCHRIFT, Untertitel} from './untertitel';
import {plane} from './zeit'; import {plane, type Plan, type Zeile} from './zeit';
// Das Bilderbuch: öffnet sich am Anfang (Intro) und klappt am Ende zu. // Das Bilderbuch: öffnet sich am Anfang (Intro) und klappt am Ende zu.
// Buchrücken bei x = 960; geschlossen liegt der Deckel rechts davon. // Buchrücken bei x = 960; geschlossen liegt der Deckel rechts davon.
@ -91,19 +91,19 @@ const Tisch: React.FC = () => (
); );
// Intro: Buch geht auf, Figuren springen auf die Seite, Kamera fährt in die Seite. // Intro: Buch geht auf, Figuren springen auf die Seite, Kamera fährt in die Seite.
export const INTRO = plane( // Die Folge plant die Zeilen selbst (mit `introZeilen`), damit Aufnahmen greifen.
[ export const introZeilen = (folgenTitel: string): Zeile[] => [
{id: 'buch', dauer: 2.2}, {id: 'buch', dauer: 2.2},
{id: 't1', wer: 'ERZ', text: 'Pino, Noa und Lulu.'}, {id: 't1', wer: 'ERZ', text: 'Pino, Noa und Lulu.'},
{id: 't2', wer: 'ERZ', text: 'Heute: Pino ist müde.'}, {id: 't2', wer: 'ERZ', text: `Heute: ${folgenTitel}.`},
], ];
0.3, export const INTRO_OPTIONEN = {vorlauf: 0.3, nachlauf: 1.6};
1.6,
);
export const Intro: React.FC = () => { type IntroProps = {plan: Plan; titel: string; posen?: [string, string, string]};
export const Intro: React.FC<IntroProps> = ({plan, titel, posen = ['pino_A3_lachen', 'noa_A3_lachen', 'lulu_A3_lachen']}) => {
const frame = useCurrentFrame(); const frame = useCurrentFrame();
const {z, dauer} = INTRO; const {z, dauer} = plan;
const auf = 30; const auf = 30;
const winkel = kurve(frame, [ const winkel = kurve(frame, [
[auf, 0], [auf, 0],
@ -120,21 +120,21 @@ export const Intro: React.FC = () => {
<> <>
<Kamera {...kam} frei> <Kamera {...kam} frei>
<Tisch /> <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 && <Innenseiten frame={frame} auf={auf + 30} titel={titel} posen={posen} />}
{winkel >= -90 && <Seite links={960} />} {winkel >= -90 && <Seite links={960} />}
<Deckel winkel={winkel} /> <Deckel winkel={winkel} />
</Kamera> </Kamera>
<Vignette /> <Vignette />
<Untertitel zeilen={INTRO.liste} /> <Untertitel zeilen={plan.liste} />
<Blende dauer={dauer} rein={12} raus={14} /> <Blende dauer={dauer} rein={12} raus={14} />
</> </>
); );
}; };
// Outro: Seite mit winkenden Figuren, Buch klappt zu. // Outro: Seite mit winkenden Figuren, Buch klappt zu.
export const OUTRO = plane([{id: 'buch', dauer: 4.5}], 0.2, 0.6); export const OUTRO = plane([{id: 'buch', dauer: 4.5}], {vorlauf: 0.2, nachlauf: 0.6});
export const Outro: React.FC = () => { export const Outro: React.FC<{posen?: [string, string, string]}> = ({posen = ['pino_B3_schlafen', 'noa_A_arm-hoch-winken', 'lulu_A3_lachen']}) => {
const frame = useCurrentFrame(); const frame = useCurrentFrame();
const {dauer} = OUTRO; const {dauer} = OUTRO;
const zu = 60; const zu = 60;
@ -152,7 +152,7 @@ export const Outro: React.FC = () => {
<> <>
<Kamera {...kam} frei> <Kamera {...kam} frei>
<Tisch /> <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 && <Innenseiten frame={frame} auf={-60} titel="Bis bald!" posen={posen} />}
{winkel >= -90 && <Seite links={960} />} {winkel >= -90 && <Seite links={960} />}
<Deckel winkel={winkel} /> <Deckel winkel={winkel} />
</Kamera> </Kamera>

View file

@ -131,6 +131,19 @@ export const ClipFigur: React.FC<ClipProps> = ({name, start, hoehe, tempo = 1, s
); );
}; };
// Gehende Figur: Geh-Clip der Bibliothek in Schleife (Blick nach links,
// `spiegeln` = nach rechts). Die Bewegung über den Boden macht die Szene (x).
export const Geher: React.FC<Omit<ClipProps, 'name' | 'start' | 'schleife'> & {figur: FigurName}> = ({figur, ...rest}) => (
<ClipFigur name={`${figur}_C1_gehen-schleife`} start={0} schleife ersatz={`${figur}_A7-1_gehen`} {...rest} />
);
// Noa geht in die Hocke: Clip „hinsetzen“ bis zur Hocke (ca. 1,65 s), danach
// bleibt das letzte Bild stehen. `auf` spielt ihn rückwärts (aufstehen).
export const NOA_HOCKE_FRAMES = 50; // Dauer in Frames der Komposition bei Tempo 1
export const NoaHocke: React.FC<Omit<ClipProps, 'name' | 'bis' | 'rueckwaerts'> & {auf?: boolean}> = ({auf = false, ...rest}) => (
<ClipFigur name="noa_C5_hinsetzen" bis={1.65} rueckwaerts={auf} ersatz={auf ? 'noa_ref_stehend' : 'noa_B_hocken'} {...rest} />
);
// Kamera: zeigt den Ausschnitt um (x, y) mit Zoom z (1 = ganze 1920 × 1080-Welt). // 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). // 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}) => { 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}) => {

View file

@ -0,0 +1,107 @@
import React from 'react';
import {AbsoluteFill, Composition, Series, getInputProps, useCurrentFrame} from 'remotion';
import {SCHRIFT} from './untertitel';
import type {Geplant} from './zeit';
// Eine Folge = Liste von Szenen. Daraus entstehen die Komposition der ganzen
// Folge und je Szene eine eigene Komposition (zum einzelnen Rendern):
// F02 ganze Folge
// F02-03-... nur Szene 3
// Mit --props='{"entwurf": true}' blendet jede Szene oben links Szene, Zeit
// und Zeilen-ID ein, damit Rückmeldungen genau sagen können, wo etwas ist.
export type SzenenEintrag = {
name: string;
datei: string; // Quelldatei der Szene (für werkzeuge/render.mjs)
plan: {dauer: number; liste: Geplant[]};
K: React.FC;
};
const BILD = {fps: 30, width: 1920, height: 1080} as const;
const zeit = (f: number) => {
const s = Math.floor(f / 30);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
};
export const kennung = (kuerzel: string, i: number, name: string) =>
`${kuerzel}-${String(i + 1).padStart(2, '0')}-${name
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/[^A-Za-z0-9]+/g, '-')
.replace(/^-|-$/g, '')}`;
const Entwurf: React.FC<{szenen: SzenenEintrag[]; index: number; versatz: number}> = ({szenen, index, versatz}) => {
const f = useCurrentFrame();
const sz = szenen[index];
const zeile = sz.plan.liste.find((l) => f >= l.start && f < l.frei + 10);
return (
<div
style={{
position: 'absolute',
left: 16,
top: 14,
padding: '6px 14px',
borderRadius: 10,
background: 'rgba(20, 16, 24, 0.72)',
color: '#fff',
fontFamily: SCHRIFT,
fontSize: 26,
fontVariantNumeric: 'tabular-nums',
}}
>
{zeit(versatz + f)} · Szene {index + 1} „{sz.name}“ · {zeit(f)}
{zeile ? ` · ${zeile.id}` : ''}
</div>
);
};
const istEntwurf = () => getInputProps().entwurf === true;
const versatzVon = (szenen: SzenenEintrag[], index: number) => szenen.slice(0, index).reduce((s, x) => s + x.plan.dauer, 0);
export const folgenDauer = (szenen: SzenenEintrag[]) => versatzVon(szenen, szenen.length);
const EinzelSzene: React.FC<{szenen: SzenenEintrag[]; index: number}> = ({szenen, index}) => {
const {K} = szenen[index];
return (
<AbsoluteFill style={{background: '#f4ead8'}}>
<K />
{istEntwurf() && <Entwurf szenen={szenen} index={index} versatz={versatzVon(szenen, index)} />}
</AbsoluteFill>
);
};
// Komponenten einmal je Folge bauen (Kompositions-Props müssen ohne
// Funktionen auskommen, darum stecken die Szenen in den Komponenten selbst).
export const baueFolge = (kuerzel: string, szenen: SzenenEintrag[]) => {
const Ganz: React.FC = () => (
<AbsoluteFill style={{background: '#f4ead8'}}>
<Series>
{szenen.map((s, i) => (
<Series.Sequence key={s.name} durationInFrames={s.plan.dauer} name={s.name}>
<EinzelSzene szenen={szenen} index={i} />
</Series.Sequence>
))}
</Series>
</AbsoluteFill>
);
const einzeln = szenen.map((s, i) => {
const E: React.FC = () => <EinzelSzene szenen={szenen} index={i} />;
return {id: kennung(kuerzel, i, s.name), K: E, dauer: s.plan.dauer, datei: s.datei, name: s.name};
});
return {kuerzel, dauer: folgenDauer(szenen), Ganz, einzeln};
};
export type Folge = ReturnType<typeof baueFolge>;
// Alle Kompositionen einer Folge (für Root.tsx)
export const FolgenKompositionen: React.FC<{folge: Folge}> = ({folge}) => (
<>
<Composition id={folge.kuerzel} component={folge.Ganz} durationInFrames={folge.dauer} {...BILD} />
{folge.einzeln.map((e) => (
<Composition key={e.id} id={e.id} component={e.K} durationInFrames={e.dauer} {...BILD} />
))}
</>
);

View file

@ -69,20 +69,41 @@ export const Blaetterregen: React.FC<{start: number; x0: number; y0: number; bod
); );
}; };
// Ein Vogelschwarm zieht nach rechts oben davon. // Ein Vogel von der Seite (fliegt nach rechts). `schlag` -1 … 1 = Flügel unten … oben.
export const Voegel: React.FC<{start: number; anzahl?: number; skala?: number}> = ({start, anzahl = 11, skala = 1}) => { const Vogel: React.FC<{x: number; y: number; g: number; schlag: number; neigung: number}> = ({x, y, g, schlag, neigung}) => {
const h = -8 - schlag * 24; // Flügelspitze relativ zur Schulter
const fluegel = (dx: number, dy: number) => `M ${3 + dx} ${-3 + dy} Q ${-3 + dx} ${h * 0.55 + dy} ${-15 + dx} ${h + dy} Q ${-6 + dx} ${h * 0.35 + dy} ${-7 + dx} ${1 + dy} Z`;
return (
<g transform={`translate(${x} ${y}) rotate(${neigung}) scale(${g / 30})`}>
<path d={fluegel(4, -1)} fill="#3a3540" opacity={0.85} />
<path d="M -14 0 L -27 -6 L -25 0 L -27 6 Z" fill="#4a4550" />
<ellipse cx={0} cy={0} rx={16} ry={7} fill="#4f4a55" />
<ellipse cx={1} cy={3} rx={11} ry={3.5} fill="#8a7f78" opacity={0.6} />
<circle cx={15} cy={-4} r={6.5} fill="#4f4a55" />
<path d="M 20 -6 L 28 -3.5 L 20 -1.5 Z" fill="#c98a3c" />
<circle cx={17} cy={-5.5} r={1.4} fill="#f4ead8" />
<path d={fluegel(0, 0)} fill="#5c5662" />
</g>
);
};
// Ein Vogelschwarm zieht in V-Form nach rechts oben davon.
export const Voegel: React.FC<{start: number; anzahl?: number; skala?: number}> = ({start, anzahl = 9, skala = 1}) => {
const frame = useCurrentFrame(); const frame = useCurrentFrame();
const t = frame - start; const t = frame - start;
return ( return (
<svg viewBox="0 0 1920 1080" style={{position: 'absolute', inset: 0, width: '100%', height: '100%'}}> <svg viewBox="0 0 1920 1080" style={{position: 'absolute', inset: 0, width: '100%', height: '100%'}}>
{new Array(anzahl).fill(0).map((_, i) => { {new Array(anzahl).fill(0).map((_, i) => {
const r = (k: string) => random(`vogel${k}${i}`); const r = (k: string) => random(`vogel${k}${i}`);
const reihe = Math.abs(i - (anzahl - 1) / 2); const reihe = Math.ceil(i / 2) * (i % 2 ? 1 : -1); // Spitze vorn, Äste nach hinten
const x = 100 + t * 6 - reihe * 70 * skala + r('x') * 30; const x = 160 + t * 5.5 - Math.abs(reihe) * 75 * skala + r('x') * 20;
const y = 560 - t * 1.6 + reihe * 38 * skala + r('y') * 20; const y = 600 - t * 1.5 + reihe * 42 * skala + Math.sin(t * 0.05 + r('w') * 6) * 8;
const schlag = Math.sin(t * 0.45 + r('p') * 6) * 12 * skala; // Flügelschlag mit kurzen Gleitphasen
const g = (16 + r('g') * 6) * skala; const takt = t * 0.38 + r('p') * 6;
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" />; const gleiten = Math.sin(t * 0.02 + r('q') * 6) > 0.6;
const schlag = gleiten ? 0.15 : Math.sin(takt);
const g = (34 + r('g') * 8) * skala * (1 - Math.abs(reihe) * 0.03);
return <Vogel key={i} x={x} y={y} g={g} schlag={schlag} neigung={-12 + Math.cos(takt) * 3} />;
})} })}
</svg> </svg>
); );

View file

@ -8,6 +8,10 @@ import './untertitel'; // lädt die Schrift für den Kalender
const B = 1920; const B = 1920;
const H = 1080; const H = 1080;
// Maße im Garten (passen zu den Bildern garten und garten_nacht)
export const BODEN = 935; // Fußlinie der Figuren auf der Wiese
export const NEST_X = 1480; // Platz vor dem Holzstapel
const Filter: React.FC = () => ( const Filter: React.FC = () => (
<defs> <defs>
<filter id="aq" x="-10%" y="-10%" width="120%" height="120%"> <filter id="aq" x="-10%" y="-10%" width="120%" height="120%">
@ -307,9 +311,13 @@ export const TischSVG: React.FC = () => (
); );
// Der gemalte Kalender (Nahaufnahme von oben). // Der gemalte Kalender (Nahaufnahme von oben).
export const KalenderSVG: React.FC = () => { const K_FARBEN = ['#d9534f', '#e0a020', '#5f8a4a', '#3f5a8a', '#a0526b', '#c8642c'];
const farben = ['#d9534f', '#e0a020', '#5f8a4a', '#3f5a8a', '#a0526b', '#c8642c']; const K_NUMMERN = [7, 15, 2, 21, 11, 18, 4, 23, 9, 13, 1, 16, 20, 6, 24, 12, 3, 17, 10, 22, 5, 14, 19, 8];
const nummern = [7, 15, 2, 21, 11, 18, 4, 23, 9, 13, 1, 16, 20, 6, 24, 12, 3, 17, 10, 22, 5, 14, 19, 8]; const kTuer = (i: number) => ({x: 330 + (i % 6) * 215, y: 250 + Math.floor(i / 6) * 180, farbe: K_FARBEN[i % K_FARBEN.length], nummer: K_NUMMERN[i]});
export const KalenderSVG: React.FC<{leer?: boolean}> = ({leer = false}) => {
const farben = K_FARBEN;
const nummern = K_NUMMERN;
return ( return (
<svg viewBox={`0 0 ${B} ${H}`} style={{position: 'absolute', inset: 0, width: '100%', height: '100%'}}> <svg viewBox={`0 0 ${B} ${H}`} style={{position: 'absolute', inset: 0, width: '100%', height: '100%'}}>
<Filter /> <Filter />
@ -319,7 +327,7 @@ export const KalenderSVG: React.FC = () => {
))} ))}
<g transform="rotate(-2 960 540)"> <g transform="rotate(-2 960 540)">
<rect x={250} y={90} width={1420} height={900} fill="#fdf8ec" filter="url(#aq)" /> <rect x={250} y={90} width={1420} height={900} fill="#fdf8ec" filter="url(#aq)" />
<g filter="url(#stift)"> {!leer && <g filter="url(#stift)">
<text x={960} y={190} textAnchor="middle" fontSize={78} fontFamily="Fredoka, sans-serif" fontWeight={600} fill="#c8642c"> <text x={960} y={190} textAnchor="middle" fontSize={78} fontFamily="Fredoka, sans-serif" fontWeight={600} fill="#c8642c">
Pinos Winterfest-Kalender Pinos Winterfest-Kalender
</text> </text>
@ -340,7 +348,7 @@ export const KalenderSVG: React.FC = () => {
{/* kleine Kritzeleien */} {/* 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 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" /> <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>}
</g> </g>
<Korn staerke={0.15} /> <Korn staerke={0.15} />
</svg> </svg>
@ -348,8 +356,93 @@ export const KalenderSVG: React.FC = () => {
}; };
export const KALENDER_TUER1 = {x: 330 + 4 * 215 + 90, y: 250 + 1 * 180 + 75}; // Türchen „1“ (Index 10) export const KALENDER_TUER1 = {x: 330 + 4 * 215 + 90, y: 250 + 1 * 180 + 75}; // Türchen „1“ (Index 10)
// Noa malt den Kalender (Blick von oben auf das Blatt, über `kalender_leer`).
// `p` 0…1: Titel, dann die Türchen Zeile für Zeile. Die Stiftspitze folgt dem Strich.
const drehe = (x: number, y: number) => {
const w = (-2 * Math.PI) / 180;
const dx = x - 960;
const dy = y - 540;
return {x: 960 + dx * Math.cos(w) - dy * Math.sin(w), y: 540 + dx * Math.sin(w) + dy * Math.cos(w)};
};
const umfangPunkt = (x: number, y: number, b: number, h: number, t: number) => {
const u = 2 * (b + h);
let d = t * u;
if (d < b) return {x: x + d, y};
d -= b;
if (d < h) return {x: x + b, y: y + d};
d -= h;
if (d < b) return {x: x + b - d, y: y + h};
d -= b;
return {x, y: y + h - d};
};
export const KalenderMalen: React.FC<{p: number; wackeln: number}> = ({p, wackeln}) => {
const titelAnteil = 0.08;
const tp = Math.min(1, p / titelAnteil);
const tuerP = Math.max(0, (p - titelAnteil) / (1 - titelAnteil)) * 24;
const aktiv = Math.min(23, Math.floor(tuerP));
const innen = tuerP - aktiv;
let spitze: {x: number; y: number};
let farbe = '#c8642c';
if (p < titelAnteil) {
spitze = {x: 560 + tp * 800, y: 175 + Math.sin(tp * 60) * 14};
} else {
const t = kTuer(aktiv);
farbe = t.farbe;
spitze = innen < 0.75 ? umfangPunkt(t.x, t.y, 180, 150, innen / 0.75) : {x: t.x + 62 + Math.sin(innen * 40) * 18, y: t.y + 75 + Math.cos(innen * 30) * 22};
}
const s = drehe(spitze.x, spitze.y);
s.x += Math.sin(wackeln * 0.9) * 2;
s.y += Math.cos(wackeln * 1.3) * 2;
return (
<svg viewBox={`0 0 ${B} ${H}`} style={{position: 'absolute', inset: 0, width: '100%', height: '100%'}}>
<Filter />
<g transform="rotate(-2 960 540)">
<g filter="url(#stift)">
<clipPath id="titelclip">
<rect x={540} y={100} width={tp * 840} height={120} />
</clipPath>
<text x={960} y={190} textAnchor="middle" fontSize={78} fontFamily="Fredoka, sans-serif" fontWeight={600} fill="#c8642c" clipPath="url(#titelclip)">
Pinos Winterfest-Kalender
</text>
{new Array(24).fill(0).map((_, i) => {
if (i > aktiv || p < titelAnteil) return null;
const t = kTuer(i);
const q = i < aktiv ? 1 : innen;
return (
<g key={i}>
<rect x={t.x} y={t.y} width={180} height={150} rx={14} fill="none" stroke={t.farbe} strokeWidth={7} pathLength={1} strokeDasharray={1} strokeDashoffset={1 - Math.min(1, q / 0.75)} />
{q > 0.75 && (
<>
<circle cx={t.x + 158} cy={t.y + 75} r={7} fill={t.farbe} />
<text x={t.x + 82} y={t.y + 102} textAnchor="middle" fontSize={74} fontFamily="Fredoka, sans-serif" fontWeight={600} fill={t.farbe} opacity={Math.min(1, (q - 0.75) / 0.15)}>
{t.nummer}
</text>
</>
)}
</g>
);
})}
</g>
</g>
{/* Noas Hand mit Buntstift, von unten rechts */}
<g transform={`translate(${s.x} ${s.y}) rotate(-28)`}>
<path d="M -9 -4 L 0 -24 L 9 -4 Z" fill="#f1d2a6" />
<path d="M -3 -17 L 0 -24 L 3 -17 Z" fill={farbe} />
<rect x={-11} y={-6} width={22} height={260} rx={4} fill={farbe} />
<rect x={-11} y={-6} width={7} height={260} fill="rgba(255,255,255,0.18)" />
<ellipse cx={-6} cy={150} rx={62} ry={56} fill="#f2c4a2" />
<ellipse cx={-40} cy={118} rx={20} ry={26} fill="#f2c4a2" />
<ellipse cx={30} cy={124} rx={22} ry={18} fill="#f2c4a2" />
<path d="M -70 175 L 60 175 L 110 900 L -150 900 Z" fill="#d39b3a" />
<path d="M -72 172 Q -6 196 62 172 L 66 200 Q -6 222 -76 200 Z" fill="#c1862c" />
</g>
</svg>
);
};
// Vorberechnete Bilder einbinden // 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}} />; export const Ort: React.FC<{name: string}> = ({name}) => <Img src={staticFile(`orte/${name}.webp`)} style={{position: 'absolute', inset: 0, width: B, height: H}} />;
// Kompositionen zum Vorberechnen // Kompositionen zum Vorberechnen
export const ORTE: Record<string, React.FC> = { export const ORTE: Record<string, React.FC> = {
@ -360,6 +453,7 @@ export const ORTE: Record<string, React.FC> = {
zimmer_wand: () => <ZimmerWandSVG />, zimmer_wand: () => <ZimmerWandSVG />,
zimmer_tisch: () => <TischSVG />, zimmer_tisch: () => <TischSVG />,
kalender: () => <KalenderSVG />, kalender: () => <KalenderSVG />,
kalender_leer: () => <KalenderSVG leer />,
}; };
export const OrtStill: React.FC<{name: string}> = ({name}) => { export const OrtStill: React.FC<{name: string}> = ({name}) => {
const K = ORTE[name]; const K = ORTE[name];

View file

@ -25,7 +25,7 @@ export const FIGUREN = {
export const POSEN: Record<string, Pose> = { export const POSEN: Record<string, Pose> = {
"pino_A1_augen-zu": { "pino_A1_augen-zu": {
"figur": "pino", "figur": "pino",
"datei": "folge02/figuren/pino_A1_augen-zu.webp", "datei": "bibliothek/figuren/pino_A1_augen-zu.webp",
"x": 391, "x": 391,
"y": 69, "y": 69,
"b": 623, "b": 623,
@ -39,7 +39,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"pino_A2_augen-halb": { "pino_A2_augen-halb": {
"figur": "pino", "figur": "pino",
"datei": "folge02/figuren/pino_A2_augen-halb.webp", "datei": "bibliothek/figuren/pino_A2_augen-halb.webp",
"x": 391, "x": 391,
"y": 69, "y": 69,
"b": 623, "b": 623,
@ -53,7 +53,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"pino_A3_lachen": { "pino_A3_lachen": {
"figur": "pino", "figur": "pino",
"datei": "folge02/figuren/pino_A3_lachen.webp", "datei": "bibliothek/figuren/pino_A3_lachen.webp",
"x": 391, "x": 391,
"y": 69, "y": 69,
"b": 623, "b": 623,
@ -67,7 +67,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"pino_A4_staunen": { "pino_A4_staunen": {
"figur": "pino", "figur": "pino",
"datei": "folge02/figuren/pino_A4_staunen.webp", "datei": "bibliothek/figuren/pino_A4_staunen.webp",
"x": 391, "x": 391,
"y": 69, "y": 69,
"b": 623, "b": 623,
@ -81,7 +81,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"pino_A5_blick-links": { "pino_A5_blick-links": {
"figur": "pino", "figur": "pino",
"datei": "folge02/figuren/pino_A5_blick-links.webp", "datei": "bibliothek/figuren/pino_A5_blick-links.webp",
"x": 297.5, "x": 297.5,
"y": 69, "y": 69,
"b": 623, "b": 623,
@ -95,7 +95,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"pino_A6_blick-rechts": { "pino_A6_blick-rechts": {
"figur": "pino", "figur": "pino",
"datei": "folge02/figuren/pino_A6_blick-rechts.webp", "datei": "bibliothek/figuren/pino_A6_blick-rechts.webp",
"x": 391, "x": 391,
"y": 69, "y": 69,
"b": 623, "b": 623,
@ -109,7 +109,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"pino_A7-1_gehen": { "pino_A7-1_gehen": {
"figur": "pino", "figur": "pino",
"datei": "folge02/figuren/pino_A7-1_gehen.webp", "datei": "bibliothek/figuren/pino_A7-1_gehen.webp",
"x": 391, "x": 391,
"y": 69, "y": 69,
"b": 623, "b": 623,
@ -123,7 +123,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"pino_A7-2_gehen": { "pino_A7-2_gehen": {
"figur": "pino", "figur": "pino",
"datei": "folge02/figuren/pino_A7-2_gehen.webp", "datei": "bibliothek/figuren/pino_A7-2_gehen.webp",
"x": 391, "x": 391,
"y": 69, "y": 69,
"b": 623, "b": 623,
@ -137,7 +137,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"pino_A7-3_gehen": { "pino_A7-3_gehen": {
"figur": "pino", "figur": "pino",
"datei": "folge02/figuren/pino_A7-3_gehen.webp", "datei": "bibliothek/figuren/pino_A7-3_gehen.webp",
"x": 391, "x": 391,
"y": 69, "y": 69,
"b": 623, "b": 623,
@ -151,7 +151,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"pino_A7-4_gehen": { "pino_A7-4_gehen": {
"figur": "pino", "figur": "pino",
"datei": "folge02/figuren/pino_A7-4_gehen.webp", "datei": "bibliothek/figuren/pino_A7-4_gehen.webp",
"x": 391, "x": 391,
"y": 69, "y": 69,
"b": 623, "b": 623,
@ -165,7 +165,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"pino_A8_rueckansicht": { "pino_A8_rueckansicht": {
"figur": "pino", "figur": "pino",
"datei": "folge02/figuren/pino_A8_rueckansicht.webp", "datei": "bibliothek/figuren/pino_A8_rueckansicht.webp",
"x": 391, "x": 391,
"y": 69, "y": 69,
"b": 623, "b": 623,
@ -179,7 +179,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"pino_A_hinterbeine": { "pino_A_hinterbeine": {
"figur": "pino", "figur": "pino",
"datei": "folge02/figuren/pino_A_hinterbeine.webp", "datei": "bibliothek/figuren/pino_A_hinterbeine.webp",
"x": 391, "x": 391,
"y": 69, "y": 69,
"b": 623, "b": 623,
@ -193,7 +193,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"pino_A_kugel": { "pino_A_kugel": {
"figur": "pino", "figur": "pino",
"datei": "folge02/figuren/pino_A_kugel.webp", "datei": "bibliothek/figuren/pino_A_kugel.webp",
"x": 391, "x": 391,
"y": 69, "y": 69,
"b": 623, "b": 623,
@ -207,7 +207,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"pino_A_laufen-1": { "pino_A_laufen-1": {
"figur": "pino", "figur": "pino",
"datei": "folge02/figuren/pino_A_laufen-1.webp", "datei": "bibliothek/figuren/pino_A_laufen-1.webp",
"x": 391, "x": 391,
"y": 69, "y": 69,
"b": 623, "b": 623,
@ -221,7 +221,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"pino_A_laufen-2": { "pino_A_laufen-2": {
"figur": "pino", "figur": "pino",
"datei": "folge02/figuren/pino_A_laufen-2.webp", "datei": "bibliothek/figuren/pino_A_laufen-2.webp",
"x": 391, "x": 391,
"y": 69, "y": 69,
"b": 623, "b": 623,
@ -235,7 +235,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"pino_A_laufen-3": { "pino_A_laufen-3": {
"figur": "pino", "figur": "pino",
"datei": "folge02/figuren/pino_A_laufen-3.webp", "datei": "bibliothek/figuren/pino_A_laufen-3.webp",
"x": 391, "x": 391,
"y": 69, "y": 69,
"b": 623, "b": 623,
@ -249,7 +249,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"pino_A_laufen-4": { "pino_A_laufen-4": {
"figur": "pino", "figur": "pino",
"datei": "folge02/figuren/pino_A_laufen-4.webp", "datei": "bibliothek/figuren/pino_A_laufen-4.webp",
"x": 391, "x": 391,
"y": 69, "y": 69,
"b": 623, "b": 623,
@ -263,7 +263,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"pino_B1_traurig": { "pino_B1_traurig": {
"figur": "pino", "figur": "pino",
"datei": "folge02/figuren/pino_B1_traurig.webp", "datei": "bibliothek/figuren/pino_B1_traurig.webp",
"x": 391, "x": 391,
"y": 69, "y": 69,
"b": 623, "b": 623,
@ -277,7 +277,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"pino_B2_erschrocken": { "pino_B2_erschrocken": {
"figur": "pino", "figur": "pino",
"datei": "folge02/figuren/pino_B2_erschrocken.webp", "datei": "bibliothek/figuren/pino_B2_erschrocken.webp",
"x": 391, "x": 391,
"y": 69, "y": 69,
"b": 623, "b": 623,
@ -291,7 +291,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"pino_B3_schlafen": { "pino_B3_schlafen": {
"figur": "pino", "figur": "pino",
"datei": "folge02/figuren/pino_B3_schlafen.webp", "datei": "bibliothek/figuren/pino_B3_schlafen.webp",
"x": 391, "x": 391,
"y": 69, "y": 69,
"b": 623, "b": 623,
@ -305,7 +305,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"pino_B_blick-nach-oben": { "pino_B_blick-nach-oben": {
"figur": "pino", "figur": "pino",
"datei": "folge02/figuren/pino_B_blick-nach-oben.webp", "datei": "bibliothek/figuren/pino_B_blick-nach-oben.webp",
"x": 391, "x": 391,
"y": 69, "y": 69,
"b": 623, "b": 623,
@ -319,7 +319,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"pino_B_schlaeft-eingerollt": { "pino_B_schlaeft-eingerollt": {
"figur": "pino", "figur": "pino",
"datei": "folge02/figuren/pino_B_schlaeft-eingerollt.webp", "datei": "bibliothek/figuren/pino_B_schlaeft-eingerollt.webp",
"x": 391, "x": 391,
"y": 69, "y": 69,
"b": 623, "b": 623,
@ -333,7 +333,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"pino_ref_sitzend": { "pino_ref_sitzend": {
"figur": "pino", "figur": "pino",
"datei": "folge02/figuren/pino_ref_sitzend.webp", "datei": "bibliothek/figuren/pino_ref_sitzend.webp",
"x": 391, "x": 391,
"y": 69, "y": 69,
"b": 623, "b": 623,
@ -347,7 +347,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"noa_A1_augen-zu": { "noa_A1_augen-zu": {
"figur": "noa", "figur": "noa",
"datei": "folge02/figuren/noa_A1_augen-zu.webp", "datei": "bibliothek/figuren/noa_A1_augen-zu.webp",
"x": 339, "x": 339,
"y": 156, "y": 156,
"b": 715, "b": 715,
@ -361,7 +361,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"noa_A2_augen-halb": { "noa_A2_augen-halb": {
"figur": "noa", "figur": "noa",
"datei": "folge02/figuren/noa_A2_augen-halb.webp", "datei": "bibliothek/figuren/noa_A2_augen-halb.webp",
"x": 339, "x": 339,
"y": 156, "y": 156,
"b": 715, "b": 715,
@ -375,7 +375,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"noa_A3_lachen": { "noa_A3_lachen": {
"figur": "noa", "figur": "noa",
"datei": "folge02/figuren/noa_A3_lachen.webp", "datei": "bibliothek/figuren/noa_A3_lachen.webp",
"x": 339, "x": 339,
"y": 156, "y": 156,
"b": 715, "b": 715,
@ -389,7 +389,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"noa_A4_staunen": { "noa_A4_staunen": {
"figur": "noa", "figur": "noa",
"datei": "folge02/figuren/noa_A4_staunen.webp", "datei": "bibliothek/figuren/noa_A4_staunen.webp",
"x": 339, "x": 339,
"y": 156, "y": 156,
"b": 715, "b": 715,
@ -403,7 +403,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"noa_A5_blick-links": { "noa_A5_blick-links": {
"figur": "noa", "figur": "noa",
"datei": "folge02/figuren/noa_A5_blick-links.webp", "datei": "bibliothek/figuren/noa_A5_blick-links.webp",
"x": 314.0, "x": 314.0,
"y": 156, "y": 156,
"b": 715, "b": 715,
@ -417,7 +417,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"noa_A6_blick-rechts": { "noa_A6_blick-rechts": {
"figur": "noa", "figur": "noa",
"datei": "folge02/figuren/noa_A6_blick-rechts.webp", "datei": "bibliothek/figuren/noa_A6_blick-rechts.webp",
"x": 339, "x": 339,
"y": 156, "y": 156,
"b": 715, "b": 715,
@ -431,7 +431,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"noa_A7-1_gehen": { "noa_A7-1_gehen": {
"figur": "noa", "figur": "noa",
"datei": "folge02/figuren/noa_A7-1_gehen.webp", "datei": "bibliothek/figuren/noa_A7-1_gehen.webp",
"x": 339, "x": 339,
"y": 156, "y": 156,
"b": 715, "b": 715,
@ -445,7 +445,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"noa_A7-2_gehen": { "noa_A7-2_gehen": {
"figur": "noa", "figur": "noa",
"datei": "folge02/figuren/noa_A7-2_gehen.webp", "datei": "bibliothek/figuren/noa_A7-2_gehen.webp",
"x": 339, "x": 339,
"y": 156, "y": 156,
"b": 715, "b": 715,
@ -459,7 +459,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"noa_A7-3_gehen": { "noa_A7-3_gehen": {
"figur": "noa", "figur": "noa",
"datei": "folge02/figuren/noa_A7-3_gehen.webp", "datei": "bibliothek/figuren/noa_A7-3_gehen.webp",
"x": 339, "x": 339,
"y": 156, "y": 156,
"b": 715, "b": 715,
@ -473,7 +473,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"noa_A7-4_gehen": { "noa_A7-4_gehen": {
"figur": "noa", "figur": "noa",
"datei": "folge02/figuren/noa_A7-4_gehen.webp", "datei": "bibliothek/figuren/noa_A7-4_gehen.webp",
"x": 339, "x": 339,
"y": 156, "y": 156,
"b": 715, "b": 715,
@ -487,7 +487,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"noa_A8_rueckansicht": { "noa_A8_rueckansicht": {
"figur": "noa", "figur": "noa",
"datei": "folge02/figuren/noa_A8_rueckansicht.webp", "datei": "bibliothek/figuren/noa_A8_rueckansicht.webp",
"x": 339, "x": 339,
"y": 156, "y": 156,
"b": 715, "b": 715,
@ -501,7 +501,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"noa_A_arm-hoch-winken": { "noa_A_arm-hoch-winken": {
"figur": "noa", "figur": "noa",
"datei": "folge02/figuren/noa_A_arm-hoch-winken.webp", "datei": "bibliothek/figuren/noa_A_arm-hoch-winken.webp",
"x": 339, "x": 339,
"y": 156, "y": 156,
"b": 715, "b": 715,
@ -515,7 +515,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"noa_A_arme-hoch": { "noa_A_arme-hoch": {
"figur": "noa", "figur": "noa",
"datei": "folge02/figuren/noa_A_arme-hoch.webp", "datei": "bibliothek/figuren/noa_A_arme-hoch.webp",
"x": 339, "x": 339,
"y": 156, "y": 156,
"b": 715, "b": 715,
@ -529,7 +529,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"noa_A_laterne": { "noa_A_laterne": {
"figur": "noa", "figur": "noa",
"datei": "folge02/figuren/noa_A_laterne.webp", "datei": "bibliothek/figuren/noa_A_laterne.webp",
"x": 339, "x": 339,
"y": 156, "y": 156,
"b": 715, "b": 715,
@ -543,7 +543,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"noa_B1_traurig": { "noa_B1_traurig": {
"figur": "noa", "figur": "noa",
"datei": "folge02/figuren/noa_B1_traurig.webp", "datei": "bibliothek/figuren/noa_B1_traurig.webp",
"x": 339, "x": 339,
"y": 156, "y": 156,
"b": 715, "b": 715,
@ -557,7 +557,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"noa_B2_erschrocken": { "noa_B2_erschrocken": {
"figur": "noa", "figur": "noa",
"datei": "folge02/figuren/noa_B2_erschrocken.webp", "datei": "bibliothek/figuren/noa_B2_erschrocken.webp",
"x": 339, "x": 339,
"y": 156, "y": 156,
"b": 715, "b": 715,
@ -571,7 +571,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"noa_B3_schlafen": { "noa_B3_schlafen": {
"figur": "noa", "figur": "noa",
"datei": "folge02/figuren/noa_B3_schlafen.webp", "datei": "bibliothek/figuren/noa_B3_schlafen.webp",
"x": 339, "x": 339,
"y": 156, "y": 156,
"b": 715, "b": 715,
@ -585,7 +585,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"noa_B_hocken": { "noa_B_hocken": {
"figur": "noa", "figur": "noa",
"datei": "folge02/figuren/noa_B_hocken.webp", "datei": "bibliothek/figuren/noa_B_hocken.webp",
"x": 339, "x": 339,
"y": 156, "y": 156,
"b": 715, "b": 715,
@ -599,7 +599,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"noa_B_sitzt": { "noa_B_sitzt": {
"figur": "noa", "figur": "noa",
"datei": "folge02/figuren/noa_B_sitzt.webp", "datei": "bibliothek/figuren/noa_B_sitzt.webp",
"x": 339, "x": 339,
"y": 156, "y": 156,
"b": 715, "b": 715,
@ -613,7 +613,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"noa_B_zeigen": { "noa_B_zeigen": {
"figur": "noa", "figur": "noa",
"datei": "folge02/figuren/noa_B_zeigen.webp", "datei": "bibliothek/figuren/noa_B_zeigen.webp",
"x": 339, "x": 339,
"y": 156, "y": 156,
"b": 715, "b": 715,
@ -627,7 +627,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"noa_ref_stehend": { "noa_ref_stehend": {
"figur": "noa", "figur": "noa",
"datei": "folge02/figuren/noa_ref_stehend.webp", "datei": "bibliothek/figuren/noa_ref_stehend.webp",
"x": 339, "x": 339,
"y": 156, "y": 156,
"b": 715, "b": 715,
@ -641,7 +641,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"lulu_A1_augen-zu": { "lulu_A1_augen-zu": {
"figur": "lulu", "figur": "lulu",
"datei": "folge02/figuren/lulu_A1_augen-zu.webp", "datei": "bibliothek/figuren/lulu_A1_augen-zu.webp",
"x": 322, "x": 322,
"y": 23, "y": 23,
"b": 698, "b": 698,
@ -655,7 +655,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"lulu_A2_augen-halb": { "lulu_A2_augen-halb": {
"figur": "lulu", "figur": "lulu",
"datei": "folge02/figuren/lulu_A2_augen-halb.webp", "datei": "bibliothek/figuren/lulu_A2_augen-halb.webp",
"x": 322, "x": 322,
"y": 23, "y": 23,
"b": 698, "b": 698,
@ -669,7 +669,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"lulu_A3_lachen": { "lulu_A3_lachen": {
"figur": "lulu", "figur": "lulu",
"datei": "folge02/figuren/lulu_A3_lachen.webp", "datei": "bibliothek/figuren/lulu_A3_lachen.webp",
"x": 322, "x": 322,
"y": 23, "y": 23,
"b": 698, "b": 698,
@ -683,7 +683,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"lulu_A4_staunen": { "lulu_A4_staunen": {
"figur": "lulu", "figur": "lulu",
"datei": "folge02/figuren/lulu_A4_staunen.webp", "datei": "bibliothek/figuren/lulu_A4_staunen.webp",
"x": 322, "x": 322,
"y": 23, "y": 23,
"b": 698, "b": 698,
@ -697,7 +697,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"lulu_A5_blick-links": { "lulu_A5_blick-links": {
"figur": "lulu", "figur": "lulu",
"datei": "folge02/figuren/lulu_A5_blick-links.webp", "datei": "bibliothek/figuren/lulu_A5_blick-links.webp",
"x": 373.5, "x": 373.5,
"y": 23, "y": 23,
"b": 698, "b": 698,
@ -711,7 +711,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"lulu_A6_blick-rechts": { "lulu_A6_blick-rechts": {
"figur": "lulu", "figur": "lulu",
"datei": "folge02/figuren/lulu_A6_blick-rechts.webp", "datei": "bibliothek/figuren/lulu_A6_blick-rechts.webp",
"x": 322, "x": 322,
"y": 23, "y": 23,
"b": 698, "b": 698,
@ -725,7 +725,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"lulu_A7-1_gehen": { "lulu_A7-1_gehen": {
"figur": "lulu", "figur": "lulu",
"datei": "folge02/figuren/lulu_A7-1_gehen.webp", "datei": "bibliothek/figuren/lulu_A7-1_gehen.webp",
"x": 322, "x": 322,
"y": 23, "y": 23,
"b": 698, "b": 698,
@ -739,7 +739,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"lulu_A7-2_gehen": { "lulu_A7-2_gehen": {
"figur": "lulu", "figur": "lulu",
"datei": "folge02/figuren/lulu_A7-2_gehen.webp", "datei": "bibliothek/figuren/lulu_A7-2_gehen.webp",
"x": 322, "x": 322,
"y": 23, "y": 23,
"b": 698, "b": 698,
@ -753,7 +753,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"lulu_A7-3_gehen": { "lulu_A7-3_gehen": {
"figur": "lulu", "figur": "lulu",
"datei": "folge02/figuren/lulu_A7-3_gehen.webp", "datei": "bibliothek/figuren/lulu_A7-3_gehen.webp",
"x": 322, "x": 322,
"y": 23, "y": 23,
"b": 698, "b": 698,
@ -767,7 +767,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"lulu_A7-4_gehen": { "lulu_A7-4_gehen": {
"figur": "lulu", "figur": "lulu",
"datei": "folge02/figuren/lulu_A7-4_gehen.webp", "datei": "bibliothek/figuren/lulu_A7-4_gehen.webp",
"x": 322, "x": 322,
"y": 23, "y": 23,
"b": 698, "b": 698,
@ -781,7 +781,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"lulu_A8_rueckansicht": { "lulu_A8_rueckansicht": {
"figur": "lulu", "figur": "lulu",
"datei": "folge02/figuren/lulu_A8_rueckansicht.webp", "datei": "bibliothek/figuren/lulu_A8_rueckansicht.webp",
"x": 322, "x": 322,
"y": 23, "y": 23,
"b": 698, "b": 698,
@ -795,7 +795,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"lulu_A_apfel": { "lulu_A_apfel": {
"figur": "lulu", "figur": "lulu",
"datei": "folge02/figuren/lulu_A_apfel.webp", "datei": "bibliothek/figuren/lulu_A_apfel.webp",
"x": 322, "x": 322,
"y": 23, "y": 23,
"b": 698, "b": 698,
@ -809,7 +809,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"lulu_A_hals-unten": { "lulu_A_hals-unten": {
"figur": "lulu", "figur": "lulu",
"datei": "folge02/figuren/lulu_A_hals-unten.webp", "datei": "bibliothek/figuren/lulu_A_hals-unten.webp",
"x": 322, "x": 322,
"y": 23, "y": 23,
"b": 698, "b": 698,
@ -823,7 +823,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"lulu_B1_traurig": { "lulu_B1_traurig": {
"figur": "lulu", "figur": "lulu",
"datei": "folge02/figuren/lulu_B1_traurig.webp", "datei": "bibliothek/figuren/lulu_B1_traurig.webp",
"x": 322, "x": 322,
"y": 23, "y": 23,
"b": 698, "b": 698,
@ -837,7 +837,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"lulu_B2_erschrocken": { "lulu_B2_erschrocken": {
"figur": "lulu", "figur": "lulu",
"datei": "folge02/figuren/lulu_B2_erschrocken.webp", "datei": "bibliothek/figuren/lulu_B2_erschrocken.webp",
"x": 322, "x": 322,
"y": 23, "y": 23,
"b": 698, "b": 698,
@ -851,7 +851,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"lulu_B3_schlafen": { "lulu_B3_schlafen": {
"figur": "lulu", "figur": "lulu",
"datei": "folge02/figuren/lulu_B3_schlafen.webp", "datei": "bibliothek/figuren/lulu_B3_schlafen.webp",
"x": 322, "x": 322,
"y": 23, "y": 23,
"b": 698, "b": 698,
@ -865,7 +865,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"lulu_B_kopf-profil": { "lulu_B_kopf-profil": {
"figur": "lulu", "figur": "lulu",
"datei": "folge02/figuren/lulu_B_kopf-profil.webp", "datei": "bibliothek/figuren/lulu_B_kopf-profil.webp",
"x": 322, "x": 322,
"y": 23, "y": 23,
"b": 698, "b": 698,
@ -879,7 +879,7 @@ export const POSEN: Record<string, Pose> = {
}, },
"lulu_B_liegt": { "lulu_B_liegt": {
"figur": "lulu", "figur": "lulu",
"datei": "folge02/figuren/lulu_B_liegt.webp", "datei": "bibliothek/figuren/lulu_B_liegt.webp",
"x": 322, "x": 322,
"y": 23, "y": 23,
"b": 698, "b": 698,
@ -896,7 +896,7 @@ export const POSEN: Record<string, Pose> = {
export const CLIPS: Record<string, ClipDaten> = { export const CLIPS: Record<string, ClipDaten> = {
"lulu_C9_hals-runter": { "lulu_C9_hals-runter": {
"figur": "lulu", "figur": "lulu",
"datei": "folge02/clips/lulu_C9_hals-runter.webm", "datei": "bibliothek/clips/lulu_C9_hals-runter.webm",
"x": 294.70404984423675, "x": 294.70404984423675,
"y": 24.64797507788162, "y": 24.64797507788162,
"b": 709.4330218068535, "b": 709.4330218068535,
@ -905,7 +905,7 @@ export const CLIPS: Record<string, ClipDaten> = {
}, },
"noa_C4_winken": { "noa_C4_winken": {
"figur": "noa", "figur": "noa",
"datei": "folge02/clips/noa_C4_winken.webm", "datei": "bibliothek/clips/noa_C4_winken.webm",
"x": 529.3956386292834, "x": 529.3956386292834,
"y": 158.6043613707165, "y": 158.6043613707165,
"b": 413.6573208722741, "b": 413.6573208722741,
@ -914,7 +914,7 @@ export const CLIPS: Record<string, ClipDaten> = {
}, },
"pino_C3_freudensprung": { "pino_C3_freudensprung": {
"figur": "pino", "figur": "pino",
"datei": "folge02/clips/pino_C3_freudensprung.webm", "datei": "bibliothek/clips/pino_C3_freudensprung.webm",
"x": 382.5794392523364, "x": 382.5794392523364,
"y": 0.0, "y": 0.0,
"b": 512.2492211838006, "b": 512.2492211838006,
@ -923,7 +923,7 @@ export const CLIPS: Record<string, ClipDaten> = {
}, },
"pino_C7_einrollen": { "pino_C7_einrollen": {
"figur": "pino", "figur": "pino",
"datei": "folge02/clips/pino_C7_einrollen.webm", "datei": "bibliothek/clips/pino_C7_einrollen.webm",
"x": 390.08099688473516, "x": 390.08099688473516,
"y": 101.80685358255451, "y": 101.80685358255451,
"b": 552.9719626168223, "b": 552.9719626168223,
@ -932,7 +932,7 @@ export const CLIPS: Record<string, ClipDaten> = {
}, },
"pino_C8_aufrichten": { "pino_C8_aufrichten": {
"figur": "pino", "figur": "pino",
"datei": "folge02/clips/pino_C8_aufrichten.webm", "datei": "bibliothek/clips/pino_C8_aufrichten.webm",
"x": 387.9376947040498, "x": 387.9376947040498,
"y": 62.155763239875384, "y": 62.155763239875384,
"b": 563.6884735202492, "b": 563.6884735202492,

View file

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import {interpolate, useCurrentFrame} from 'remotion'; import {Audio, Sequence, getInputProps, getStaticFiles, interpolate, staticFile, useCurrentFrame} from 'remotion';
import {loadFont} from '@remotion/google-fonts/Fredoka'; import {loadFont} from '@remotion/google-fonts/Fredoka';
import type {Geplant, Sprecher} from './zeit'; import type {Geplant, Sprecher} from './zeit';
@ -22,13 +22,33 @@ const FARBE: Record<Sprecher, string> = {
NOA_LULU: '#a0526b', NOA_LULU: '#a0526b',
}; };
// Untertitel für die Vorschau ohne Ton. Mit Sprachaufnahmen können sie aus. // Vorhandene Dateien in public/ (zum Bündeln bekannt). Fehlt eine Aufnahme
// auf diesem Rechner, läuft die Szene ohne sie statt mit einem Fehler.
const VORHANDEN = new Set(getStaticFiles().map((d) => d.name));
// Sprachaufnahmen der Zeilen abspielen
export const Stimmen: React.FC<{zeilen: Geplant[]}> = ({zeilen}) => (
<>
{zeilen.map((l) =>
l.audio && VORHANDEN.has(l.audio) ? (
<Sequence key={l.id} from={l.start} durationInFrames={l.ende - l.start + 30} layout="none">
<Audio src={staticFile(l.audio)} />
</Sequence>
) : null,
)}
</>
);
// Untertitel (für die Vorschau ohne Ton) und Stimmen. Untertitel lassen sich
// beim Rendern abschalten: --props='{"untertitel": false}'
export const Untertitel: React.FC<{zeilen: Geplant[]}> = ({zeilen}) => { export const Untertitel: React.FC<{zeilen: Geplant[]}> = ({zeilen}) => {
const frame = useCurrentFrame(); const frame = useCurrentFrame();
if (getInputProps().untertitel === false) return <Stimmen zeilen={zeilen} />;
const z = zeilen.find((l) => l.text && frame >= l.start && frame < l.ende + 6); 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); const frage = zeilen.find((l) => (l.pause ?? 0) >= 2.5 && frame >= l.ende && frame < l.frei);
return ( return (
<> <>
<Stimmen zeilen={zeilen} />
{z && z.wer && ( {z && z.wer && (
<div <div
style={{ style={{

View file

@ -0,0 +1,65 @@
// Zeitplan einer Szene aus ihren Sprechzeilen.
//
// Ohne Sprachaufnahme wird die Dauer jeder Zeile aus der Textlänge geschätzt.
// Gibt es eine Aufnahme (werkzeuge/stimmen.ts schreibt sie in die
// stimmen.json der Folge), gilt ihre echte Länge, und alle Bewegungen, die
// 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
};
// Aufnahmen einer Folge: Schlüssel „szene/zeile“
export type Aufnahme = {datei: string; dauer: number; text: string};
export type Aufnahmen = Record<string, Aufnahme>;
export type Geplant = Zeile & {start: number; ende: number; frei: number; audio?: string};
export type PlanOptionen = {
vorlauf?: number; // Sekunden vor der ersten Zeile
nachlauf?: number; // Sekunden nach der letzten Zeile
szene?: string; // Schlüssel der Szene für die Aufnahmen
aufnahmen?: Aufnahmen;
ordner?: string; // Ordner der Aufnahmen in public/
};
const schaetze = (z: Zeile) => {
if (z.dauer !== undefined) return z.dauer;
if (!z.text) return 1;
// Kinderfernsehen: ruhig gesprochen, ca. 13–14 Zeichen pro Sekunde
const tempo = z.wer === 'ERZ' ? 13 : 14;
return 0.6 + z.text.length / tempo;
};
// Lücke zwischen zwei Zeilen
const LUECKE = 0.35;
export const plane = (zeilen: Zeile[], {vorlauf = 0.5, nachlauf = 0.8, szene, aufnahmen, ordner}: PlanOptionen = {}) => {
let t = vorlauf * FPS;
const liste: Geplant[] = [];
for (const z of zeilen) {
// Aufnahme nur verwenden, wenn sie zum aktuellen Text passt
const a = szene && z.text ? aufnahmen?.[`${szene}/${z.id}`] : undefined;
const passt = a && a.text === z.text;
const d = Math.round((passt ? a.dauer + 0.15 : schaetze(z)) * FPS);
const start = Math.round(t);
const ende = start + d;
const frei = ende + Math.round((z.pause ?? 0) * FPS);
liste.push({...z, start, ende, frei, audio: passt && ordner ? `${ordner}/${a.datei}` : undefined});
t = frei + LUECKE * FPS;
}
const dauer = Math.round(t + nachlauf * FPS);
const z = Object.fromEntries(liste.map((l) => [l.id, l])) as Record<string, Geplant>;
return {liste, z, dauer, szene};
};
export type Plan = ReturnType<typeof plane>;

View file

@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import {AbsoluteFill, Series} from 'remotion'; import {INTRO_OPTIONEN, Intro, OUTRO, Outro, introZeilen} from '../baukasten/buch';
import {INTRO, Intro, OUTRO, Outro} from './buch'; import {baueFolge, type SzenenEintrag} from '../baukasten/folge';
import {LIED, Lied} from './lied'; import {LIED, Lied} from './lied';
import {plane} from './plan';
import {S2, Szene2} from './szene2'; import {S2, Szene2} from './szene2';
import {S3, Szene3} from './szene3'; import {S3, Szene3} from './szene3';
import {S4, Szene4} from './szene4'; import {S4, Szene4} from './szene4';
@ -9,31 +10,21 @@ import {S5, Szene5} from './szene5';
import {S6, Szene6} from './szene6'; import {S6, Szene6} from './szene6';
import {S7, Szene7} from './szene7'; import {S7, Szene7} from './szene7';
// Folge 2 „Pino ist müde“ – komplett per Code aus der Figuren-Bibliothek, // 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}[] = [ const TITEL = 'Pino ist müde';
{name: 'Intro', dauer: INTRO.dauer, K: Intro}, const INTRO = plane('intro', introZeilen(TITEL), INTRO_OPTIONEN);
{name: 'Gähnen überall', dauer: S2.dauer, K: Szene2},
{name: 'Lulu sieht es von oben', dauer: S3.dauer, K: Szene3}, export const SZENEN: SzenenEintrag[] = [
{name: 'Ein Bett aus Blättern', dauer: S4.dauer, K: Szene4}, {name: 'Intro', datei: 'Folge02.tsx', plan: INTRO, K: () => <Intro plan={INTRO} titel={TITEL} posen={['pino_A2_augen-halb', 'noa_A3_lachen', 'lulu_A3_lachen']} />},
{name: 'Lied', dauer: LIED.dauer, K: Lied}, {name: 'Gähnen überall', datei: 'szene2.tsx', plan: S2, K: Szene2},
{name: 'Pino will nicht schlafen', dauer: S5.dauer, K: Szene5}, {name: 'Lulu sieht es von oben', datei: 'szene3.tsx', plan: S3, K: Szene3},
{name: 'Noas Idee', dauer: S6.dauer, K: Szene6}, {name: 'Ein Bett aus Blättern', datei: 'szene4.tsx', plan: S4, K: Szene4},
{name: 'Gute Nacht, Pino', dauer: S7.dauer, K: Szene7}, {name: 'Lied', datei: 'lied.tsx', plan: LIED, K: Lied},
{name: 'Outro', dauer: OUTRO.dauer, K: Outro}, {name: 'Pino will nicht schlafen', datei: 'szene5.tsx', plan: S5, K: Szene5},
{name: 'Noas Idee', datei: 'szene6.tsx', plan: S6, K: Szene6},
{name: 'Gute Nacht, Pino', datei: 'szene7.tsx', plan: S7, K: Szene7},
{name: 'Outro', datei: 'Folge02.tsx', plan: OUTRO, K: () => <Outro />},
]; ];
export const FOLGE02_DAUER = SZENEN.reduce((s, x) => s + x.dauer, 0); export const F02 = baueFolge('F02', SZENEN);
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

@ -1,25 +1,23 @@
import React from 'react'; import React from 'react';
import {random, useCurrentFrame} from 'remotion'; import {random, useCurrentFrame} from 'remotion';
import {Figur, Kamera} from './figur'; import {Figur, Kamera} from '../baukasten/figur';
import {Blende, Vignette} from './helfer'; import {Blende, Vignette} from '../baukasten/helfer';
import {Ort} from './orte'; import {BODEN, Ort} from '../baukasten/orte';
import {BODEN} from './szene2'; import {SCHRIFT, Untertitel} from '../baukasten/untertitel';
import {SCHRIFT, Untertitel} from './untertitel'; import {plane} from './plan';
import {plane} from './zeit';
// Refrain „Einer klein und einer groß“ (aus Folge 1), einmal. Die drei wippen im Takt. // Refrain „Einer klein und einer groß“ (aus Folge 1), einmal. Die drei wippen im Takt.
const TAKT = 19; // Frames pro Schlag (ca. 95 bpm) const TAKT = 19; // Frames pro Schlag (ca. 95 bpm)
export const LIED = plane( export const LIED = plane('lied',
[ [
{id: 'r1', wer: 'ALLE', text: 'Einer klein und einer groß,', dauer: 3.2}, {id: '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: '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: 'r3', wer: 'ALLE', text: 'Noa steht in der Mitte drin,', dauer: 3.2},
{id: 'r4', wer: 'ALLE', text: 'zusammen kriegen wir alles hin!', dauer: 3.6}, {id: 'r4', wer: 'ALLE', text: 'zusammen kriegen wir alles hin!', dauer: 3.6},
], ],
1.0, {vorlauf: 1.0, nachlauf: 1.0},
1.0,
); );
const Note: React.FC<{x: number; y: number; s: number; farbe: string}> = ({x, y, s, farbe}) => ( const Note: React.FC<{x: number; y: number; s: number; farbe: string}> = ({x, y, s, farbe}) => (

View file

@ -0,0 +1,7 @@
import {plane as basis, type PlanOptionen, type Zeile} from '../baukasten/zeit';
import AUFNAHMEN from './stimmen.json';
// Zeitplan einer Szene dieser Folge; nimmt vorhandene Sprachaufnahmen
// (public/stimmen/folge02/, erzeugt von werkzeuge/stimmen.ts) automatisch mit.
export const plane = (szene: string, zeilen: Zeile[], opt: PlanOptionen = {}) =>
basis(zeilen, {...opt, szene, aufnahmen: AUFNAHMEN, ordner: 'stimmen/folge02'});

View file

@ -0,0 +1 @@
{}

View file

@ -1,20 +1,19 @@
import React from 'react'; import React from 'react';
import {useCurrentFrame} from 'remotion'; import {useCurrentFrame} from 'remotion';
import {kurve} from '../bewegung'; import {kurve} from '../bewegung';
import {Figur, Kamera} from './figur'; import {Figur, Geher, Kamera} from '../baukasten/figur';
import {Blende, Vignette, atmen, gehPose, gehWippen, hopfStauch, huepfer, kamFahrt, ruck} from './helfer'; import {Blende, Vignette, atmen, hopfStauch, huepfer, kamFahrt, ruck} from '../baukasten/helfer';
import {Blaetterwind, Zzz} from './natur'; import {Blaetterwind, Zzz} from '../baukasten/natur';
import {Ort} from './orte'; import {BODEN, Ort} from '../baukasten/orte';
import {Untertitel} from './untertitel'; import {Untertitel} from '../baukasten/untertitel';
import {plane} from './zeit'; import {plane} from './plan';
// Szene 2: Gähnen überall. Noas Garten am grauen Morgen. // Szene 2: Gähnen überall. Noas Garten am grauen Morgen.
export const BODEN = 935;
export const NOA_X = 760; export const NOA_X = 760;
export const PINO_X = 1060; export const PINO_X = 1060;
export const S2 = plane([ export const S2 = plane('s2', [
{id: 'bild0', dauer: 2.5}, {id: 'bild0', dauer: 2.5},
{id: 'e1', wer: 'ERZ', text: 'Es ist kalt geworden in Noas Garten.'}, {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: 'e2', wer: 'ERZ', text: 'Die Bäume haben fast alle Blätter verloren.'},
@ -71,7 +70,7 @@ export const Szene2: React.FC = () => {
atmen(f, 1, sinken > 0.5 ? 0.02 : 0.008) * atmen(f, 1, sinken > 0.5 ? 0.02 : 0.008) *
hopfStauch(f, schreck); hopfStauch(f, schreck);
const pinoDreh = sinken * 5 * (1 + 0.25 * Math.sin(f * 0.06)) + ruck(f, schreck, 4); const pinoDreh = sinken * 5 * (1 + 0.25 * Math.sin(f * 0.06)) + ruck(f, schreck, 4);
const pinoY = BODEN + huepfer(f, schreck, 36, 12) + (pinoGeht ? gehWippen(f) : 0); const pinoY = BODEN + huepfer(f, schreck, 36, 12);
// Noa kommt von links // Noa kommt von links
const noaAn = z.n1.start - 4; const noaAn = z.n1.start - 4;
@ -95,7 +94,7 @@ export const Szene2: React.FC = () => {
]; ];
// Kichern: schnelles kleines Hüpfen // 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 kichern = f > z.bild2.start + 15 && f < z.bild2.ende ? -Math.abs(Math.sin(f * 0.5)) * 6 : 0;
const noaY = BODEN + (noaGeht ? gehWippen(f) : 0) + kichern + huepfer(f, z.n1.start, 18, 10); const noaY = BODEN + kichern + huepfer(f, z.n1.start, 18, 10);
const kam = kamFahrt(f, [ const kam = kamFahrt(f, [
[0, 960, 540, 1.02], [0, 960, 540, 1.02],
@ -115,8 +114,8 @@ export const Szene2: React.FC = () => {
<> <>
<Kamera {...kam}> <Kamera {...kam}>
<Ort name="garten" /> <Ort name="garten" />
<Figur pose={noaGeht ? gehPose('noa', f) : noaPose} spiegeln={noaGeht} x={noaX} y={noaY} stauchY={atmen(f, 2)} /> {noaGeht ? <Geher figur="noa" spiegeln x={noaX} y={BODEN} /> : <Figur pose={noaPose} 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} /> {pinoGeht ? <Geher figur="pino" x={pinoX} y={BODEN} tempo={1.3} /> : <Figur pose={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} /> <Zzz x={PINO_X + 40} y={BODEN - 150} start={z.p2.ende} ende={schreck} s={0.7} />
</Kamera> </Kamera>
<Blaetterwind anzahl={6} /> <Blaetterwind anzahl={6} />

View file

@ -1,19 +1,19 @@
import React from 'react'; import React from 'react';
import {useCurrentFrame} from 'remotion'; import {useCurrentFrame} from 'remotion';
import {kurve} from '../bewegung'; import {kurve} from '../bewegung';
import {ClipFigur, Figur, Kamera} from './figur'; import {ClipFigur, Figur, Geher, Kamera, NoaHocke} from '../baukasten/figur';
import {Blende, Vignette, atmen, gehPose, gehWippen, hopfStauch, huepfer, kamFahrt} from './helfer'; import {Blende, Vignette, atmen, hopfStauch, huepfer, kamFahrt} from '../baukasten/helfer';
import {Blaetterwind, Blatt, Voegel} from './natur'; import {Blaetterwind, Blatt, Voegel} from '../baukasten/natur';
import {Ort} from './orte'; import {BODEN, Ort} from '../baukasten/orte';
import {BODEN, NOA_X, PINO_X} from './szene2'; import {NOA_X, PINO_X} from './szene2';
import {Untertitel} from './untertitel'; import {Untertitel} from '../baukasten/untertitel';
import {plane} from './zeit'; import {plane} from './plan';
// Szene 3: Lulu sieht es von oben. Winterschlaf wird erklärt, Pino ist traurig. // Szene 3: Lulu sieht es von oben. Winterschlaf wird erklärt, Pino ist traurig.
export const LULU_X = 1520; export const LULU_X = 1520;
export const S3 = plane([ export const S3 = plane('s3', [
{id: 'bild0', dauer: 3.2}, {id: 'bild0', dauer: 3.2},
{id: 'bild1', dauer: 2.4}, {id: 'bild1', dauer: 2.4},
{id: 'l1', wer: 'LULU', text: 'Guten Morgen, ihr zwei!'}, {id: 'l1', wer: 'LULU', text: 'Guten Morgen, ihr zwei!'},
@ -55,7 +55,7 @@ export const Szene3: React.FC = () => {
let lulu: React.ReactNode; let lulu: React.ReactNode;
const luluY = BODEN - 10; const luluY = BODEN - 10;
if (luluGeht) { if (luluGeht) {
lulu = <Figur pose={gehPose('lulu', f, 5)} x={luluX} y={luluY + gehWippen(f, 5, 8)} />; lulu = <Geher figur="lulu" x={luluX} y={luluY} />;
} else if (f < halsHoch) { } else if (f < halsHoch) {
lulu = <ClipFigur name="lulu_C9_hals-runter" start={halsRunter} tempo={1.25} x={LULU_X} y={luluY} />; lulu = <ClipFigur name="lulu_C9_hals-runter" start={halsRunter} tempo={1.25} x={LULU_X} y={luluY} />;
} else if (f < halsHoch + 110) { } else if (f < halsHoch + 110) {
@ -82,6 +82,13 @@ export const Szene3: React.FC = () => {
[aufsBlatt, PINO_X], [aufsBlatt, PINO_X],
[aufsBlatt + 24, GROSSES_BLATT_X], [aufsBlatt + 24, GROSSES_BLATT_X],
]); ]);
// Das große Blatt segelt vom Apfelbaum herunter und landet neben Pino
const blattFaellt = aufsBlatt - 80;
const u = Math.min(1, Math.max(0, (f - blattFaellt) / 70));
const schaukeln = Math.sin(u * 8) * (1 - u);
const blattX = GROSSES_BLATT_X - 180 * (1 - u) + schaukeln * 70;
const blattY = BODEN - 8 - 560 * (1 - u);
const blattR = 88 + schaukeln * 45;
const traurig = kurve(f, [ const traurig = kurve(f, [
[aufsBlatt + 20, 0], [aufsBlatt + 20, 0],
[aufsBlatt + 50, 1], [aufsBlatt + 50, 1],
@ -109,8 +116,8 @@ export const Szene3: React.FC = () => {
// Noa kniet sich zu Pino // Noa kniet sich zu Pino
const knien = z.bild3.start + 10; const knien = z.bild3.start + 10;
const noaX = kurve(f, [ const noaX = kurve(f, [
[knien, NOA_X + 20], [knien - 36, NOA_X + 20],
[knien + 30, NOA_X + 140], [knien, NOA_X + 140],
]); ]);
const noaPose: [number, string][] = [ const noaPose: [number, string][] = [
[0, 'noa_A6_blick-rechts'], [0, 'noa_A6_blick-rechts'],
@ -118,13 +125,8 @@ export const Szene3: React.FC = () => {
[z.l1.start + 15, 'noa_A3_lachen'], [z.l1.start + 15, 'noa_A3_lachen'],
[z.e1.start, 'noa_A6_blick-rechts'], [z.e1.start, 'noa_A6_blick-rechts'],
[z.bild2.start + 30, 'noa_B1_traurig'], [z.bild2.start + 30, 'noa_B1_traurig'],
[knien + 18, 'noa_B_hocken'],
]; ];
const noaStauch = kurve(f, [ const noaGeht = f >= knien - 36 && f < knien;
[knien + 8, 1],
[knien + 18, 0.94],
[knien + 30, 1],
]);
const kam = kamFahrt(f, [ const kam = kamFahrt(f, [
[0, 1150, 820, 1.7], [0, 1150, 820, 1.7],
@ -147,13 +149,13 @@ export const Szene3: React.FC = () => {
{imOben ? ( {imOben ? (
<Kamera x={kurve(f, [[obenStart, 900], [obenEnde, 1060]])} y={540} zoom={1.05}> <Kamera x={kurve(f, [[obenStart, 900], [obenEnde, 1060]])} y={540} zoom={1.05}>
<Ort name="oben" /> <Ort name="oben" />
<Voegel start={obenStart - 40} skala={1.9} /> <Voegel start={obenStart - 40} skala={1.6} />
</Kamera> </Kamera>
) : ( ) : (
<Kamera {...kam}> <Kamera {...kam}>
<Ort name="garten" /> <Ort name="garten" />
<Figur pose={noaPose} x={noaX} y={BODEN} stauchY={atmen(f, 2) * noaStauch} /> {noaGeht ? <Geher figur="noa" spiegeln x={noaX} y={BODEN} /> : f >= knien ? <NoaHocke start={knien} x={noaX} y={BODEN} /> : <Figur pose={noaPose} x={noaX} y={BODEN} stauchY={atmen(f, 2)} />}
{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)} />} {f > blattFaellt && <Blatt x={blattX} y={blattY} r={blattR} s={2.6} farbe="#c8642c" />}
<Figur pose={pinoPose} x={pinoX} y={pinoY} stauchY={pinoStauchY} drehung={pinoDreh} blende={6} /> <Figur pose={pinoPose} x={pinoX} y={pinoY} stauchY={pinoStauchY} drehung={pinoDreh} blende={6} />
{lulu} {lulu}
</Kamera> </Kamera>

View file

@ -1,23 +1,22 @@
import React from 'react'; import React from 'react';
import {useCurrentFrame} from 'remotion'; import {useCurrentFrame} from 'remotion';
import {kurve} from '../bewegung'; import {kurve} from '../bewegung';
import {Figur, Kamera} from './figur'; import {Figur, Geher, Kamera, NOA_HOCKE_FRAMES, NoaHocke} from '../baukasten/figur';
import {Blende, LULU_MAUL_LINKS, Vignette, atmen, gehPose, gehWippen, hopfStauch, huepfer, kamFahrt, punkt, ruck} from './helfer'; import {Blende, LULU_MAUL_LINKS, Vignette, atmen, hopfStauch, huepfer, kamFahrt, punkt, ruck} from '../baukasten/helfer';
import {Blaetterregen, Blatt, LAUB, Nest, Ruf} from './natur'; import {Blaetterregen, Blatt, LAUB, Nest, Ruf} from '../baukasten/natur';
import {Ort} from './orte'; import {BODEN, NEST_X, Ort} from '../baukasten/orte';
import {BODEN, NOA_X, PINO_X} from './szene2'; import {NOA_X, PINO_X} from './szene2';
import {Untertitel} from './untertitel'; import {Untertitel} from '../baukasten/untertitel';
import {plane} from './zeit'; import {plane} from './plan';
// Szene 4: Ein Bett aus Blättern. Zählen bis 5, Lulu niest, das Nest entsteht. // 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 NOA_ZAEHL_X = 1170;
const PINO_ZAEHL_X = 1790; const PINO_ZAEHL_X = 1790;
const LULU_BAUM_X = 650; const LULU_BAUM_X = 650;
const REIHE = [0, 1, 2, 3, 4].map((i) => ({x: 1290 + i * 78, y: BODEN + 4})); const REIHE = [0, 1, 2, 3, 4].map((i) => ({x: 1290 + i * 78, y: BODEN + 4}));
export const S4 = plane([ export const S4 = plane('s4', [
{id: 'n1', wer: 'NOA', text: 'Wenn du so müde bist, brauchst du ein warmes Bett.'}, {id: '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: '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: 'e2', wer: 'ERZ', text: 'Aus ganz vielen Blättern, unter einer Hecke oder einem Holzstapel.'},
@ -60,16 +59,24 @@ export const Szene4: React.FC = () => {
]); ]);
const noaGeht = f > noaGehtVon && f < noaGehtBis; const noaGeht = f > noaGehtVon && f < noaGehtBis;
const noaPose: [number, string][] = [ const noaPose: [number, string][] = [
[0, 'noa_B_hocken'], [0, 'noa_A3_lachen'],
[z.n1.start + 30, 'noa_A3_lachen'],
[noaGehtBis, 'noa_A6_blick-rechts'], [noaGehtBis, 'noa_A6_blick-rechts'],
[z.n2.start, 'noa_B_hocken'],
[z.c5.ende, 'noa_A3_lachen'], [z.c5.ende, 'noa_A3_lachen'],
[z.bild3.start + 4, 'noa_B2_erschrocken'], [z.bild3.start + 4, 'noa_B2_erschrocken'],
[z.l2.ende, 'noa_A3_lachen'], [z.l2.ende, 'noa_A3_lachen'],
[zurueck, 'noa_B_hocken'],
[z.e4.start, 'noa_A3_lachen'], [z.e4.start, 'noa_A3_lachen'],
]; ];
// Hocke: Clip vorwärts (runter) oder rückwärts (aufstehen); sonst Standbilder
const hocke = (() => {
const w = (start: number, auf: boolean) => ({start, auf});
if (f < z.n1.start + 30) return w(-1000, false);
if (f < z.n1.start + 30 + NOA_HOCKE_FRAMES) return w(z.n1.start + 30, true);
if (f >= z.n2.start - 15 && f < z.c5.ende) return w(z.n2.start - 15, false);
if (f >= z.c5.ende && f < z.c5.ende + NOA_HOCKE_FRAMES) return w(z.c5.ende, true);
if (f >= zurueck && f < z.e4.start) return w(-1000, false);
if (f >= z.e4.start && f < z.e4.start + NOA_HOCKE_FRAMES) return w(z.e4.start, true);
return null;
})();
// beim Hinlegen jedes Blatts kurz nach vorn beugen // 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); 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) // beim Nestbau schieben (vor und zurück)
@ -181,17 +188,24 @@ export const Szene4: React.FC = () => {
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, 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} /> <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} /> {noaGeht ? (
<Geher figur="noa" spiegeln x={noaX} y={BODEN} />
) : hocke ? (
<NoaHocke start={hocke.start} auf={hocke.auf} x={noaX + schieben * 10} y={BODEN} stauchY={1 - 0.03 * beugen} drehung={beugen * 3 + schieben * 2} />
) : (
<Figur pose={noaPose} x={noaX} y={BODEN} stauchY={atmen(f, 2)} />
)}
{imNest ? ( {imNest ? (
<div style={{position: 'absolute', inset: 0, clipPath: `inset(0 0 ${1080 - BODEN - 4}px 0)`}}> <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} /> <Figur pose={pinoPose} x={NEST_X} y={pinoImNestY} schatten={false} />
</div> </div>
) : pinoGeht ? (
<Geher figur="pino" spiegeln={pinoSpiegel} x={pinoX} y={BODEN} tempo={1.3} />
) : ( ) : (
<Figur <Figur
pose={pinoGeht ? gehPose('pino', f, 8) : pinoPose} pose={pinoPose}
spiegeln={pinoSpiegel}
x={pinoX} x={pinoX}
y={BODEN + zaehlHuepfer + huepfer(f, niesen + 2, 30, 12) + (pinoGeht ? gehWippen(f, 8, 4) : 0)} y={BODEN + zaehlHuepfer + huepfer(f, niesen + 2, 30, 12)}
stauchY={atmen(f, 1) * hopfStauch(f, niesen + 2)} stauchY={atmen(f, 1) * hopfStauch(f, niesen + 2)}
/> />
)} )}

View file

@ -1,14 +1,12 @@
import React from 'react'; import React from 'react';
import {useCurrentFrame} from 'remotion'; import {useCurrentFrame} from 'remotion';
import {kurve} from '../bewegung'; import {kurve} from '../bewegung';
import {ClipFigur, Figur, Kamera} from './figur'; import {ClipFigur, Figur, Kamera} from '../baukasten/figur';
import {Blende, Vignette, atmen, hopfStauch, huepfer, kamFahrt, ruck} from './helfer'; import {Blende, Vignette, atmen, hopfStauch, huepfer, kamFahrt, ruck} from '../baukasten/helfer';
import {Funkeln, Nest} from './natur'; import {Funkeln, Nest} from '../baukasten/natur';
import {Ort} from './orte'; import {BODEN, NEST_X, Ort} from '../baukasten/orte';
import {BODEN} from './szene2'; import {SCHRIFT, Untertitel} from '../baukasten/untertitel';
import {NEST_X} from './szene4'; import {plane} from './plan';
import {SCHRIFT, Untertitel} from './untertitel';
import {plane} from './zeit';
// Szene 5: Pino will nicht schlafen. Noa hat eine Idee. // Szene 5: Pino will nicht schlafen. Noa hat eine Idee.
@ -16,7 +14,7 @@ const NOA_X5 = 1150;
const PINO_RAUS_X = 1370; const PINO_RAUS_X = 1370;
const LULU_X5 = 1790; const LULU_X5 = 1790;
export const S5 = plane([ export const S5 = plane('s5', [
{id: 'bild1', dauer: 3.0}, {id: 'bild1', dauer: 3.0},
{id: 'bild2', dauer: 2.2}, {id: 'bild2', dauer: 2.2},
{id: 'p1', wer: 'PINO', text: 'Nein! Ich will nicht schlafen, bevor das Winterfest da ist!'}, {id: 'p1', wer: 'PINO', text: 'Nein! Ich will nicht schlafen, bevor das Winterfest da ist!'},

View file

@ -1,12 +1,12 @@
import React from 'react'; import React from 'react';
import {useCurrentFrame} from 'remotion'; import {useCurrentFrame} from 'remotion';
import {kurve} from '../bewegung'; import {kurve} from '../bewegung';
import {ClipFigur, Figur, Kamera} from './figur'; import {ClipFigur, Figur, Kamera} from '../baukasten/figur';
import {Blende, Vignette, atmen, huepfer, kamFahrt} from './helfer'; import {Blende, Vignette, atmen, huepfer, kamFahrt} from '../baukasten/helfer';
import {Funkeln} from './natur'; import {Funkeln} from '../baukasten/natur';
import {KALENDER_TUER1, Ort} from './orte'; import {KALENDER_TUER1, KalenderMalen, Ort} from '../baukasten/orte';
import {Untertitel} from './untertitel'; import {Untertitel} from '../baukasten/untertitel';
import {plane} from './zeit'; import {plane} from './plan';
// Szene 6: Noas Idee. Abends in Noas Zimmer, Noa malt den Kalender. // Szene 6: Noas Idee. Abends in Noas Zimmer, Noa malt den Kalender.
@ -16,8 +16,9 @@ const PINO_X6 = 1090;
const TISCH_Y = 802; const TISCH_Y = 802;
const LULU_X6 = 1440; const LULU_X6 = 1440;
export const S6 = plane([ export const S6 = plane('s6', [
{id: 'bild0', dauer: 3.0}, {id: 'bild0', dauer: 3.0},
{id: 'malen', dauer: 4.0},
{id: 'l1', wer: 'LULU', text: 'Was malst du da, Noa?'}, {id: 'l1', wer: 'LULU', text: 'Was malst du da, Noa?'},
{id: 'n1', wer: 'NOA', text: 'Einen Kalender! Mit 24 Türchen.'}, {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: 'n2', wer: 'NOA', text: 'Wir öffnen jeden Tag eins. Und hinter jedem Türchen steckt etwas, das Pino wach hält.'},
@ -29,27 +30,16 @@ export const S6 = plane([
{id: 'bild2', dauer: 4.2}, {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 = () => { export const Szene6: React.FC = () => {
const f = useCurrentFrame(); const f = useCurrentFrame();
const {z, dauer} = S6; const {z, dauer} = S6;
const malen = f < z.n1.ende ? Math.min(1, f / (z.n1.ende - 10)) : 1; // Noa malt: Blick von oben aufs Blatt, während „malen“ und während sie antwortet (n1)
const stiftX = 600 + malen * 250 + Math.sin(f * 0.6) * 10; const malP = kurve(f, [
const stiftY = 796 + Math.cos(f * 0.6) * 5; [z.malen.start, 0],
[z.n1.ende, 1],
]);
const malSicht = (f >= z.malen.start && f < z.l1.start) || (f >= z.n1.start && f < z.n1.ende + 6);
const malt = f < z.n1.ende; const malt = f < z.n1.ende;
const luluX = kurve(f, [ const luluX = kurve(f, [
@ -81,6 +71,19 @@ export const Szene6: React.FC = () => {
[dauer, KALENDER_TUER1.x - 40, KALENDER_TUER1.y, 1.6], [dauer, KALENDER_TUER1.x - 40, KALENDER_TUER1.y, 1.6],
]); ]);
if (malSicht) {
return (
<>
<Kamera x={kurve(f, [[z.malen.start, 900], [z.n1.ende, 1020]])} y={560} zoom={1.12}>
<Ort name="kalender_leer" />
<KalenderMalen p={malP} wackeln={f} />
</Kamera>
<Vignette />
<Untertitel zeilen={S6.liste} />
</>
);
}
if (kalender) { if (kalender) {
return ( return (
<> <>
@ -141,8 +144,6 @@ export const Szene6: React.FC = () => {
schatten={false} schatten={false}
/> />
<Ort name="zimmer_tisch" /> <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 ? ( {imSprung ? (
<ClipFigur name="pino_C3_freudensprung" start={sprung} tempo={1.4} x={PINO_X6} y={TISCH_Y} hoehe={N * 0.4} /> <ClipFigur name="pino_C3_freudensprung" start={sprung} tempo={1.4} x={PINO_X6} y={TISCH_Y} hoehe={N * 0.4} />
) : ( ) : (

View file

@ -1,13 +1,11 @@
import React from 'react'; import React from 'react';
import {useCurrentFrame} from 'remotion'; import {useCurrentFrame} from 'remotion';
import {ClipFigur, Figur, Kamera} from './figur'; import {ClipFigur, Figur, Kamera} from '../baukasten/figur';
import {Blende, Vignette, atmen, kamFahrt} from './helfer'; import {Blende, Vignette, atmen, kamFahrt} from '../baukasten/helfer';
import {Nest, Schnee, Zzz} from './natur'; import {Nest, Schnee, Zzz} from '../baukasten/natur';
import {Ort} from './orte'; import {BODEN, NEST_X, Ort} from '../baukasten/orte';
import {BODEN} from './szene2'; import {Untertitel} from '../baukasten/untertitel';
import {NEST_X} from './szene4'; import {plane} from './plan';
import {Untertitel} from './untertitel';
import {plane} from './zeit';
// Szene 7: Gute Nacht, Pino. Nacht im Garten, die erste Schneeflocke fällt. // Szene 7: Gute Nacht, Pino. Nacht im Garten, die erste Schneeflocke fällt.
@ -15,7 +13,7 @@ const NOA_X7 = 1140;
const LULU_X7 = 1800; const LULU_X7 = 1800;
const LATERNE = {x: 1290, y: BODEN + 10}; const LATERNE = {x: 1290, y: BODEN + 10};
export const S7 = plane( export const S7 = plane('s7',
[ [
{id: 'bild0', dauer: 3.4}, {id: 'bild0', dauer: 3.4},
{id: 'n1', wer: 'NOA', text: 'Schlaf gut, Pino. Nur bis morgen.', leise: true}, {id: 'n1', wer: 'NOA', text: 'Schlaf gut, Pino. Nur bis morgen.', leise: true},
@ -24,8 +22,7 @@ export const S7 = plane(
{id: 't1', wer: 'NOA_LULU', text: 'Tschüss! Bis bald!', leise: true, dauer: 2.6}, {id: 't1', wer: 'NOA_LULU', text: 'Tschüss! Bis bald!', leise: true, dauer: 2.6},
{id: 'p1', wer: 'PINO', text: '… bis bald …', leise: true, dauer: 2.6}, {id: 'p1', wer: 'PINO', text: '… bis bald …', leise: true, dauer: 2.6},
], ],
0.5, {vorlauf: 0.5, nachlauf: 1.6},
1.6,
); );
const Laterne: React.FC<{frame: number}> = ({frame}) => { const Laterne: React.FC<{frame: number}> = ({frame}) => {

View file

@ -1,48 +0,0 @@
// Zeitplan einer Szene aus ihren Sprechzeilen.
//
// Solange es keine Sprachaufnahmen gibt, wird die Dauer jeder Zeile aus der
// Textlänge geschätzt. Kommen später die ElevenLabs-Dateien, trägt man die
// echte Länge als `dauer` ein (oder liest sie aus), und alle Bewegungen, die
// sich auf die Zeilen beziehen, rutschen automatisch mit.
export const FPS = 30;
export type Sprecher = 'ERZ' | 'PINO' | 'NOA' | 'LULU' | 'ALLE' | 'NOA_LULU';
export type Zeile = {
id: string;
wer?: Sprecher; // leer = nur Bild (Handlung ohne Text)
text?: string;
dauer?: number; // Sekunden; ohne Angabe aus der Textlänge geschätzt
pause?: number; // Sekunden Stille danach (Mitmach-Pause = 3)
leise?: boolean; // geflüstert / gemurmelt
};
export type Geplant = Zeile & {start: number; ende: number; frei: number};
const schaetze = (z: Zeile) => {
if (z.dauer !== undefined) return z.dauer;
if (!z.text) return 1;
// Kinderfernsehen: ruhig gesprochen, ca. 13–14 Zeichen pro Sekunde
const tempo = z.wer === 'ERZ' ? 13 : 14;
return 0.6 + z.text.length / tempo;
};
// Lücke zwischen zwei Zeilen
const LUECKE = 0.35;
export const plane = (zeilen: Zeile[], vorlauf = 0.5, nachlauf = 0.8) => {
let t = vorlauf * FPS;
const liste: Geplant[] = [];
for (const z of zeilen) {
const d = Math.round(schaetze(z) * FPS);
const start = Math.round(t);
const ende = start + d;
const frei = ende + Math.round((z.pause ?? 0) * FPS);
liste.push({...z, start, ende, frei});
t = frei + LUECKE * FPS;
}
const dauer = Math.round(t + nachlauf * FPS);
const z = Object.fromEntries(liste.map((l) => [l.id, l])) as Record<string, Geplant>;
return {liste, z, dauer};
};

View file

@ -4,7 +4,7 @@
# Bibliothek), z. B. aus frei.py. Alle Bilder einer Figur werden mit demselben # Bibliothek), z. B. aus frei.py. Alle Bilder einer Figur werden mit demselben
# Rahmen zugeschnitten, damit Posen beim Überblenden deckungsgleich bleiben. # Rahmen zugeschnitten, damit Posen beim Überblenden deckungsgleich bleiben.
# #
# Ausgabe: public/folge02/figuren/<pose>.webp und src/folge02/posen.ts # Ausgabe: public/bibliothek/figuren/<pose>.webp und src/folge02/posen.ts
# python3 werkzeuge/posen.py <ordner_freigestellt> [<ordner_clips_frei>] # python3 werkzeuge/posen.py <ordner_freigestellt> [<ordner_clips_frei>]
import json, os, sys, glob import json, os, sys, glob
from PIL import Image from PIL import Image
@ -12,8 +12,8 @@ from PIL import Image
QUELLE = sys.argv[1] QUELLE = sys.argv[1]
CLIPS = sys.argv[2] if len(sys.argv) > 2 else None CLIPS = sys.argv[2] if len(sys.argv) > 2 else None
HIER = os.path.dirname(os.path.abspath(__file__)) HIER = os.path.dirname(os.path.abspath(__file__))
ZIEL = os.path.join(HIER, "..", "public", "folge02", "figuren") ZIEL = os.path.join(HIER, "..", "public", "bibliothek", "figuren")
TS = os.path.join(HIER, "..", "src", "folge02", "posen.ts") TS = os.path.join(HIER, "..", "src", "baukasten", "posen.ts")
os.makedirs(ZIEL, exist_ok=True) os.makedirs(ZIEL, exist_ok=True)
# Referenzpose je Figur: deren Höhe gilt als „Figurhöhe“, deren Fußpunkt als Anker. # Referenzpose je Figur: deren Höhe gilt als „Figurhöhe“, deren Fußpunkt als Anker.
@ -31,7 +31,7 @@ for figur in REF:
rahmen = [max(0, rahmen[0]), max(0, rahmen[1]), min(1376, rahmen[2]), min(768, rahmen[3])] rahmen = [max(0, rahmen[0]), max(0, rahmen[1]), min(1376, rahmen[2]), min(768, rahmen[3])]
for n, b in bilder.items(): for n, b in bilder.items():
b.crop(rahmen).save(f"{ZIEL}/{n}.webp", quality=90, method=5) b.crop(rahmen).save(f"{ZIEL}/{n}.webp", quality=90, method=5)
posen[n] = {"figur": figur, "datei": f"folge02/figuren/{n}.webp", "x": rahmen[0], "y": rahmen[1], posen[n] = {"figur": figur, "datei": f"bibliothek/figuren/{n}.webp", "x": rahmen[0], "y": rahmen[1],
"b": rahmen[2] - rahmen[0], "h": rahmen[3] - rahmen[1], "box": boxen[n]} "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, # A5 (Blick links) ist eine Spiegelung von A6 an der Bildmitte: zurückschieben,
# damit die Figur beim Wechsel A6 ↔ A5 nicht seitlich springt. # damit die Figur beim Wechsel A6 ↔ A5 nicht seitlich springt.
@ -52,7 +52,7 @@ if CLIPS:
n = os.path.basename(j)[:-5] n = os.path.basename(j)[:-5]
d = json.load(open(j)) d = json.load(open(j))
bx = d["box"] bx = d["box"]
clips[n] = {"figur": n.split("_")[0], "datei": f"folge02/clips/{n}.webm", clips[n] = {"figur": n.split("_")[0], "datei": f"bibliothek/clips/{n}.webm",
"x": bx[0] * K_CLIP, "y": bx[1] * K_CLIP, "b": (bx[2] - bx[0]) * K_CLIP, "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"]} "h": (bx[3] - bx[1]) * K_CLIP, "frames": d["frames"]}

View file

@ -0,0 +1,116 @@
// Rendert eine Folge Szene für Szene und klebt sie zusammen.
// Szenen, an denen sich nichts geändert hat, werden nicht neu gerendert.
//
// node werkzeuge/render.mjs F02 ganze Folge, volle Qualität
// node werkzeuge/render.mjs F02 --entwurf halbe Auflösung + Zeit/Szene eingeblendet
// node werkzeuge/render.mjs F02 --szene 3,5 nur diese Szenen (neu) rendern
// node werkzeuge/render.mjs F02 --neu alles neu, Zwischenstand ignorieren
// node werkzeuge/render.mjs F02 --kerne 6 Anzahl paralleler Browser-Tabs
// node werkzeuge/render.mjs F02 --ziel /shared/youtube/folge02/folge02.mp4
//
// Läuft unter Linux und Windows. Ergebnis und Zwischenstand: out/<kürzel>/
import {spawnSync} from 'node:child_process';
import {createHash} from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
const WURZEL = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
process.chdir(WURZEL);
const args = process.argv.slice(2);
const kuerzel = args.find((a) => !a.startsWith('--'));
if (!kuerzel) {
console.error('Bitte Folge angeben, z. B.: node werkzeuge/render.mjs F02');
process.exit(1);
}
const wert = (name) => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const entwurf = args.includes('--entwurf');
const neu = args.includes('--neu');
const nurSzenen = wert('--szene')?.split(',').map(Number);
const kerne = Number(wert('--kerne') ?? Math.max(1, os.cpus().length - 1));
const ziel = wert('--ziel');
const nummer = kuerzel.replace(/\D/g, '');
const ordner = `src/folge${nummer}`;
const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
const lauf = (cmd, a, opt = {}) => {
const r = spawnSync(cmd, a, {stdio: opt.still ? 'pipe' : 'inherit', encoding: 'utf8', shell: process.platform === 'win32'});
if (r.status !== 0) {
if (opt.still) console.error(r.stdout, r.stderr);
throw new Error(`Fehlgeschlagen: ${cmd} ${a.join(' ')}`);
}
return r.stdout;
};
// Szenenliste aus dem Code der Folge holen
const liste = JSON.parse(
lauf(npx, ['tsx', 'werkzeuge/szenenliste.mts', `./${ordner}/Folge${nummer}.tsx`, kuerzel], {still: true})
.split('\n')
.filter((z) => z.startsWith('['))
.pop(),
);
// Fingerabdruck: gemeinsamer Code + Bilder/Clips/Stimmen + eigene Szenendatei
const hashDatei = (h, p) => {
const st = fs.statSync(p);
if (st.isDirectory()) {
for (const n of fs.readdirSync(p).sort()) hashDatei(h, path.join(p, n));
} else {
h.update(p.replaceAll('\\', '/'));
h.update(fs.readFileSync(p));
}
};
const szenenDateien = new Set(liste.map((s) => s.datei));
const gemeinsam = createHash('sha256');
for (const p of ['src/baukasten', 'src/bewegung.ts', 'public', 'package-lock.json']) if (fs.existsSync(p)) hashDatei(gemeinsam, p);
for (const n of fs.readdirSync(ordner).sort()) {
const p = path.join(ordner, n);
if (!szenenDateien.has(n) || n === `Folge${nummer}.tsx`) hashDatei(gemeinsam, p);
}
const basis = gemeinsam.digest('hex');
const qualitaet = entwurf ? 'entwurf' : 'voll';
const aus = path.join('out', kuerzel, qualitaet);
fs.mkdirSync(aus, {recursive: true});
const propsDatei = path.join(aus, 'props.json');
fs.writeFileSync(propsDatei, JSON.stringify({entwurf}));
const gl = process.platform === 'linux' ? 'swangle' : 'angle';
const teile = [];
const start = Date.now();
for (const [i, s] of liste.entries()) {
const h = createHash('sha256');
h.update(basis + qualitaet + s.dauer);
hashDatei(h, path.join(ordner, s.datei));
const fp = h.digest('hex');
const datei = path.join(aus, `${s.id}.mp4`);
const fpDatei = datei + '.fingerabdruck';
const aktuell = fs.existsSync(datei) && fs.existsSync(fpDatei) && fs.readFileSync(fpDatei, 'utf8') === fp;
const gewaehlt = nurSzenen ? nurSzenen.includes(i + 1) : !aktuell || neu;
teile.push(datei);
if (!gewaehlt) {
console.log(`= Szene ${i + 1} „${s.name}“ unverändert`);
continue;
}
console.log(`▶ Szene ${i + 1} „${s.name}“ (${(s.dauer / 30).toFixed(1)} s) …`);
const t0 = Date.now();
const a = ['remotion', 'render', 'src/index.ts', s.id, datei, `--concurrency=${kerne}`, `--gl=${gl}`, `--props=${propsDatei}`, '--log=error'];
if (entwurf) a.push('--scale=0.5');
lauf(npx, a);
fs.writeFileSync(fpDatei, fp);
const sek = (Date.now() - t0) / 1000;
console.log(` fertig in ${sek.toFixed(0)} s (${(s.dauer / sek).toFixed(1)} Bilder/s)`);
}
// Zusammenkleben (ohne neu zu kodieren)
const listeDatei = path.join(aus, 'teile.txt');
fs.writeFileSync(listeDatei, teile.map((t) => `file '${path.resolve(t).replaceAll('\\', '/')}'`).join('\n'));
const ergebnis = ziel ?? path.join('out', kuerzel, `${kuerzel}${entwurf ? '-entwurf' : ''}.mp4`);
lauf(npx, ['remotion', 'ffmpeg', '-v', 'error', '-y', '-f', 'concat', '-safe', '0', '-i', listeDatei, '-c', 'copy', '-movflags', '+faststart', ergebnis]);
console.log(`\nFertig: ${ergebnis} (${((Date.now() - start) / 60000).toFixed(1)} min)`);

View file

@ -0,0 +1,13 @@
{
"modell": "eleven_multilingual_v2",
"format": "mp3_44100_128",
"einstellungen": {"stability": 0.5, "similarity_boost": 0.75, "style": 0.2},
"stimmen": {
"ERZ": "",
"PINO": "",
"NOA": "",
"LULU": "",
"ALLE": "ERZ",
"NOA_LULU": "NOA"
}
}

View file

@ -0,0 +1,102 @@
// Erzeugt die Sprachaufnahmen einer Folge mit ElevenLabs und trägt ihre
// Länge in src/folgeNN/stimmen.json ein. Danach passt sich der Zeitplan der
// Szenen automatisch an die echten Längen an.
//
// ELEVENLABS_API_KEY=… npx tsx werkzeuge/stimmen.mts F02
// … F02 --szene s3 nur eine Szene
// … F02 --neu alle Zeilen neu erzeugen
// … F02 --probe nur anzeigen, was erzeugt würde (kostet nichts)
//
// Der Schlüssel kommt nur aus der Umgebungsvariable, nie aus einer Datei im Repo.
// Stimmen-IDs und Modell stehen in werkzeuge/stimmen.config.json.
// Aufnahmen landen in public/stimmen/folgeNN/ (nicht im Git; auf dem VPS ein
// Link nach /shared/youtube/stimmen/). Nur Zeilen, deren Text sich geändert
// hat oder deren Datei fehlt, werden neu erzeugt.
import {spawnSync} from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
const WURZEL = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
process.chdir(WURZEL);
const args = process.argv.slice(2);
const kuerzel = args.find((a) => !a.startsWith('--'));
const wert = (n: string) => (args.includes(n) ? args[args.indexOf(n) + 1] : undefined);
const nurSzene = wert('--szene');
const neu = args.includes('--neu');
const probe = args.includes('--probe');
if (!kuerzel) throw new Error('Folge angeben, z. B. F02');
const nummer = kuerzel.replace(/\D/g, '');
const cfg = JSON.parse(fs.readFileSync('werkzeuge/stimmen.config.json', 'utf8')) as {
modell: string;
format: string;
stimmen: Record<string, string>;
einstellungen?: Record<string, unknown>;
};
const schluessel = process.env.ELEVENLABS_API_KEY;
if (!schluessel && !probe) throw new Error('ELEVENLABS_API_KEY ist nicht gesetzt.');
const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
const ausgabe = spawnSync(npx, ['tsx', 'werkzeuge/szenenliste.mts', `./src/folge${nummer}/Folge${nummer}.tsx`, kuerzel], {encoding: 'utf8', shell: process.platform === 'win32'});
const szenen = JSON.parse(ausgabe.stdout.split('\n').filter((z) => z.startsWith('[')).pop()!) as {
szene?: string;
zeilen: {id: string; wer?: string; text: string; leise?: boolean}[];
}[];
const jsonDatei = `src/folge${nummer}/stimmen.json`;
const aufnahmen: Record<string, {datei: string; dauer: number; text: string}> = JSON.parse(fs.readFileSync(jsonDatei, 'utf8'));
const ordner = `public/stimmen/folge${nummer}`;
fs.mkdirSync(ordner, {recursive: true});
// Stimme je Sprecher; ein Eintrag kann auf einen anderen Sprecher verweisen (z. B. ALLE → ERZ)
const stimmeVon = (wer: string): string | undefined => {
const v = cfg.stimmen[wer];
if (!v) return undefined;
return v in cfg.stimmen ? stimmeVon(v) : v;
};
const laenge = (datei: string) => {
const r = spawnSync(npx, ['remotion', 'ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', datei], {
encoding: 'utf8',
shell: process.platform === 'win32',
});
return Math.round(parseFloat(r.stdout.trim()) * 100) / 100;
};
let zeichen = 0;
let erzeugt = 0;
for (const s of szenen) {
if (!s.szene || (nurSzene && s.szene !== nurSzene)) continue;
for (const z of s.zeilen) {
const schl = `${s.szene}/${z.id}`;
const datei = `${s.szene}-${z.id}.mp3`;
const pfad = path.join(ordner, datei);
const alt = aufnahmen[schl];
if (!neu && alt && alt.text === z.text && fs.existsSync(pfad)) continue;
const stimme = z.wer && stimmeVon(z.wer);
if (!stimme) {
console.warn(`! ${schl}: keine Stimme für „${z.wer}“ in stimmen.config.json`);
continue;
}
zeichen += z.text.length;
if (probe) {
console.log(`~ ${schl} (${z.wer}): ${z.text}`);
continue;
}
const antwort = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${stimme}?output_format=${cfg.format}`, {
method: 'POST',
headers: {'xi-api-key': schluessel!, 'Content-Type': 'application/json', Accept: 'audio/mpeg'},
body: JSON.stringify({text: z.text, model_id: cfg.modell, voice_settings: cfg.einstellungen}),
});
if (!antwort.ok) throw new Error(`${schl}: ElevenLabs antwortet ${antwort.status} ${await antwort.text()}`);
fs.writeFileSync(pfad, Buffer.from(await antwort.arrayBuffer()));
aufnahmen[schl] = {datei, dauer: laenge(pfad), text: z.text};
fs.writeFileSync(jsonDatei, JSON.stringify(aufnahmen, null, '\t') + '\n');
erzeugt++;
console.log(`✓ ${schl} ${aufnahmen[schl].dauer} s`);
}
}
console.log(`\n${probe ? 'Würde erzeugen' : 'Erzeugt'}: ${probe ? '' : erzeugt + ' Zeilen, '}${zeichen} Zeichen`);

Some files were not shown because too many files have changed in this diff Show more