Remotion-Test: Szene „Noas Laterne“ aus freigestellten Figuren
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4e247ed309
commit
46ffe5c52c
15 changed files with 3524 additions and 0 deletions
2
remotion/.gitignore
vendored
Normal file
2
remotion/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
out/
|
||||
42
remotion/README.md
Normal file
42
remotion/README.md
Normal 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
2902
remotion/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
18
remotion/package.json
Normal file
18
remotion/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
remotion/public/figuren/lulu.png
Normal file
BIN
remotion/public/figuren/lulu.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 295 KiB |
BIN
remotion/public/figuren/noa.png
Normal file
BIN
remotion/public/figuren/noa.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 160 KiB |
BIN
remotion/public/figuren/pino.png
Normal file
BIN
remotion/public/figuren/pino.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 672 KiB |
14
remotion/src/Root.tsx
Normal file
14
remotion/src/Root.tsx
Normal 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
44
remotion/src/figuren.ts
Normal 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
4
remotion/src/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import {registerRoot} from 'remotion';
|
||||
import {Root} from './Root';
|
||||
|
||||
registerRoot(Root);
|
||||
177
remotion/src/szenen/LaterneTest.tsx
Normal file
177
remotion/src/szenen/LaterneTest.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
163
remotion/src/teile/Abendhuegel.tsx
Normal file
163
remotion/src/teile/Abendhuegel.tsx
Normal 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%)',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
89
remotion/src/teile/Figur.tsx
Normal file
89
remotion/src/teile/Figur.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
57
remotion/src/teile/Laterne.tsx
Normal file
57
remotion/src/teile/Laterne.tsx
Normal 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
12
remotion/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Reference in a new issue