Remotion-Test: Szene „Noas Laterne“ aus freigestellten Figuren

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pascal Scholz 2026-10-28 19:20:01 +00:00
parent 4e247ed309
commit 46ffe5c52c
15 changed files with 3524 additions and 0 deletions

2
remotion/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
out/

42
remotion/README.md Normal file
View file

@ -0,0 +1,42 @@
# Remotion – Szenen aus Figuren-Grafiken
Animiert die freigestellten Figuren (Pino, Noa, Lulu) per Code, ohne
Higgsfield-Clips. Erster Test: `LaterneTest` (14 s, Abendspaziergang mit
Noas Laterne, ohne Ton).
## Aufbau
| Pfad | Inhalt |
| --- | --- |
| `public/figuren/` | freigestellte PNGs der Figuren |
| `src/figuren.ts` | Bildmaße, Augenpositionen (fürs Blinzeln), Noas Hand |
| `src/teile/Figur.tsx` | Figur an den Füßen verankert: verschieben, drehen, stauchen, blinzeln |
| `src/teile/Laterne.tsx` | Laternenstab mit pendelnder Papierlaterne |
| `src/teile/Abendhuegel.tsx` | Hintergrund in Aquarell-Optik (SVG), Vordergrund, Papierstruktur |
| `src/szenen/LaterneTest.tsx` | die Testszene mit Zeitplan |
## Benutzen
```bash
npm install
npx remotion studio # Vorschau im Browser
npx remotion render src/index.ts LaterneTest /shared/youtube/tests/laterne-test.mp4
```
Fertige Videos gehören nach `/shared/youtube/`, nicht ins Git.
Im VPS-Container fehlen Chrome einige Systembibliotheken. Sie liegen ohne
Root-Rechte unter `~/.local/chrome-libs` (Debian-Pakete, nur entpackt) und
werden so eingebunden:
```bash
export LD_LIBRARY_PATH=$HOME/.local/chrome-libs/root/usr/lib/x86_64-linux-gnu
```
## Figuren freistellen
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.
## Bekannte Grenzen
- Die Figuren haben nur die Pose ihres Bildes. Neue Posen brauchen ein neues
Bild (z. B. aus Gemini).
- Die SVG-Filter im Hintergrund werden pro Frame neu berechnet und machen das
Rendern langsam. Für lange Folgen die Hintergrund-Ebenen einmal als PNG
vorberechnen.

2902
remotion/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

18
remotion/package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "pino-remotion",
"private": true,
"scripts": {
"studio": "remotion studio",
"render": "remotion render"
},
"dependencies": {
"@remotion/cli": "^4.0.300",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"remotion": "^4.0.300",
"typescript": "^5.6.3"
},
"devDependencies": {
"@types/react": "^18.3.12"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 KiB

14
remotion/src/Root.tsx Normal file
View file

@ -0,0 +1,14 @@
import React from 'react';
import {Composition} from 'remotion';
import {LaterneTest} from './szenen/LaterneTest';
export const Root: React.FC = () => (
<Composition
id="LaterneTest"
component={LaterneTest}
durationInFrames={420}
fps={30}
width={1920}
height={1080}
/>
);

44
remotion/src/figuren.ts Normal file
View file

@ -0,0 +1,44 @@
// Freigestellte Figuren mit Bildmaßen und Augenpositionen (in Pixeln des PNG).
// Die Augen braucht <Figur>, um Blinzeln darüberzulegen.
export type Auge = {x: number; y: number; r: number; haut: string};
export type FigurDaten = {
datei: string;
breite: number;
hoehe: number;
augen: Auge[];
};
export const PINO: FigurDaten = {
datei: 'figuren/pino.png',
breite: 554,
hoehe: 658,
augen: [
{x: 368, y: 323, r: 23, haut: 'rgb(250,240,214)'},
{x: 489, y: 324, r: 19, haut: 'rgb(244,226,198)'},
],
};
export const NOA: FigurDaten = {
datei: 'figuren/noa.png',
breite: 219,
hoehe: 429,
augen: [
{x: 91, y: 86, r: 7, haut: 'rgb(247,225,208)'},
{x: 153, y: 83, r: 7, haut: 'rgb(247,225,208)'},
],
};
export const LULU: FigurDaten = {
datei: 'figuren/lulu.png',
breite: 368,
hoehe: 487,
augen: [
{x: 106, y: 105, r: 16, haut: 'rgb(246,230,197)'},
{x: 39, y: 94, r: 14, haut: 'rgb(252,236,210)'},
],
};
// Noas rechter Fäustling (vom Betrachter aus rechts), dort hängt die Laterne.
export const NOA_HAND = {x: 188, y: 294};

4
remotion/src/index.ts Normal file
View file

@ -0,0 +1,4 @@
import {registerRoot} from 'remotion';
import {Root} from './Root';
registerRoot(Root);

View file

@ -0,0 +1,177 @@
import React from 'react';
import {AbsoluteFill, Easing, interpolate, useCurrentFrame} from 'remotion';
import {LULU, NOA, NOA_HAND, PINO} from '../figuren';
import {Abendhuegel, Papier, Vordergrund} from '../teile/Abendhuegel';
import {Figur} from '../teile/Figur';
import {Laterne, laternenMitte} from '../teile/Laterne';
// Testszene „Noas Laterne“ (14 s, ohne Ton):
// Noa läuft mit Laterne über den Abendhügel, Pino hüpft hinterher.
// Ein riesiger Schatten erschreckt die beiden – es ist nur Lulu.
const BODEN = 884;
const NOA_H = 430;
const PINO_H = 170;
const LULU_H = 720;
const klemm = {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'} as const;
const weich = Easing.bezier(0.33, 0, 0.2, 1);
// Gedämpfte Schwingung ab `start` (für Schreck und Pendel).
const nachschwingen = (frame: number, start: number, staerke: number, tempo = 0.35, abklingen = 22) =>
frame < start ? 0 : staerke * Math.exp(-(frame - start) / abklingen) * Math.sin((frame - start) * tempo);
const huepfer = (frame: number, start: number, ende: number, anzahl: number) => {
if (frame < start || frame > ende) return 0;
const t = (frame - start) / (ende - start);
return Math.abs(Math.sin(t * Math.PI * anzahl));
};
export const LaterneTest: React.FC = () => {
const frame = useCurrentFrame();
const kameraX = interpolate(frame, [0, 420], [70, -70]);
const zoom = interpolate(frame, [0, 420], [1.07, 1.0]);
// Noa: läuft von links herein, erschrickt, freut sich.
const noaX = interpolate(frame, [20, 165], [-260, 860], {...klemm, easing: Easing.bezier(0.2, 0, 0.35, 1)});
const noaLaeuft = frame >= 20 && frame < 165;
const schritt = frame * 0.42;
const noaWippen = noaLaeuft ? Math.abs(Math.sin(schritt)) : 0;
const schreckSprung = interpolate(frame, [205, 210, 218], [0, 1, 0], klemm);
const freude = huepfer(frame, 300, 330, 2);
const noaY = BODEN - noaWippen * 10 - schreckSprung * 18 - freude * 22;
const noaDreh =
(noaLaeuft ? Math.sin(schritt) * 2 : 0) - interpolate(frame, [205, 212, 250], [0, 5, 0], klemm) + Math.sin(frame * 0.05) * 0.6;
const noaAtmen = 1 + Math.sin(frame * 0.09) * 0.006;
const schwung =
Math.sin(frame * 0.11) * 5 + (noaLaeuft ? Math.sin(schritt) * 9 : 0) + nachschwingen(frame, 205, 24) + nachschwingen(frame, 300, 12, 0.3, 18);
const leuchten = 0.5 + 0.5 * Math.sin(frame * 0.7) * Math.sin(frame * 0.23);
// Pino: hüpft hinterher, versteckt sich hinter Noa, kommt wieder vor.
const pinoAnkunft = interpolate(frame, [42, 178], [-480, 650], {...klemm, easing: Easing.bezier(0.2, 0, 0.35, 1)});
const pinoVersteck = interpolate(frame, [212, 236], [0, 140], {...klemm, easing: weich});
const pinoZurueck = interpolate(frame, [318, 348], [0, -150], {...klemm, easing: weich});
const zittern = frame > 236 && frame < 300 ? Math.sin(frame * 2.3) * 2.2 : 0;
const pinoX = pinoAnkunft + pinoVersteck + pinoZurueck + zittern;
const pinoHuepft = frame >= 42 && frame < 178 ? Math.abs(Math.sin(frame * 0.5)) : huepfer(frame, 318, 348, 2);
const pinoY = BODEN + 4 - pinoHuepft * 30;
const pinoLandung = 1 - (1 - pinoHuepft) * (pinoHuepft > 0 ? 0.09 : 0);
const pinoGeduckt = interpolate(frame, [214, 230, 300, 316], [1, 0.9, 0.9, 1], klemm);
// Schatten: steigt hinter dem Hügel auf, verschwindet, als Lulu kommt.
const schattenHoch = interpolate(frame, [190, 238], [380, 0], {...klemm, easing: weich});
const schattenSicht = interpolate(frame, [190, 215, 262, 296], [0, 0.6, 0.6, 0], klemm);
// Lulu: schiebt sich von rechts ins Bild und richtet sich auf.
const luluX = interpolate(frame, [255, 318], [2560, 1430], {...klemm, easing: Easing.bezier(0.25, 0.1, 0.25, 1.15)});
const luluDreh = interpolate(frame, [255, 300, 330], [-16, 3, 0], {...klemm, easing: weich}) + Math.sin(frame * 0.05) * 0.8;
const noaS = NOA_H / NOA.hoehe;
const noaBreite = NOA.breite * noaS;
const licht = laternenMitte(NOA_HAND);
const lichtX = noaX - noaBreite / 2 + licht.x * noaS;
const lichtY = noaY - NOA_H + licht.y * noaS;
const iris = interpolate(frame, [372, 414], [2300, 0], {...klemm, easing: Easing.in(Easing.cubic)});
const abend = 'brightness(0.86) saturate(0.95)';
return (
<AbsoluteFill style={{backgroundColor: '#24325c', overflow: 'hidden'}}>
<AbsoluteFill style={{transform: `scale(${zoom})`}}>
<Abendhuegel kameraX={kameraX} />
<AbsoluteFill style={{transform: `translateX(${kameraX}px)`}}>
{/* Lulus Schatten auf dem Hügel */}
<Figur
daten={LULU}
hoehe={1000}
x={1330}
y={830 + schattenHoch}
filter="brightness(0) blur(4px)"
deckkraft={schattenSicht * 0.7}
/>
{/* warmer Lichtschein der Laterne auf der Wiese */}
<div
style={{
position: 'absolute',
left: lichtX - 330,
top: BODEN - 70,
width: 660,
height: 150,
borderRadius: '50%',
background: 'radial-gradient(closest-side, rgba(255,200,110,0.45), rgba(255,200,110,0))',
}}
/>
<Figur
daten={PINO}
hoehe={PINO_H}
x={pinoX}
y={pinoY}
stauchY={pinoLandung * pinoGeduckt}
stauchX={2 - pinoLandung}
drehung={pinoHuepft * 4}
blinzeln={[110, 188, 262, 280, 360]}
filter={abend}
/>
<Figur
daten={NOA}
hoehe={NOA_H}
x={noaX}
y={noaY}
drehung={noaDreh}
stauchY={noaAtmen}
blinzeln={[70, 182, 240, 338, 392]}
filter={abend}
>
<Laterne hand={NOA_HAND} schwung={schwung} leuchten={leuchten} />
</Figur>
<Figur
daten={LULU}
hoehe={LULU_H}
x={luluX}
y={BODEN + 6}
drehung={luluDreh}
blinzeln={[305, 352, 398]}
filter="brightness(0.82) saturate(0.92)"
/>
{/* Lichtschein um die Laterne */}
<div
style={{
position: 'absolute',
left: lichtX - 380,
top: lichtY - 380,
width: 760,
height: 760,
borderRadius: '50%',
mixBlendMode: 'screen',
opacity: 0.75 + leuchten * 0.15,
background: 'radial-gradient(closest-side, rgba(255,214,140,0.55), rgba(255,170,80,0.18) 45%, rgba(255,170,80,0) 100%)',
}}
/>
</AbsoluteFill>
<Vordergrund kameraX={kameraX} />
</AbsoluteFill>
<Papier />
{/* Kreisblende zum Schluss, zieht sich um die Laterne zusammen */}
<svg viewBox="0 0 1920 1080" style={{position: 'absolute', inset: 0, width: '100%', height: '100%'}}>
<defs>
<mask id="blende">
<rect width={1920} height={1080} fill="white" />
<circle cx={960 + (lichtX + kameraX - 960) * zoom} cy={540 + (lichtY - 540) * zoom} r={iris} fill="black" />
</mask>
</defs>
<rect width={1920} height={1080} fill="#0f1426" mask="url(#blende)" />
</svg>
</AbsoluteFill>
);
};

View file

@ -0,0 +1,163 @@
import React from 'react';
import {interpolate, random, useCurrentFrame} from 'remotion';
// Hintergrund in Aquarell-Optik, komplett als SVG gezeichnet:
// Abendhimmel, Sterne, Mond, Hügel in mehreren Ebenen (Parallax über kameraX).
const B = 1920;
const H = 1080;
const STERNE = new Array(70).fill(0).map((_, i) => ({
x: random(`sx${i}`) * (B + 400) - 200,
y: random(`sy${i}`) * 520,
r: 1.2 + random(`sr${i}`) * 2.6,
phase: random(`sp${i}`) * Math.PI * 2,
start: 10 + random(`st${i}`) * 70,
}));
const Ebene: React.FC<{versatz: number; children: React.ReactNode}> = ({versatz, children}) => (
<g transform={`translate(${versatz} 0)`}>{children}</g>
);
export const AquarellFilter: React.FC = () => (
<defs>
<filter id="aquarell" x="-10%" y="-10%" width="120%" height="120%">
{/* welliger Rand */}
<feTurbulence type="fractalNoise" baseFrequency="0.009" numOctaves="2" seed="4" result="rausch" />
<feDisplacementMap in="SourceGraphic" in2="rausch" scale="16" xChannelSelector="R" yChannelSelector="G" result="rand" />
{/* dunklerer Farbrand, wie wenn Wasserfarbe am Rand antrocknet */}
<feMorphology in="rand" operator="erode" radius="5" result="innen" />
<feGaussianBlur in="innen" stdDeviation="6" result="innenWeich" />
<feComposite in="rand" in2="innenWeich" operator="out" result="saum" />
<feColorMatrix in="saum" type="matrix" values="0.7 0 0 0 0 0 0.7 0 0 0 0 0 0.75 0 0 0 0 0 0.5 0" result="saumDunkel" />
{/* großflächige, weiche Farbschwankungen statt Körnung */}
<feTurbulence type="fractalNoise" baseFrequency="0.0035 0.006" numOctaves="2" seed="9" result="flecken" />
<feColorMatrix in="flecken" type="matrix" values="0 0 0 0 1 0 0 0 0 0.95 0 0 0 0 0.85 0 0 0 -0.55 0.3" result="hell" />
<feComposite in="hell" in2="rand" operator="in" result="wolken" />
<feMerge>
<feMergeNode in="rand" />
<feMergeNode in="wolken" />
<feMergeNode in="saumDunkel" />
</feMerge>
</filter>
<filter id="weich">
<feGaussianBlur stdDeviation="18" />
</filter>
<linearGradient id="himmel" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#24325c" />
<stop offset="0.45" stopColor="#5b5590" />
<stop offset="0.75" stopColor="#c98a9a" />
<stop offset="1" stopColor="#f2c48d" />
</linearGradient>
<radialGradient id="mondschein">
<stop offset="0" stopColor="#fff8e0" stopOpacity="0.55" />
<stop offset="1" stopColor="#fff8e0" stopOpacity="0" />
</radialGradient>
<linearGradient id="wiese" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#6d8a63" />
<stop offset="0.3" stopColor="#4c6a55" />
<stop offset="1" stopColor="#2f4646" />
</linearGradient>
</defs>
);
const Baum: React.FC<{x: number; y: number; s: number; farbe: string}> = ({x, y, s, farbe}) => (
<g transform={`translate(${x} ${y}) scale(${s})`} fill={farbe}>
<rect x={-5} y={-40} width={10} height={44} />
<ellipse cx={0} cy={-70} rx={34} ry={44} />
<ellipse cx={-20} cy={-52} rx={22} ry={20} />
<ellipse cx={22} cy={-56} rx={20} ry={22} />
</g>
);
export const Abendhuegel: React.FC<{kameraX: number}> = ({kameraX}) => {
const frame = useCurrentFrame();
return (
<svg viewBox={`0 0 ${B} ${H}`} style={{position: 'absolute', inset: 0, width: '100%', height: '100%'}}>
<AquarellFilter />
<rect x={-200} y={-200} width={B + 400} height={H + 400} fill="url(#himmel)" />
<Ebene versatz={kameraX * 0.1}>
<circle cx={1520} cy={190} r={260} fill="url(#mondschein)" />
<circle cx={1520} cy={190} r={62} fill="#fbf1d2" filter="url(#aquarell)" />
<circle cx={1497} cy={176} r={11} fill="#efe0b8" opacity={0.7} />
<circle cx={1540} cy={210} r={8} fill="#efe0b8" opacity={0.6} />
{STERNE.map((s, i) => {
const auf = interpolate(frame, [s.start, s.start + 25], [0, 1], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'});
const funkeln = 0.6 + 0.4 * Math.sin(frame * 0.12 + s.phase);
return <circle key={i} cx={s.x} cy={s.y} r={s.r} fill="#fffbe8" opacity={auf * funkeln} />;
})}
</Ebene>
<Ebene versatz={kameraX * 0.25}>
<path
d="M -300 690 C 100 560, 420 600, 700 640 S 1250 540, 1600 600 S 2100 650, 2300 620 L 2300 1100 L -300 1100 Z"
fill="#6a6f9c"
filter="url(#aquarell)"
/>
<Baum x={260} y={612} s={0.9} farbe="#4f5683" />
<Baum x={330} y={606} s={0.7} farbe="#4f5683" />
<Baum x={1340} y={590} s={1} farbe="#4f5683" />
<Baum x={1660} y={604} s={0.8} farbe="#4f5683" />
</Ebene>
<Ebene versatz={kameraX * 0.5}>
<path
d="M -300 790 C 200 700, 650 720, 1000 760 S 1700 700, 2300 740 L 2300 1100 L -300 1100 Z"
fill="#4d6377"
filter="url(#aquarell)"
/>
</Ebene>
<Ebene versatz={kameraX}>
<path
d="M -300 880 C 300 840, 900 850, 1300 862 S 2100 900, 2400 890 L 2400 1200 L -300 1200 Z"
fill="url(#wiese)"
filter="url(#aquarell)"
/>
</Ebene>
</svg>
);
};
// Vordergrund: Grasbüschel und dunkle Büsche an den Rändern, laufen schneller als die Kamera.
export const Vordergrund: React.FC<{kameraX: number}> = ({kameraX}) => {
const frame = useCurrentFrame();
const wind = Math.sin(frame * 0.06) * 3;
const bueschel = [80, 210, 520, 760, 1010, 1180, 1530, 1790, 2000];
return (
<svg viewBox={`0 0 ${B} ${H}`} style={{position: 'absolute', inset: 0, width: '100%', height: '100%'}}>
<g transform={`translate(${kameraX * 1.3} 0)`}>
<ellipse cx={-40} cy={1060} rx={260} ry={150} fill="#22343a" filter="url(#aquarell)" />
<ellipse cx={1990} cy={1070} rx={280} ry={140} fill="#22343a" filter="url(#aquarell)" />
{bueschel.map((x, i) => (
<g key={i} transform={`translate(${x} ${905 + (i % 3) * 14})`} stroke="#3b5646" strokeWidth={4} strokeLinecap="round" fill="none">
<path d={`M 0 0 Q ${-6 + wind} -22 ${-12 + wind * 1.5} -40`} />
<path d={`M 6 0 Q ${8 + wind} -26 ${4 + wind * 1.5} -48`} />
<path d={`M 12 0 Q ${20 + wind} -18 ${26 + wind * 1.5} -34`} />
</g>
))}
</g>
</svg>
);
};
// Papierstruktur und Vignette über allem.
export const Papier: React.FC = () => (
<>
<svg viewBox={`0 0 ${B} ${H}`} style={{position: 'absolute', inset: 0, width: '100%', height: '100%', mixBlendMode: 'multiply', opacity: 0.35}}>
<filter id="papier">
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="3" />
<feColorMatrix type="matrix" values="0 0 0 0 0.55 0 0 0 0 0.5 0 0 0 0 0.45 0 0 0 0.6 0" />
</filter>
<rect width={B} height={H} filter="url(#papier)" />
</svg>
<div
style={{
position: 'absolute',
inset: 0,
background: 'radial-gradient(ellipse at 50% 55%, rgba(0,0,0,0) 55%, rgba(10,12,30,0.45) 100%)',
}}
/>
</>
);

View file

@ -0,0 +1,89 @@
import React from 'react';
import {Img, interpolate, staticFile, useCurrentFrame} from 'remotion';
import type {FigurDaten} from '../figuren';
// Eine freigestellte Figur, verankert an den Füßen (x, y = Mitte unten).
// Bewegung kommt nur über Verschieben, Drehen und Stauchen; dazu Blinzeln.
type Props = {
daten: FigurDaten;
hoehe: number;
x: number;
y: number;
drehung?: number;
stauchX?: number;
stauchY?: number;
blinzeln?: number[];
filter?: string;
deckkraft?: number;
children?: React.ReactNode;
};
const BLINZEL_DAUER = 6;
export const Figur: React.FC<Props> = ({
daten,
hoehe,
x,
y,
drehung = 0,
stauchX = 1,
stauchY = 1,
blinzeln = [],
filter,
deckkraft = 1,
children,
}) => {
const frame = useCurrentFrame();
const breite = (daten.breite / daten.hoehe) * hoehe;
const lid = blinzeln.reduce((zu, start) => {
const t = frame - start;
if (t < 0 || t > BLINZEL_DAUER) return zu;
return Math.max(zu, interpolate(t, [0, 2, 4, BLINZEL_DAUER], [0, 1, 1, 0]));
}, 0);
return (
<div
style={{
position: 'absolute',
left: x - breite / 2,
top: y - hoehe,
width: breite,
height: hoehe,
transformOrigin: '50% 100%',
transform: `rotate(${drehung}deg) scale(${stauchX}, ${stauchY})`,
opacity: deckkraft,
}}
>
<Img src={staticFile(daten.datei)} style={{width: '100%', height: '100%', filter}} />
<svg
viewBox={`0 0 ${daten.breite} ${daten.hoehe}`}
style={{position: 'absolute', inset: 0, width: '100%', height: '100%', overflow: 'visible', filter}}
>
{lid > 0 &&
daten.augen.map((a, i) => (
<g key={i}>
<ellipse cx={a.x} cy={a.y - a.r * (1 - lid) * 0.6} rx={a.r * 1.25} ry={a.r * 1.2 * lid} fill={a.haut} />
{lid > 0.8 && (
<path
d={`M ${a.x - a.r} ${a.y} Q ${a.x} ${a.y + a.r * 0.7} ${a.x + a.r} ${a.y}`}
stroke="#3b2a20"
strokeWidth={Math.max(1.5, a.r * 0.22)}
strokeLinecap="round"
fill="none"
/>
)}
</g>
))}
</svg>
{/* Zubehör (z. B. die Laterne) ohne den Abendfilter der Figur */}
{children && (
<svg
viewBox={`0 0 ${daten.breite} ${daten.hoehe}`}
style={{position: 'absolute', inset: 0, width: '100%', height: '100%', overflow: 'visible'}}
>
{children}
</svg>
)}
</div>
);
};

View file

@ -0,0 +1,57 @@
import React from 'react';
// Laternenstab mit Papierlaterne, gezeichnet im Koordinatensystem der Figur,
// die sie trägt. `hand` ist der Griffpunkt, `schwung` der Pendelwinkel in Grad.
export const STAB = {dx: 96, dy: -74};
export const SCHNUR = 26;
export const LATERNE_R = 30;
export const Laterne: React.FC<{hand: {x: number; y: number}; schwung: number; leuchten: number}> = ({
hand,
schwung,
leuchten,
}) => {
const spitze = {x: hand.x + STAB.dx, y: hand.y + STAB.dy};
const mitte = SCHNUR + LATERNE_R;
return (
<g>
<defs>
<radialGradient id="laternenlicht" cx="0.45" cy="0.45" r="0.6">
<stop offset="0" stopColor="#fffbe0" />
<stop offset="0.55" stopColor={`rgb(255,${190 + leuchten * 30},90)`} />
<stop offset="1" stopColor="#e0762a" />
</radialGradient>
</defs>
<line x1={hand.x - 8} y1={hand.y + 10} x2={spitze.x} y2={spitze.y} stroke="#5e3b22" strokeWidth={8} strokeLinecap="round" />
<line x1={hand.x - 8} y1={hand.y + 9} x2={spitze.x} y2={spitze.y - 1} stroke="#b07a48" strokeWidth={3} strokeLinecap="round" />
<g transform={`rotate(${schwung} ${spitze.x} ${spitze.y})`}>
<line x1={spitze.x} y1={spitze.y} x2={spitze.x} y2={spitze.y + SCHNUR} stroke="#5a3d28" strokeWidth={1.6} />
<rect x={spitze.x - 13} y={spitze.y + SCHNUR - 4} width={26} height={8} rx={3} fill="#9b3b2b" />
<ellipse cx={spitze.x} cy={spitze.y + mitte} rx={LATERNE_R + 2} ry={LATERNE_R - 2} fill="url(#laternenlicht)" />
{[-0.6, 0, 0.6].map((k) => (
<path
key={k}
d={`M ${spitze.x + k * LATERNE_R} ${spitze.y + SCHNUR + 3} Q ${spitze.x + k * LATERNE_R * 1.7} ${spitze.y + mitte} ${spitze.x + k * LATERNE_R} ${spitze.y + mitte + LATERNE_R - 3}`}
stroke="#d9822b"
strokeWidth={1.4}
fill="none"
opacity={0.7}
/>
))}
<rect x={spitze.x - 13} y={spitze.y + mitte + LATERNE_R - 6} width={26} height={8} rx={3} fill="#9b3b2b" />
<path
d={`M ${spitze.x - 6} ${spitze.y + mitte + LATERNE_R + 2} l -3 14 M ${spitze.x} ${spitze.y + mitte + LATERNE_R + 2} l 0 16 M ${spitze.x + 6} ${spitze.y + mitte + LATERNE_R + 2} l 3 14`}
stroke="#c0392b"
strokeWidth={2}
strokeLinecap="round"
/>
</g>
</g>
);
};
// Mittelpunkt der Laterne relativ zum Griffpunkt, ohne Pendelwinkel (für den Lichtschein).
export const laternenMitte = (hand: {x: number; y: number}) => ({
x: hand.x + STAB.dx,
y: hand.y + STAB.dy + SCHNUR + LATERNE_R,
});

12
remotion/tsconfig.json Normal file
View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}