Folge 2: fertiges Nest vorberechnet, Nachtfilter nur auf Figuren, bessere Hand beim Malen

Szene „Pino will nicht schlafen“ rendert damit 2,7-mal, „Gute Nacht“ 2,2-mal so schnell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pascal Scholz 2026-10-29 19:48:57 +00:00
parent cbacc2fd97
commit 8f3ce567dd
9 changed files with 108 additions and 23 deletions

View file

@ -131,8 +131,19 @@ python3 werkzeuge/posen.py <zielordner> <clips_frei> # Posen und C
``` ```
Mit `pip install "rembg[gpu]"` nutzt `rembg` eine Nvidia-Grafikkarte Mit `pip install "rembg[gpu]"` nutzt `rembg` eine Nvidia-Grafikkarte
automatisch (auf dem VPS gibt es keine; dort ca. 3 s pro Bild). 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` bzw. am Nest in `natur.tsx`):
`npx remotion still src/index.ts F2Ort-garten public/orte/garten.webp --image-format=webp` `npx remotion still src/index.ts F2Ort-garten public/orte/garten.webp --image-format=webp`
Bilder mit durchsichtigem Hintergrund (Zimmerwand, Tisch, Nest) als PNG
rendern (`--image-format=png`): Remotions WebP-Ausgabe speichert keine
Transparenz.
### Was das Rendern bremst
Gemessen im Entwurf (halbe Auflösung, 4 Kerne auf dem VPS): die meisten
Szenen schaffen 3,5–5,5 Bilder/s. Teuer sind viele einzeln gezeichnete
Elemente (z. B. über 70 Blätter im Nest) und CSS-Filter oder Mischmodi über
das ganze Bild. Darum: Fertiges wie das Nest als Bild vorberechnen
(`F2Nest-…`), Filter nur auf die Figuren legen (`filter`-Prop von `Figur`/
`ClipFigur`/`Nest`), keine `mixBlendMode`-Ebenen über das ganze Bild.
## 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -4,7 +4,8 @@ import {HintergrundAbend, PapierStruktur, VordergrundAbend} from './szenen/Hinte
import {LaterneTest} from './szenen/LaterneTest'; import {LaterneTest} from './szenen/LaterneTest';
import {VerformTest} from './szenen/VerformTest'; import {VerformTest} from './szenen/VerformTest';
import {FolgenKompositionen} from './baukasten/folge'; import {FolgenKompositionen} from './baukasten/folge';
import {ORTE, OrtStill} from './baukasten/orte'; import {NEST_BILD} from './baukasten/natur';
import {NestStill, ORTE, OrtStill} from './baukasten/orte';
import {F02} from './folge02/Folge02'; 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;
@ -17,6 +18,9 @@ export const Root: React.FC = () => (
<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} />
<FolgenKompositionen folge={F02} /> <FolgenKompositionen folge={F02} />
{(['hinten', 'vorne'] as const).map((seite) => (
<Composition key={seite} id={`F2Nest-${seite}`} component={NestStill} defaultProps={{seite}} durationInFrames={1} fps={30} width={NEST_BILD.b} height={NEST_BILD.h} />
))}
{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

@ -33,12 +33,13 @@ type Lage = {
stauchY?: number; stauchY?: number;
deckkraft?: number; deckkraft?: number;
schatten?: boolean; schatten?: boolean;
filter?: string; // CSS-Filter nur auf die Figur (z. B. Nacht), billiger als über das ganze Bild
children: React.ReactNode; children: React.ReactNode;
figur: FigurName; figur: FigurName;
}; };
// Gemeinsamer Rahmen: Leinwand der Figur so legen, dass der Fußpunkt auf (x, y) liegt. // Gemeinsamer Rahmen: Leinwand der Figur so legen, dass der Fußpunkt auf (x, y) liegt.
const Rahmen: React.FC<Lage> = ({x, y, hoehe, spiegeln, drehung = 0, stauchX = 1, stauchY = 1, deckkraft = 1, schatten = true, figur, children}) => { const Rahmen: React.FC<Lage> = ({x, y, hoehe, spiegeln, drehung = 0, stauchX = 1, stauchY = 1, deckkraft = 1, schatten = true, filter, figur, children}) => {
const f = FIGUREN[figur]; const f = FIGUREN[figur];
const s = hoehe / f.refHoehe; const s = hoehe / f.refHoehe;
return ( return (
@ -57,7 +58,7 @@ const Rahmen: React.FC<Lage> = ({x, y, hoehe, spiegeln, drehung = 0, stauchX = 1
/> />
)} )}
<div style={{position: 'absolute', transform: `rotate(${drehung}deg) scale(${(spiegeln ? -1 : 1) * stauchX}, ${stauchY})`, transformOrigin: '0 0'}}> <div style={{position: 'absolute', transform: `rotate(${drehung}deg) scale(${(spiegeln ? -1 : 1) * stauchX}, ${stauchY})`, transformOrigin: '0 0'}}>
<div style={{position: 'absolute', left: -f.fussX * s, top: -f.fussY * s, transform: `scale(${s})`, transformOrigin: '0 0'}}> <div style={{position: 'absolute', left: -f.fussX * s, top: -f.fussY * s, transform: `scale(${s})`, transformOrigin: '0 0', filter}}>
{children} {children}
</div> </div>
</div> </div>

View file

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import {random, useCurrentFrame} from 'remotion'; import {Img, random, staticFile, useCurrentFrame} from 'remotion';
import {SCHRIFT} from './untertitel'; import {SCHRIFT} from './untertitel';
// Kleine, per Code gezeichnete Dinge: Blätter, Vögel, Schnee, Nest, Zzz, Funkeln. // Kleine, per Code gezeichnete Dinge: Blätter, Vögel, Schnee, Nest, Zzz, Funkeln.
@ -168,14 +168,33 @@ const nestBlaetter = (seite: 'hinten' | 'vorne') =>
}); });
const NEST = {hinten: nestBlaetter('hinten'), vorne: nestBlaetter('vorne')}; const NEST = {hinten: nestBlaetter('hinten'), vorne: nestBlaetter('vorne')};
export const Nest: React.FC<{x: number; y: number; voll?: number; seite: 'hinten' | 'vorne'; s?: number}> = ({x, y, voll = 1, seite, s = 1}) => ( // Das fertige Nest (voll = 1) kommt als vorberechnetes Bild (public/orte/nest_*.png,
<div style={{position: 'absolute', left: x, top: y, transform: `scale(${s})`, transformOrigin: '0 0'}}> // Komposition F2Nest-…); nur das wachsende Nest wird Blatt für Blatt gezeichnet.
export const NEST_BILD = {b: 440, h: 260, x: 220, y: 180}; // Bildgröße und Lage des Ankerpunkts
export const NestBlaetter: React.FC<{voll?: number; seite: 'hinten' | 'vorne'}> = ({voll = 1, seite}) => (
<>
{seite === 'hinten' && ( {seite === 'hinten' && (
<div style={{position: 'absolute', left: -170, top: -30, width: 340, height: 60, borderRadius: '50%', background: 'radial-gradient(closest-side, rgba(70,45,25,0.45), rgba(70,45,25,0))'}} /> <div style={{position: 'absolute', left: -170, top: -30, width: 340, height: 60, borderRadius: '50%', background: 'radial-gradient(closest-side, rgba(70,45,25,0.45), rgba(70,45,25,0))'}} />
)} )}
{NEST[seite].map((b, i) => {NEST[seite].map((b, i) =>
b.reihe < voll ? <Blatt key={i} x={b.x * (0.5 + 0.5 * voll)} y={b.y * (0.4 + 0.6 * voll)} r={b.r} s={b.s} farbe={b.farbe} /> : null, b.reihe < voll ? <Blatt key={i} x={b.x * (0.5 + 0.5 * voll)} y={b.y * (0.4 + 0.6 * voll)} r={b.r} s={b.s} farbe={b.farbe} /> : null,
)} )}
</>
);
export const Nest: React.FC<{x: number; y: number; voll?: number; seite: 'hinten' | 'vorne'; s?: number; filter?: string}> = ({x, y, voll = 1, seite, s = 1, filter}) =>
voll >= 1 ? (
<Img
src={staticFile(`orte/nest_${seite}.png`)}
style={{position: 'absolute', left: x - NEST_BILD.x * s, top: y - NEST_BILD.y * s, width: NEST_BILD.b * s, height: NEST_BILD.h * s, filter}}
/>
) : (
<div style={{position: 'absolute', left: x, top: y, transform: `scale(${s})`, transformOrigin: '0 0', filter}}>
{seite === 'hinten' && (
<div style={{position: 'absolute', left: -170, top: -30, width: 340, height: 60, borderRadius: '50%', background: 'radial-gradient(closest-side, rgba(70,45,25,0.45), rgba(70,45,25,0))'}} />
)}
<NestBlaetter voll={voll} seite={seite} />
</div> </div>
); );

View file

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import {AbsoluteFill, Img, random, staticFile} from 'remotion'; import {AbsoluteFill, Img, random, staticFile} from 'remotion';
import './untertitel'; // lädt die Schrift für den Kalender import './untertitel'; // lädt die Schrift für den Kalender
import {NEST_BILD, NestBlaetter} from './natur';
// Orte der Folge 2 in Aquarell-Optik. Werden einmal als PNG vorberechnet // Orte der Folge 2 in Aquarell-Optik. Werden einmal als PNG vorberechnet
// (Kompositionen „F2Ort…“, siehe README) und in den Szenen als Bild benutzt. // (Kompositionen „F2Ort…“, siehe README) und in den Szenen als Bild benutzt.
@ -385,7 +386,7 @@ export const KalenderMalen: React.FC<{p: number; wackeln: number}> = ({p, wackel
let spitze: {x: number; y: number}; let spitze: {x: number; y: number};
let farbe = '#c8642c'; let farbe = '#c8642c';
if (p < titelAnteil) { if (p < titelAnteil) {
spitze = {x: 560 + tp * 800, y: 175 + Math.sin(tp * 60) * 14}; spitze = {x: 440 + tp * 1060, y: 175 + Math.sin(tp * 60) * 14};
} else { } else {
const t = kTuer(aktiv); const t = kTuer(aktiv);
farbe = t.farbe; farbe = t.farbe;
@ -400,7 +401,7 @@ export const KalenderMalen: React.FC<{p: number; wackeln: number}> = ({p, wackel
<g transform="rotate(-2 960 540)"> <g transform="rotate(-2 960 540)">
<g filter="url(#stift)"> <g filter="url(#stift)">
<clipPath id="titelclip"> <clipPath id="titelclip">
<rect x={540} y={100} width={tp * 840} height={120} /> <rect x={400} y={100} width={tp * 1120} height={120} />
</clipPath> </clipPath>
<text x={960} y={190} textAnchor="middle" fontSize={78} fontFamily="Fredoka, sans-serif" fontWeight={600} fill="#c8642c" clipPath="url(#titelclip)"> <text x={960} y={190} textAnchor="middle" fontSize={78} fontFamily="Fredoka, sans-serif" fontWeight={600} fill="#c8642c" clipPath="url(#titelclip)">
Pinos Winterfest-Kalender Pinos Winterfest-Kalender
@ -431,11 +432,15 @@ export const KalenderMalen: React.FC<{p: number; wackeln: number}> = ({p, wackel
<path d="M -3 -17 L 0 -24 L 3 -17 Z" fill={farbe} /> <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={22} height={260} rx={4} fill={farbe} />
<rect x={-11} y={-6} width={7} height={260} fill="rgba(255,255,255,0.18)" /> <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" /> {/* Ärmel */}
<ellipse cx={-40} cy={118} rx={20} ry={26} fill="#f2c4a2" /> <path d="M -62 150 L 66 150 L 120 900 L -150 900 Z" fill="#d39b3a" />
<ellipse cx={30} cy={124} rx={22} ry={18} fill="#f2c4a2" /> <path d="M -66 140 Q 2 164 70 140 L 74 176 Q 2 198 -70 176 Z" fill="#c1862c" />
<path d="M -70 175 L 60 175 L 110 900 L -150 900 Z" fill="#d39b3a" /> {/* Handrücken und Finger, die den Stift halten */}
<path d="M -72 172 Q -6 196 62 172 L 66 200 Q -6 222 -76 200 Z" fill="#c1862c" /> <ellipse cx={4} cy={118} rx={52} ry={44} fill="#f2c4a2" stroke="#dba585" strokeWidth={2} />
<ellipse cx={-30} cy={98} rx={15} ry={24} fill="#f2c4a2" stroke="#dba585" strokeWidth={2} transform="rotate(-25 -30 98)" />
<ellipse cx={-6} cy={72} rx={17} ry={13} fill="#f2c4a2" stroke="#dba585" strokeWidth={2} />
<ellipse cx={20} cy={80} rx={16} ry={12} fill="#f2c4a2" stroke="#dba585" strokeWidth={2} />
<ellipse cx={38} cy={94} rx={14} ry={11} fill="#f2c4a2" stroke="#dba585" strokeWidth={2} />
</g> </g>
</svg> </svg>
); );
@ -455,6 +460,15 @@ export const ORTE: Record<string, React.FC> = {
kalender: () => <KalenderSVG />, kalender: () => <KalenderSVG />,
kalender_leer: () => <KalenderSVG leer />, kalender_leer: () => <KalenderSVG leer />,
}; };
// Fertiges Nest zum Vorberechnen (440 × 260, Anker bei NEST_BILD.x/y)
export const NestStill: React.FC<{seite: 'hinten' | 'vorne'}> = ({seite}) => (
<AbsoluteFill>
<div style={{position: 'absolute', left: NEST_BILD.x, top: NEST_BILD.y}}>
<NestBlaetter seite={seite} />
</div>
</AbsoluteFill>
);
export const OrtStill: React.FC<{name: string}> = ({name}) => { export const OrtStill: React.FC<{name: string}> = ({name}) => {
const K = ORTE[name]; const K = ORTE[name];
return ( return (

View file

@ -894,6 +894,15 @@ export const POSEN: Record<string, Pose> = {
}; };
export const CLIPS: Record<string, ClipDaten> = { export const CLIPS: Record<string, ClipDaten> = {
"lulu_C1_gehen-schleife": {
"figur": "lulu",
"datei": "bibliothek/clips/lulu_C1_gehen-schleife.webm",
"x": 488.67289719626166,
"y": 31.077881619937692,
"b": 422.2305295950155,
"h": 705.1464174454828,
"frames": 121
},
"lulu_C9_hals-runter": { "lulu_C9_hals-runter": {
"figur": "lulu", "figur": "lulu",
"datei": "bibliothek/clips/lulu_C9_hals-runter.webm", "datei": "bibliothek/clips/lulu_C9_hals-runter.webm",
@ -903,6 +912,15 @@ export const CLIPS: Record<string, ClipDaten> = {
"h": 715.8629283489096, "h": 715.8629283489096,
"frames": 121 "frames": 121
}, },
"noa_C1_gehen-schleife": {
"figur": "noa",
"datei": "bibliothek/clips/noa_C1_gehen-schleife.webm",
"x": 537.9688473520249,
"y": 147.8878504672897,
"b": 312.9221183800623,
"h": 555.1152647975077,
"frames": 121
},
"noa_C4_winken": { "noa_C4_winken": {
"figur": "noa", "figur": "noa",
"datei": "bibliothek/clips/noa_C4_winken.webm", "datei": "bibliothek/clips/noa_C4_winken.webm",
@ -912,6 +930,24 @@ export const CLIPS: Record<string, ClipDaten> = {
"h": 546.5420560747663, "h": 546.5420560747663,
"frames": 121 "frames": 121
}, },
"noa_C5_hinsetzen": {
"figur": "noa",
"datei": "bibliothek/clips/noa_C5_hinsetzen.webm",
"x": 531.5389408099688,
"y": 159.67601246105917,
"b": 430.803738317757,
"h": 542.2554517133956,
"frames": 121
},
"pino_C1_gehen-schleife": {
"figur": "pino",
"datei": "bibliothek/clips/pino_C1_gehen-schleife.webm",
"x": 475.8130841121495,
"y": 65.37071651090342,
"b": 469.38317757009344,
"h": 623.7009345794391,
"frames": 121
},
"pino_C3_freudensprung": { "pino_C3_freudensprung": {
"figur": "pino", "figur": "pino",
"datei": "bibliothek/clips/pino_C3_freudensprung.webm", "datei": "bibliothek/clips/pino_C3_freudensprung.webm",

View file

@ -12,6 +12,7 @@ import {plane} from './plan';
const NOA_X7 = 1140; 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};
const NACHT = 'brightness(0.62) saturate(0.8) hue-rotate(-8deg)'; // nur auf Figuren und Nest
export const S7 = plane('s7', export const S7 = plane('s7',
[ [
@ -68,7 +69,6 @@ export const Szene7: React.FC = () => {
<> <>
<Kamera {...kam}> <Kamera {...kam}>
<Ort name="garten_nacht" /> <Ort name="garten_nacht" />
<div style={{position: 'absolute', inset: 0, filter: 'brightness(0.62) saturate(0.8) hue-rotate(-8deg)'}}>
{f < winken ? ( {f < winken ? (
<Figur <Figur
pose={[ pose={[
@ -79,15 +79,16 @@ export const Szene7: React.FC = () => {
x={NOA_X7} x={NOA_X7}
y={BODEN} y={BODEN}
stauchY={atmen(f, 2)} stauchY={atmen(f, 2)}
filter={NACHT}
/> />
) : ( ) : (
<ClipFigur name="noa_C4_winken" start={winken} tempo={1.2} x={NOA_X7} y={BODEN} /> <ClipFigur name="noa_C4_winken" start={winken} tempo={1.2} x={NOA_X7} y={BODEN} filter={NACHT} />
)} )}
<Nest x={NEST_X} y={BODEN} seite="hinten" /> <Nest x={NEST_X} y={BODEN} seite="hinten" filter={NACHT} />
<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="pino_B_schlaeft-eingerollt" x={NEST_X + 10} y={BODEN - 34} stauchY={atmen(f, 1, 0.02)} schatten={false} /> <Figur pose="pino_B_schlaeft-eingerollt" x={NEST_X + 10} y={BODEN - 34} stauchY={atmen(f, 1, 0.02)} schatten={false} filter={NACHT} />
</div> </div>
<Nest x={NEST_X} y={BODEN} seite="vorne" /> <Nest x={NEST_X} y={BODEN} seite="vorne" filter={NACHT} />
<Figur <Figur
pose={[ pose={[
[0, 'lulu_A5_blick-links'], [0, 'lulu_A5_blick-links'],
@ -96,10 +97,10 @@ export const Szene7: React.FC = () => {
]} ]}
x={LULU_X7} x={LULU_X7}
y={BODEN - 10} y={BODEN - 10}
filter={NACHT}
stauchY={atmen(f, 3, 0.006)} stauchY={atmen(f, 3, 0.006)}
drehung={f > z.t1.start && f < z.t1.ende ? Math.sin((f - z.t1.start) * 0.25) * 2 : 0} drehung={f > z.t1.start && f < z.t1.ende ? Math.sin((f - z.t1.start) * 0.25) * 2 : 0}
/> />
</div>
{/* warmes Licht der Laterne */} {/* warmes Licht der Laterne */}
<div <div
style={{ style={{
@ -108,8 +109,7 @@ export const Szene7: React.FC = () => {
top: LATERNE.y - 560, top: LATERNE.y - 560,
width: 1040, width: 1040,
height: 900, height: 900,
background: `radial-gradient(closest-side, rgba(255, 200, 110, ${0.42 * flackern}), rgba(255, 180, 90, 0.12) 55%, rgba(255, 180, 90, 0) 100%)`, background: `radial-gradient(closest-side, rgba(255, 200, 110, ${0.3 * flackern}), rgba(255, 180, 90, 0.08) 55%, rgba(255, 180, 90, 0) 100%)`,
mixBlendMode: 'screen',
}} }}
/> />
<Laterne frame={f} /> <Laterne frame={f} />