Remotion: Körperteil-Masken (Rig) für Pino, Noa und Lulu, VerformTest darauf umgestellt

- werkzeuge/rig.py erzeugt pro Figur Teil- und Einfluss-Masken, Gelenkpunkte und Hierarchie (src/rigs.ts)
- Gitter-Verformung mit Masken-Gewichten und 3-px-Gitter
- Pinos Sprung gedämpft, Kopfbereich deutlich größer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pascal Scholz 2026-10-28 20:12:50 +00:00
parent 7c120597d9
commit 6a29198c32
84 changed files with 759 additions and 59 deletions

View file

@ -34,6 +34,33 @@ Mit `rembg` (Modell `isnet-general-use`), danach Maske härten, nur die größte
Fläche behalten, Rand um 2 px einziehen (sonst heller Saum vor dunklem
Hintergrund). Am besten funktionieren Einzelbilder vor hellem Papier.
## Körperteil-Masken (Rig)
`werkzeuge/rig.py` zerlegt jede Figur in Körperteile und erzeugt:
| Pfad | Inhalt |
| --- | --- |
| `public/figuren/<figur>/teile/<teil>.png` | harte Maske, genau an der Kontur |
| `public/figuren/<figur>/einfluss/<teil>.png` | weiche Maske für die Verformung: Teil + alles, was daran hängt, weicher Übergang am Gelenk, reicht über die Kontur hinaus |
| `public/figuren/<figur>/uebersicht.png` | alle Teile farbig, zum Prüfen |
| `src/rigs.ts` | Gelenkpunkte und Hierarchie (automatisch erzeugt) |
Teile: Lulu – kopf, hals, ohr_links/rechts, horn_links/rechts, rumpf,
bein_vorne_links/rechts, bein_hinten_links/rechts, schwanz, schal_schlinge,
schal_ende, schal_boden. Pino – koerper, kopf, ohr_links/rechts, bauch,
arm_links/rechts, fuss_links/rechts, muetze, bommel. Noa – kopf,
zopf_links/rechts, rumpf, arm_links/rechts, hand_links/rechts,
bein_links/rechts, stiefel_links/rechts.
Zuordnung: Accessoires über ihre Farbe, alles andere über Kernflächen pro
Teil, die bis zur Kontur wachsen – nur innerhalb der Figur, also nie über
Lücken (Schwanz ↔ Bein). Neue Figur oder neues Bild: Skelett und Flächen in
`rig.py` eintragen, dann
```bash
python3 werkzeuge/rig.py # braucht numpy, scipy, pillow
```
und `uebersicht.png` prüfen. In einer Szene: `gelenk('lulu', 'hals', grad)`
(siehe `src/szenen/VerformTest.tsx`).
## Bekannte Grenzen
- Die Figuren haben nur die Pose ihres Bildes. Neue Posen brauchen ein neues
Bild (z. B. aus Gemini).

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 839 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 828 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 823 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,003 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 557 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 976 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 751 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 923 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 923 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

280
remotion/src/rigs.ts Normal file
View file

@ -0,0 +1,280 @@
// Automatisch erzeugt von werkzeuge/rig.py – nicht von Hand ändern.
// Gelenkpunkte (um) in Bildpixeln der Figur, eltern = woran das Teil hängt.
// Masken: figuren/<figur>/teile/<teil>.png und figuren/<figur>/einfluss/<teil>.png
export type RigTeil = {eltern: string | null; um: [number, number] | null};
export const RIGS: Record<string, Record<string, RigTeil>> = {
"lulu": {
"rumpf": {
"eltern": null,
"um": [
130,
292
]
},
"hals": {
"eltern": "rumpf",
"um": [
124,
244
]
},
"kopf": {
"eltern": "hals",
"um": [
111,
150
]
},
"ohr_links": {
"eltern": "kopf",
"um": [
44,
57
]
},
"ohr_rechts": {
"eltern": "kopf",
"um": [
146,
72
]
},
"horn_links": {
"eltern": "kopf",
"um": [
65,
44
]
},
"horn_rechts": {
"eltern": "kopf",
"um": [
122,
46
]
},
"bein_vorne_links": {
"eltern": "rumpf",
"um": [
92,
332
]
},
"bein_vorne_rechts": {
"eltern": "rumpf",
"um": [
124,
340
]
},
"bein_hinten_links": {
"eltern": "rumpf",
"um": [
186,
345
]
},
"bein_hinten_rechts": {
"eltern": "rumpf",
"um": [
230,
335
]
},
"schwanz": {
"eltern": "rumpf",
"um": [
246,
288
]
},
"schal_schlinge": {
"eltern": "hals",
"um": [
124,
244
]
},
"schal_ende": {
"eltern": "schal_schlinge",
"um": [
62,
252
]
},
"schal_boden": {
"eltern": "rumpf",
"um": [
160,
360
]
}
},
"pino": {
"koerper": {
"eltern": null,
"um": [
230,
430
]
},
"kopf": {
"eltern": "koerper",
"um": [
400,
465
]
},
"ohr_links": {
"eltern": "kopf",
"um": [
290,
258
]
},
"ohr_rechts": {
"eltern": "kopf",
"um": [
510,
270
]
},
"bauch": {
"eltern": "koerper",
"um": [
330,
600
]
},
"arm_links": {
"eltern": "koerper",
"um": [
322,
462
]
},
"arm_rechts": {
"eltern": "koerper",
"um": [
458,
468
]
},
"fuss_links": {
"eltern": "koerper",
"um": [
310,
568
]
},
"fuss_rechts": {
"eltern": "koerper",
"um": [
495,
600
]
},
"bommel": {
"eltern": "muetze",
"um": [
445,
88
]
},
"muetze": {
"eltern": "kopf",
"um": [
400,
200
]
}
},
"noa": {
"rumpf": {
"eltern": null,
"um": [
115,
165
]
},
"kopf": {
"eltern": "rumpf",
"um": [
120,
156
]
},
"zopf_links": {
"eltern": "kopf",
"um": [
48,
120
]
},
"zopf_rechts": {
"eltern": "kopf",
"um": [
178,
108
]
},
"arm_links": {
"eltern": "rumpf",
"um": [
64,
172
]
},
"arm_rechts": {
"eltern": "rumpf",
"um": [
166,
172
]
},
"bein_links": {
"eltern": "rumpf",
"um": [
92,
322
]
},
"bein_rechts": {
"eltern": "rumpf",
"um": [
128,
322
]
},
"hand_links": {
"eltern": "arm_links",
"um": [
54,
292
]
},
"hand_rechts": {
"eltern": "arm_rechts",
"um": [
186,
275
]
},
"stiefel_links": {
"eltern": "bein_links",
"um": [
92,
350
]
},
"stiefel_rechts": {
"eltern": "bein_rechts",
"um": [
132,
350
]
}
}
};

View file

@ -1,15 +1,26 @@
import React from 'react';
import {AbsoluteFill, Img, staticFile, useCurrentFrame} from 'remotion';
import {feder, glatt, kreis, kurve} from '../bewegung';
import {feder, kreis, kurve} from '../bewegung';
import {LULU, PINO} from '../figuren';
import {RIGS} from '../rigs';
import {VerformFigur, type Knochen} from '../teile/VerformFigur';
// Test für weiche Animation (6 s, ohne Ton): Lulu beugt den Hals zu Pino
// hinunter, Pino freut sich und hüpft. Alles über Gitter-Verformung und
// Federn, Hintergrund und Papier sind vorberechnete Bilder.
// hinunter, Pino freut sich und hüpft. Alles über Gitter-Verformung mit den
// Körperteil-Masken aus werkzeuge/rig.py und Federn; Hintergrund und Papier
// sind vorberechnete Bilder.
const BODEN = 884;
// Drehgelenk eines Rig-Teils: Gelenkpunkt und Einfluss-Maske (Teil + alles, was daran hängt).
const gelenk = (figur: string, teil: string, grad: number): Knochen => ({
name: teil,
art: 'drehen',
um: RIGS[figur][teil].um!,
grad,
gewicht: {maske: `${figur}/einfluss/${teil}`},
});
// ---------- Lulu ----------
const halsKurve = (f: number) =>
kurve(f, [
@ -29,70 +40,52 @@ const luluKnochen = (f: number): Knochen[] => {
const kopf = feder((g) => -halsKurve(g) * 0.35 + neugierKurve(g), f, {steifheit: 0.07, daempfung: 0.18});
const ohrL = feder((g) => -26 * ohrZucken(g, [20, 98]), f, {steifheit: 0.25, daempfung: 0.12}) + (hals - halsTraege) * 0.6;
const ohrR = feder((g) => 22 * ohrZucken(g, [60, 104]), f, {steifheit: 0.25, daempfung: 0.12}) - (hals - halsTraege) * 0.5;
const schwanz = 13 * Math.sin(f * 0.085) + feder((g) => halsKurve(g) * 0.5, f, {steifheit: 0.04, daempfung: 0.1}) - halsKurve(f) * 0.5;
const schwanz = -6 + 10 * Math.sin(f * 0.085) + feder((g) => halsKurve(g) * 0.5, f, {steifheit: 0.04, daempfung: 0.1}) - halsKurve(f) * 0.5;
const schal = (halsTraege - hals) * 0.9 + 2.5 * Math.sin(f * 0.07);
const atmen = Math.sin(f * 0.1);
return [
{art: 'drehen', um: [45, 56], grad: ohrL, gewicht: kreis(22, 32, 18, 42)},
{art: 'drehen', um: [150, 68], grad: ohrR, gewicht: kreis(176, 56, 16, 38)},
{art: 'drehen', um: [112, 158], grad: kopf, gewicht: (x, y) => glatt(172, 128, y)},
{art: 'drehen', um: [125, 238], grad: hals, gewicht: (x, y) => glatt(244, 150, y)},
{
art: 'drehen',
um: [244, 288],
grad: schwanz,
gewicht: (x, y) => glatt(236, 252, x) * glatt(268, 284, y) * glatt(392, 376, y),
},
{
art: 'drehen',
um: [62, 252],
grad: schal,
gewicht: (x, y) => glatt(252, 330, y) * glatt(74, 62, x) * glatt(395, 380, y),
},
{art: 'stauchen', um: [175, 330], sx: 1 - atmen * 0.006, sy: 1 + atmen * 0.012, gewicht: kreis(180, 300, 50, 110)},
gelenk('lulu', 'ohr_links', ohrL),
gelenk('lulu', 'ohr_rechts', ohrR),
gelenk('lulu', 'kopf', kopf),
gelenk('lulu', 'hals', hals),
gelenk('lulu', 'schwanz', schwanz),
gelenk('lulu', 'schal_ende', schal),
{name: 'atmen', art: 'stauchen', um: [175, 330], sx: 1 - atmen * 0.006, sy: 1 + atmen * 0.012, gewicht: kreis(180, 300, 50, 110)},
];
};
// ---------- Pino ----------
// Sprung: Anlauf (Ducken), Flug, Landung mit Nachfedern.
const SPRUNG = {duck: 84, ab: 93, auf: 111};
// Kleiner Freudenhüpfer: kurz ducken, abheben, landen, nachfedern.
const SPRUNG = {duck: 86, ab: 93, auf: 107};
const sprungHoehe = (f: number) => {
if (f < SPRUNG.ab || f > SPRUNG.auf) return 0;
const t = (f - SPRUNG.ab) / (SPRUNG.auf - SPRUNG.ab);
return 4 * t * (1 - t);
};
const stauchZiel = (f: number) => {
if (f >= SPRUNG.duck && f < SPRUNG.ab) return kurve(f, [[SPRUNG.duck, 1], [SPRUNG.ab - 2, 0.86]]);
if (f >= SPRUNG.ab && f <= SPRUNG.auf) return 1 + 0.09 * Math.sin(Math.PI * Math.min(1, (f - SPRUNG.ab) / 8)) * (1 - sprungHoehe(f) * 0.3);
if (f > SPRUNG.auf && f < SPRUNG.auf + 4) return 0.84;
if (f >= SPRUNG.duck && f < SPRUNG.ab) return kurve(f, [[SPRUNG.duck, 1], [SPRUNG.ab - 1, 0.95]]);
if (f >= SPRUNG.ab && f <= SPRUNG.auf) return 1 + 0.035 * Math.sin(Math.PI * Math.min(1, (f - SPRUNG.ab) / 7));
if (f > SPRUNG.auf && f < SPRUNG.auf + 3) return 0.955;
return 1;
};
const pinoKopfKurve = (f: number) => kurve(f, [[0, 0], [38, 0], [56, -9], [84, -9], [92, -3], [128, -3], [146, 7], [180, 7]]);
const freude = (f: number) => (f > SPRUNG.auf + 4 && f < SPRUNG.auf + 40 ? Math.sin((f - SPRUNG.auf - 4) * 0.35) : 0);
const pinoKopfKurve = (f: number) => kurve(f, [[0, 0], [38, 0], [56, -7], [86, -7], [94, -2], [128, -2], [146, 5], [180, 5]]);
const freude = (f: number) => (f > SPRUNG.auf + 3 && f < SPRUNG.auf + 38 ? Math.sin((f - SPRUNG.auf - 3) * 0.35) : 0);
const pinoWerte = (f: number) => {
const stauch = feder(stauchZiel, f, {steifheit: 0.3, daempfung: 0.22});
const hoch = sprungHoehe(f) * 80;
const stauch = feder(stauchZiel, f, {steifheit: 0.3, daempfung: 0.3});
const hoch = sprungHoehe(f) * 28;
const tempo = (g: number) => (sprungHoehe(g + 1) - sprungHoehe(g - 1)) * 40;
const bommel = feder((g) => tempo(g) * 1.4, f, {steifheit: 0.12, daempfung: 0.1});
const bommel = feder((g) => tempo(g) * 0.8, f, {steifheit: 0.12, daempfung: 0.1});
const ohr = feder((g) => (g > SPRUNG.auf && g < SPRUNG.auf + 3 ? -14 : 0), f, {steifheit: 0.25, daempfung: 0.12});
const atmen = Math.sin(f * 0.11);
const knochen: Knochen[] = [
{art: 'drehen', um: [432, 92], grad: bommel + 3 * Math.sin(f * 0.06), gewicht: kreis(432, 48, 34, 56)},
{
art: 'drehen',
um: [330, 292],
grad: feder((g) => (g > SPRUNG.auf && g < SPRUNG.auf + 4 ? -20 : 0), f, {steifheit: 0.25, daempfung: 0.12}),
gewicht: kreis(318, 266, 22, 42),
},
{art: 'drehen', um: [430, 500], grad: freude(f) * 14, gewicht: kreis(432, 522, 22, 42)},
{art: 'drehen', um: [520, 495], grad: -freude(f) * 12, gewicht: kreis(540, 515, 20, 38)},
{
art: 'drehen',
um: [380, 480],
grad: feder(pinoKopfKurve, f, {steifheit: 0.08, daempfung: 0.2}),
gewicht: (x, y) => glatt(520, 380, y) * glatt(230, 330, x),
},
{art: 'stauchen', um: [420, 600], sx: 1 + atmen * 0.01, sy: 1 + atmen * 0.014, gewicht: kreis(420, 540, 70, 170)},
gelenk('pino', 'bommel', bommel + 3 * Math.sin(f * 0.06)),
gelenk('pino', 'ohr_links', ohr),
gelenk('pino', 'ohr_rechts', -ohr * 0.7),
gelenk('pino', 'arm_links', freude(f) * 12),
gelenk('pino', 'arm_rechts', -freude(f) * 10),
gelenk('pino', 'kopf', feder(pinoKopfKurve, f, {steifheit: 0.08, daempfung: 0.2})),
{name: 'atmen', art: 'stauchen', um: [420, 600], sx: 1 + atmen * 0.01, sy: 1 + atmen * 0.014, gewicht: kreis(420, 540, 70, 170)},
];
return {stauch, hoch, knochen};
};

View file

@ -7,24 +7,48 @@ import type {FigurDaten} from '../figuren';
// verschieben, werden die Gitterpunkte über „Knochen“ gedreht und gestaucht.
// So biegen sich Hals, Ohren, Schwanz usw. weich.
export type Gewicht = (x: number, y: number) => number;
// Gewicht eines Gitterpunkts für einen Knochen: entweder eine Formel in
// Bildkoordinaten oder der Name einer Maske (Graustufen-PNG in public/figuren/,
// gleich groß wie die Figur, weiß = bewegt sich voll mit).
export type Gewicht = ((x: number, y: number) => number) | {maske: string};
export type Knochen =
| {art: 'drehen'; um: [number, number]; grad: number; gewicht: Gewicht}
| {art: 'stauchen'; um: [number, number]; sx: number; sy: number; gewicht: Gewicht};
type Basis = {name: string; um: [number, number]; gewicht: Gewicht};
export type Knochen = (Basis & {art: 'drehen'; grad: number}) | (Basis & {art: 'stauchen'; sx: number; sy: number});
const RAND = 0.35; // Platz um das Bild, falls Teile über den Rand hinausschwingen
const SPALTEN = 48;
const ZEILEN = 64;
const ZELLE = 3; // Gitterweite in Bildpixeln
type Maske = {breite: number; hoehe: number; werte: Uint8ClampedArray};
const maskenWert = (m: Maske, x: number, y: number) => {
const ix = Math.min(m.breite - 1, Math.max(0, Math.round(x)));
const iy = Math.min(m.hoehe - 1, Math.max(0, Math.round(y)));
return m.werte[(iy * m.breite + ix) * 4] / 255;
};
const ladeMaske = (name: string) =>
new Promise<Maske>((ok, fehler) => {
const bild = new Image();
bild.onload = () => {
const c = document.createElement('canvas');
c.width = bild.width;
c.height = bild.height;
const ctx = c.getContext('2d')!;
ctx.drawImage(bild, 0, 0);
ok({breite: bild.width, hoehe: bild.height, werte: ctx.getImageData(0, 0, bild.width, bild.height).data});
};
bild.onerror = fehler;
bild.src = staticFile(`figuren/${name}.png`);
});
// Knochen werden der Reihe nach angewendet: zuerst die äußeren (Ohr),
// dann die inneren (Hals), damit Kinder mit den Eltern mitwandern.
// Die Gewichte beziehen sich immer auf die Ruheposition.
const verforme = (x: number, y: number, knochen: Knochen[]): [number, number] => {
// Die Gewichte beziehen sich auf die Ruheposition und werden pro Knochen einmal berechnet.
const verforme = (n: number, x: number, y: number, knochen: Knochen[], gewichte: Map<string, Float32Array>): [number, number] => {
let px = x;
let py = y;
for (const k of knochen) {
const w = k.gewicht(x, y);
const w = gewichte.get(k.name)![n];
if (w <= 0) continue;
const [ux, uy] = k.um;
if (k.art === 'drehen') {
@ -59,6 +83,8 @@ type Szene = {
kamera: THREE.OrthographicCamera;
pos: THREE.BufferAttribute;
ruhe: Float32Array;
masken: Map<string, Maske>;
gewichte: Map<string, Float32Array>;
};
export const VerformFigur: React.FC<Props> = ({daten, hoehe, x, y, knochen, drehung = 0, stauchX = 1, stauchY = 1, filter}) => {
@ -85,6 +111,8 @@ export const VerformFigur: React.FC<Props> = ({daten, hoehe, x, y, knochen, dreh
renderer.outputColorSpace = THREE.SRGBColorSpace;
const kamera = new THREE.OrthographicCamera(-mx, W + mx, my, -(H + my), -10, 10);
const SPALTEN = Math.ceil(W / ZELLE);
const ZEILEN = Math.ceil(H / ZELLE);
const ruhe = new Float32Array((SPALTEN + 1) * (ZEILEN + 1) * 2);
const uv = new Float32Array((SPALTEN + 1) * (ZEILEN + 1) * 2);
const index: number[] = [];
@ -111,24 +139,40 @@ export const VerformFigur: React.FC<Props> = ({daten, hoehe, x, y, knochen, dreh
geo.setIndex(index);
const welt = new THREE.Scene();
new THREE.TextureLoader().load(staticFile(daten.datei), (tex) => {
const maskenNamen = [...new Set(knochen.flatMap((k) => ('maske' in k.gewicht ? [k.gewicht.maske] : [])))];
const textur = new THREE.TextureLoader().loadAsync(staticFile(daten.datei));
Promise.all([textur, Promise.all(maskenNamen.map(ladeMaske))]).then(([tex, geladen]) => {
tex.colorSpace = THREE.SRGBColorSpace;
tex.premultiplyAlpha = true;
tex.anisotropy = 4;
const mat = new THREE.MeshBasicMaterial({map: tex, transparent: true, premultipliedAlpha: true});
welt.add(new THREE.Mesh(geo, mat));
szene.current = {renderer, szene: welt, kamera, pos, ruhe};
const masken = new Map(maskenNamen.map((m, i) => [m, geladen[i]]));
szene.current = {renderer, szene: welt, kamera, pos, ruhe, masken, gewichte: new Map()};
setBereit(true);
});
return () => renderer.dispose();
// Die Knochen-Namen und Masken stehen beim ersten Frame fest.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [daten.datei, W, H, mx, my]);
useLayoutEffect(() => {
const sz = szene.current;
if (!sz) return;
const n = sz.ruhe.length / 2;
for (const k of knochen) {
if (sz.gewichte.has(k.name)) continue;
const g = new Float32Array(n);
const gw = k.gewicht;
for (let i = 0; i < n; i++) {
const [px, py] = verforme(sz.ruhe[i * 2], sz.ruhe[i * 2 + 1], knochen);
const x = sz.ruhe[i * 2];
const y = sz.ruhe[i * 2 + 1];
g[i] = 'maske' in gw ? maskenWert(sz.masken.get(gw.maske)!, x, y) : gw(x, y);
}
sz.gewichte.set(k.name, g);
}
for (let i = 0; i < n; i++) {
const [px, py] = verforme(i, sz.ruhe[i * 2], sz.ruhe[i * 2 + 1], knochen, sz.gewichte);
sz.pos.setXYZ(i, px, -py, 0);
}
sz.pos.needsUpdate = true;

356
remotion/werkzeuge/rig.py Normal file
View file

@ -0,0 +1,356 @@
"""Erzeugt die Körperteil-Masken („Rig“) für die freigestellten Figuren.
Für jede Figur ist unten ein Skelett aus Knochenlinien (in Bildpixeln) definiert.
Jedes Pixel der Figur wird genau einem Teil zugeordnet:
1. Accessoires (Schal, Mütze, Fäustlinge, Stiefel …) über ihre Farbe,
2. alles andere über den kürzesten Weg *innerhalb der Figur* zum nächsten
Knochen. Der Weg führt nicht über Lücken, deshalb greift z. B. Lulus
Schwanz nie ins Bein daneben.
Ausgabe je Figur unter public/figuren/<figur>/:
teile/<teil>.png harte Maske des Teils (weiß = gehört dazu)
einfluss/<teil>.png weiche Maske für die Verformung: das Teil plus alles,
was daran hängt, mit weichem Übergang am Gelenk
uebersicht.png alle Teile farbig, zum Prüfen
Dazu src/rigs.ts mit Gelenkpunkten und Hierarchie.
Aufruf: python3 werkzeuge/rig.py (braucht numpy, scipy, pillow)
"""
import colorsys
import json
import os
import sys
import numpy as np
from PIL import Image, ImageDraw, ImageFilter
from scipy import ndimage
HIER = os.path.dirname(os.path.abspath(__file__))
FIGUREN = os.path.join(HIER, "..", "public", "figuren")
RIGS_TS = os.path.join(HIER, "..", "src", "rigs.ts")
# Knochen: name, eltern, von (Gelenk), bis. weich = Länge des Übergangs am Gelenk in px.
# Accessoire-Gruppen: Farbe + Bereich; ihre Teile werden über Startlinien getrennt.
FIGUREN_DEF = {
"lulu": {
"knochen": [
("rumpf", None, (130, 292), (232, 296), 0,
[(120, 245), (170, 238), (230, 250), (248, 290), (245, 345), (200, 352), (150, 347), (105, 337), (95, 300)]),
("hals", "rumpf", (124, 244), (112, 156), 14,
[(78, 150), (132, 140), (142, 178), (128, 205), (85, 205), (72, 176)]),
("kopf", "hals", (111, 150), (86, 72), 10,
[(10, 60), (40, 45), (60, 47), (80, 25), (110, 30), (140, 52), (146, 90), (136, 135), (126, 152),
(95, 160), (60, 156), (25, 140), (14, 110)]),
("ohr_links", "kopf", (44, 57), (12, 25), 5,
[(0, 10), (30, 12), (52, 40), (52, 62), (40, 70), (20, 60), (4, 40)]),
("ohr_rechts", "kopf", (146, 72), (186, 56), 5,
[(140, 56), (165, 42), (196, 45), (193, 62), (170, 78), (146, 88), (140, 78)]),
("horn_links", "kopf", (65, 44), (62, 8), 4,
[(55, 0), (78, 0), (78, 30), (73, 44), (58, 44), (56, 30)]),
("horn_rechts", "kopf", (122, 46), (126, 10), 4,
[(112, 8), (137, 8), (137, 28), (129, 48), (117, 48), (112, 30)]),
("bein_vorne_links", "rumpf", (92, 332), (80, 465), 12,
[(95, 320), (106, 335), (100, 400), (95, 445), (93, 472), (62, 472), (70, 440), (72, 400), (78, 340)]),
("bein_vorne_rechts", "rumpf", (124, 340), (127, 475), 12,
[(108, 338), (141, 335), (140, 400), (138, 470), (141, 487), (110, 487), (115, 440), (112, 380)]),
("bein_hinten_links", "rumpf", (186, 345), (193, 448), 12,
[(168, 342), (200, 346), (205, 400), (206, 460), (178, 460), (182, 420), (170, 380)]),
("bein_hinten_rechts", "rumpf", (230, 335), (235, 470), 12,
[(212, 332), (243, 324), (250, 360), (248, 420), (251, 481), (222, 481), (225, 430), (215, 380)]),
("schwanz", "rumpf", (246, 288), (266, 364), 6,
[(244, 282), (256, 300), (262, 330), (277, 350), (273, 373), (256, 373), (254, 340), (244, 300)]),
],
"accessoires": [
{
"farbe": "gruen",
"bereich": None,
"teile": [
("schal_schlinge", "hals", [(70, 228), (160, 205)], (124, 244), 10),
("schal_ende", "schal_schlinge", [(40, 292), (45, 372)], (62, 252), 10),
("schal_boden", "rumpf", [(230, 420), (350, 440)], (160, 360), 10),
("schal_boden", None, [(160, 362), (190, 388)], None, 0),
],
},
{
"farbe": "rot",
"bereich": (283, 378, 318, 404),
"teile": [("schal_boden", None, [(300, 392)], None, 0)],
},
],
},
"pino": {
"knochen": [
("koerper", None, (230, 430), (400, 560), 0,
[(20, 350), (150, 200), (245, 200), (262, 300), (275, 445), (300, 560), (200, 640), (60, 560)]),
("kopf", "koerper", (400, 465), (420, 320), 26,
[(245, 150), (300, 115), (420, 120), (520, 180), (554, 240), (554, 455), (470, 465), (380, 462),
(300, 450), (268, 400), (262, 300), (240, 240)]),
("ohr_links", "kopf", (290, 258), (256, 222), 6, "kreis", (272, 240, 27)),
("ohr_rechts", "kopf", (510, 270), (538, 248), 6, "kreis", (525, 258, 21)),
("bauch", "koerper", (330, 600), (430, 470), 20,
[(300, 450), (470, 460), (485, 520), (480, 600), (470, 640), (400, 645), (360, 610), (300, 545)]),
("arm_links", "koerper", (322, 462), (370, 540), 10,
[(322, 456), (345, 470), (380, 505), (392, 540), (372, 556), (345, 548), (330, 515), (318, 480)]),
("arm_rechts", "koerper", (458, 468), (455, 545), 10,
[(445, 465), (472, 470), (478, 520), (470, 556), (442, 556), (440, 510)]),
("fuss_links", "koerper", (310, 568), (320, 645), 10,
[(305, 565), (355, 563), (360, 600), (350, 655), (315, 658), (305, 620)]),
("fuss_rechts", "koerper", (495, 600), (540, 575), 10,
[(495, 560), (540, 548), (554, 575), (545, 640), (505, 640), (490, 600)]),
],
"accessoires": [
{
"farbe": "weiss",
"bereich": (360, 0, 500, 92),
"teile": [("bommel", "muetze", [(445, 45)], (445, 88), 8)],
},
{
"farbe": "blau",
"bereich": (250, 60, 554, 215),
"teile": [("muetze", "kopf", [(330, 170), (480, 170)], (400, 200), 10)],
},
],
},
"noa": {
"knochen": [
("rumpf", None, (115, 165), (115, 320), 0,
[(80, 150), (160, 150), (165, 200), (170, 320), (75, 325), (82, 200)]),
("kopf", "rumpf", (120, 156), (112, 40), 12,
[(55, 15), (115, 3), (165, 15), (190, 45), (197, 90), (182, 108), (170, 135), (140, 152), (100, 155),
(70, 140), (48, 118), (32, 95), (30, 60)]),
("zopf_links", "kopf", (48, 120), (15, 135), 6,
[(0, 120), (20, 108), (45, 112), (52, 130), (45, 150), (20, 155), (5, 145)]),
("zopf_rechts", "kopf", (178, 108), (200, 126), 6,
[(172, 100), (195, 95), (210, 108), (212, 130), (195, 148), (176, 140)]),
("arm_links", "rumpf", (64, 172), (52, 288), 12,
[(35, 180), (62, 165), (78, 180), (80, 290), (60, 300), (35, 295), (28, 240)]),
("arm_rechts", "rumpf", (166, 172), (183, 272), 12,
[(160, 170), (180, 180), (192, 230), (195, 275), (178, 280), (165, 240), (158, 190)]),
("bein_links", "rumpf", (92, 322), (92, 350), 6,
[(78, 320), (108, 320), (108, 352), (80, 352)]),
("bein_rechts", "rumpf", (128, 322), (132, 350), 6,
[(110, 320), (142, 320), (142, 352), (112, 352)]),
],
"accessoires": [
{
"farbe": "rot",
"bereich": (0, 260, 219, 340),
"teile": [
("hand_links", "arm_links", [(55, 310)], (54, 292), 6),
("hand_rechts", "arm_rechts", [(188, 292)], (186, 275), 6),
],
},
{
"farbe": "rot",
"bereich": (0, 340, 219, 429),
"teile": [
("stiefel_links", "bein_links", [(92, 362), (95, 415)], (92, 350), 6),
("stiefel_rechts", "bein_rechts", [(140, 362), (150, 410)], (132, 350), 6),
],
},
],
},
}
def farbmaske(rgb, art):
h, w, _ = rgb.shape
hsv = np.array([colorsys.rgb_to_hsv(*(p / 255)) for p in rgb.reshape(-1, 3)]).reshape(h, w, 3)
hue, sat, val = hsv[..., 0] * 360, hsv[..., 1], hsv[..., 2]
if art == "gruen":
m = (hue > 52) & (hue < 180) & (sat > 0.06) & (val > 0.12)
elif art == "blau":
m = (hue > 185) & (hue < 260) & (sat > 0.12)
elif art == "rot":
m = ((hue < 22) | (hue > 335)) & (sat > 0.35) & (val > 0.3)
elif art == "weiss":
m = (sat < 0.18) & (val > 0.72)
else:
raise ValueError(art)
return m
def linie_pixel(punkte, form):
bild = Image.new("L", (form[1], form[0]), 0)
d = ImageDraw.Draw(bild)
if len(punkte) == 1:
x, y = punkte[0]
d.ellipse((x - 2, y - 2, x + 2, y + 2), fill=1)
else:
d.line(punkte, fill=1, width=3)
return np.asarray(bild).astype(bool)
def flaechen_pixel(flaeche, form):
bild = Image.new("L", (form[1], form[0]), 0)
d = ImageDraw.Draw(bild)
if flaeche[0] == "kreis":
x, y, r = flaeche[1]
d.ellipse((x - r, y - r, x + r, y + r), fill=1)
else:
d.polygon(flaeche[0], fill=1)
return np.asarray(bild).astype(bool)
def ausbreiten(labels, erlaubt):
"""Wachsen der Labels innerhalb von `erlaubt`, Pixel für Pixel (Weg innerhalb der Figur)."""
labels = labels.copy()
kreuz = ndimage.generate_binary_structure(2, 2)
while True:
frei = erlaubt & (labels == 0)
if not frei.any():
break
gewachsen = ndimage.grey_dilation(labels, footprint=kreuz)
neu = frei & (gewachsen > 0)
if not neu.any():
break
labels[neu] = gewachsen[neu]
return labels
def rampe_entlang(form, von, bis, weich):
"""0 hinter dem Gelenk, 1 nach `weich` px in Knochenrichtung."""
if weich <= 0:
return np.ones(form)
yy, xx = np.mgrid[0 : form[0], 0 : form[1]].astype(float)
dx, dy = bis[0] - von[0], bis[1] - von[1]
l = np.hypot(dx, dy)
t = ((xx - von[0]) * dx + (yy - von[1]) * dy) / l
s = np.clip((t + weich * 0.4) / weich, 0, 1)
return s * s * (3 - 2 * s)
def baue(figur, d):
bild = Image.open(os.path.join(FIGUREN, f"{figur}.png")).convert("RGBA")
arr = np.asarray(bild).astype(float)
rgb, alpha = arr[..., :3], arr[..., 3]
figur_maske = alpha > 40
form = figur_maske.shape
namen = []
info = {} # name -> (eltern, gelenk, bis, weich)
for name, eltern, von, bis, weich, *_ in d["knochen"]:
namen.append(name)
info[name] = (eltern, von, bis, weich)
for gruppe in d["accessoires"]:
for name, eltern, linie, gelenk, weich in gruppe["teile"]:
if name not in info:
namen.append(name)
info[name] = (eltern, gelenk, linie[-1], weich)
nummer = {n: i + 1 for i, n in enumerate(namen)}
labels = np.zeros(form, dtype=np.int32)
# 1. Accessoires über Farbe
for gruppe in d["accessoires"]:
m = figur_maske & farbmaske(rgb, gruppe["farbe"])
if gruppe["bereich"]:
x0, y0, x1, y1 = gruppe["bereich"]
b = np.zeros(form, bool)
b[y0:y1, x0:x1] = True
m &= b
m = ndimage.binary_opening(m, iterations=1)
m = ndimage.binary_closing(m, iterations=2) & figur_maske
start = np.zeros(form, np.int32)
for name, _, linie, _, _ in gruppe["teile"]:
start[linie_pixel(linie, form) & m] = nummer[name]
# Flächen ohne Startlinie (z. B. Schalstück zwischen zwei Beinen) bekommen ab einer
# Mindestgröße das nächstgelegene Teil der Gruppe, kleine Farbflecken fallen weg.
lab, n = ndimage.label(m)
verbunden = set(np.unique(lab[start > 0])) - {0}
_, (iy, ix) = ndimage.distance_transform_edt(start == 0, return_indices=True)
for k in range(1, n + 1):
if k in verbunden:
continue
flaeche = lab == k
if flaeche.sum() >= 40:
ys, xs = np.where(flaeche)
y0, x0 = ys[len(ys) // 2], xs[len(xs) // 2]
start[flaeche] = start[iy[y0, x0], ix[y0, x0]]
verbunden.add(k)
behalten = np.isin(lab, list(verbunden)) & (lab > 0)
behalten = ndimage.binary_fill_holes(behalten) & figur_maske # z. B. Blümchen im Schal
gruppe_labels = ausbreiten(start, behalten)
frei = labels == 0
labels[frei & (gruppe_labels > 0)] = gruppe_labels[frei & (gruppe_labels > 0)]
# 2. Körper: Kernflächen der Teile (spätere Teile überschreiben frühere), dazu die Knochenlinie
start = np.zeros(form, np.int32)
rest = figur_maske & (labels == 0)
for name, _, von, bis, _, *flaeche in d["knochen"]:
kern = linie_pixel([von, bis], form)
if flaeche:
kern |= flaechen_pixel(flaeche, form)
start[kern & rest] = nummer[name]
for name in [k[0] for k in d["knochen"]]:
if not (start == nummer[name]).any():
print(f" Warnung: Knochen {name} liegt nicht auf der Figur", file=sys.stderr)
koerper = ausbreiten(start, rest)
labels[rest] = koerper[rest]
# 3. abgeschnittene Inseln über die ganze Figur auffüllen
labels = ausbreiten(labels, figur_maske)
ziel = os.path.join(FIGUREN, figur)
os.makedirs(os.path.join(ziel, "teile"), exist_ok=True)
os.makedirs(os.path.join(ziel, "einfluss"), exist_ok=True)
kinder = {n: [m for m in namen if info[m][0] == n] for n in namen}
def unterbaum(n):
return [n] + [x for k in kinder[n] for x in unterbaum(k)]
# Für die Verformung reicht jedes Teil über die Kontur hinaus: Punkte außerhalb der Figur
# bekommen das Teil des nächsten Figurpixels. Sonst bleiben Gitterpunkte am Rand stehen,
# während die Figur sich dreht, und die Randpixel verschmieren.
_, (iy, ix) = ndimage.distance_transform_edt(~figur_maske, return_indices=True)
labels_aussen = labels[iy, ix]
for name in namen:
teil = labels == nummer[name]
Image.fromarray((teil * 255).astype("uint8")).save(os.path.join(ziel, "teile", f"{name}.png"))
eltern, gelenk, bis, weich = info[name]
baum = np.isin(labels_aussen, [nummer[x] for x in unterbaum(name)])
weich_bild = Image.fromarray((baum * 255).astype("uint8")).filter(ImageFilter.GaussianBlur(1.5))
e = np.asarray(weich_bild).astype(float) / 255
if gelenk is not None:
e *= rampe_entlang(form, gelenk, bis, weich)
Image.fromarray((e * 255).astype("uint8")).save(os.path.join(ziel, "einfluss", f"{name}.png"))
# Übersicht
rng = np.random.default_rng(3)
farben = rng.integers(60, 255, size=(len(namen) + 1, 3))
farben[0] = (30, 30, 60)
uebersicht = farben[labels].astype(float)
uebersicht = uebersicht * 0.55 + rgb * 0.45
uebersicht[~figur_maske] = (30, 30, 60)
ueb = Image.fromarray(uebersicht.astype("uint8"))
dr = ImageDraw.Draw(ueb)
for name in namen:
ys, xs = np.where(labels == nummer[name])
if len(xs):
dr.text((int(xs.mean()) - 10, int(ys.mean()) - 4), name, fill=(255, 255, 255))
ueb.save(os.path.join(ziel, "uebersicht.png"))
groessen = {n: int((labels == nummer[n]).sum()) for n in namen}
print(figur, groessen)
return {
n: {"eltern": info[n][0], "um": list(info[n][1]) if info[n][1] else None}
for n in namen
}
def main():
rigs = {figur: baue(figur, d) for figur, d in FIGUREN_DEF.items()}
with open(RIGS_TS, "w") as f:
f.write("// Automatisch erzeugt von werkzeuge/rig.py – nicht von Hand ändern.\n")
f.write("// Gelenkpunkte (um) in Bildpixeln der Figur, eltern = woran das Teil hängt.\n")
f.write("// Masken: figuren/<figur>/teile/<teil>.png und figuren/<figur>/einfluss/<teil>.png\n\n")
f.write("export type RigTeil = {eltern: string | null; um: [number, number] | null};\n\n")
daten = json.dumps(rigs, indent="\t", ensure_ascii=False)
f.write(f"export const RIGS: Record<string, Record<string, RigTeil>> = {daten};\n")
if __name__ == "__main__":
main()