diff --git a/remotion/package-lock.json b/remotion/package-lock.json index 7c509ad..0b61520 100644 --- a/remotion/package-lock.json +++ b/remotion/package-lock.json @@ -10,10 +10,12 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "remotion": "^4.0.300", + "three": "^0.169.0", "typescript": "^5.6.3" }, "devDependencies": { - "@types/react": "^18.3.12" + "@types/react": "^18.3.12", + "@types/three": "^0.169.0" } }, "node_modules/@babel/parser": { @@ -807,6 +809,13 @@ "zod": "3.22.3" } }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -866,6 +875,35 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.169.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.169.0.tgz", + "integrity": "sha512-oan7qCgJBt03wIaK+4xPWclYRPG9wzcg7Z2f5T8xYTNEF95kh0t0lklxLLYBDo7gQiGLYzE6iF4ta7nXF2bcsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": "*", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.18.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -1022,6 +1060,13 @@ "@xtuc/long": "4.2.2" } }, + "node_modules/@webgpu/types": { + "version": "0.1.74", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz", + "integrity": "sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -1575,6 +1620,13 @@ "pend": "~1.2.0" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, "node_modules/fs-monkey": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz", @@ -1802,6 +1854,13 @@ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "license": "MIT" }, + "node_modules/meshoptimizer": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", + "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", + "dev": true, + "license": "MIT" + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -2625,6 +2684,12 @@ } } }, + "node_modules/three": { + "version": "0.169.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.169.0.tgz", + "integrity": "sha512-Ed906MA3dR4TS5riErd4QBsRGPcx+HBDX2O5yYE5GqJeFQTPU+M56Va/f/Oph9X7uZo3W3o4l2ZhBZ6f6qUv0w==", + "license": "MIT" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", diff --git a/remotion/package.json b/remotion/package.json index 84aea88..acdc28e 100644 --- a/remotion/package.json +++ b/remotion/package.json @@ -10,9 +10,11 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "remotion": "^4.0.300", + "three": "^0.169.0", "typescript": "^5.6.3" }, "devDependencies": { - "@types/react": "^18.3.12" + "@types/react": "^18.3.12", + "@types/three": "^0.169.0" } } diff --git a/remotion/public/figuren/lulu.png b/remotion/public/figuren/lulu.png index b101fa6..b3c71ba 100644 Binary files a/remotion/public/figuren/lulu.png and b/remotion/public/figuren/lulu.png differ diff --git a/remotion/public/hintergrund/abend.png b/remotion/public/hintergrund/abend.png new file mode 100644 index 0000000..b24a21c Binary files /dev/null and b/remotion/public/hintergrund/abend.png differ diff --git a/remotion/public/hintergrund/papier.png b/remotion/public/hintergrund/papier.png new file mode 100644 index 0000000..f5d7dac Binary files /dev/null and b/remotion/public/hintergrund/papier.png differ diff --git a/remotion/public/hintergrund/vordergrund.png b/remotion/public/hintergrund/vordergrund.png new file mode 100644 index 0000000..d25a170 Binary files /dev/null and b/remotion/public/hintergrund/vordergrund.png differ diff --git a/remotion/src/Root.tsx b/remotion/src/Root.tsx index ea03b57..e053309 100644 --- a/remotion/src/Root.tsx +++ b/remotion/src/Root.tsx @@ -1,14 +1,17 @@ import React from 'react'; import {Composition} from 'remotion'; +import {HintergrundAbend, PapierStruktur, VordergrundAbend} from './szenen/Hintergruende'; import {LaterneTest} from './szenen/LaterneTest'; +import {VerformTest} from './szenen/VerformTest'; + +const BILD = {fps: 30, width: 1920, height: 1080} as const; export const Root: React.FC = () => ( - + <> + + + + + + ); diff --git a/remotion/src/bewegung.ts b/remotion/src/bewegung.ts new file mode 100644 index 0000000..5d5e7c9 --- /dev/null +++ b/remotion/src/bewegung.ts @@ -0,0 +1,43 @@ +// Hilfen für weiche Bewegung: Kurven, Federn, Gewichte für die Verformung. + +export const klemm01 = (v: number) => Math.min(1, Math.max(0, v)); + +// Weicher Übergang 0 → 1 zwischen a und b (a > b ist erlaubt und kehrt die Richtung um). +export const glatt = (a: number, b: number, v: number) => { + const t = klemm01((v - a) / (b - a)); + return t * t * (3 - 2 * t); +}; + +// Ease-in-out zwischen Schlüsselwerten: [[frame, wert], ...] +export const kurve = (frame: number, punkte: [number, number][]) => { + if (frame <= punkte[0][0]) return punkte[0][1]; + for (let i = 1; i < punkte.length; i++) { + const [f0, v0] = punkte[i - 1]; + const [f1, v1] = punkte[i]; + if (frame <= f1) { + const t = (frame - f0) / (f1 - f0); + return v0 + (v1 - v0) * (0.5 - 0.5 * Math.cos(Math.PI * t)); + } + } + return punkte[punkte.length - 1][1]; +}; + +// Gedämpfte Feder, die einem Zielsignal nachläuft (für Nachschwingen von +// Bommel, Schal, Schwanz). Wird pro Frame deterministisch von 0 an simuliert. +export const feder = ( + ziel: (f: number) => number, + frame: number, + {steifheit = 0.08, daempfung = 0.18} = {}, +) => { + let x = ziel(0); + let v = 0; + for (let f = 1; f <= frame; f++) { + v += (ziel(f) - x) * steifheit - v * daempfung; + x += v; + } + return x; +}; + +// Gewicht 1 innerhalb von r0 um (cx, cy), fällt bis r1 auf 0. +export const kreis = (cx: number, cy: number, r0: number, r1: number) => (x: number, y: number) => + glatt(r1, r0, Math.hypot(x - cx, y - cy)); diff --git a/remotion/src/szenen/Hintergruende.tsx b/remotion/src/szenen/Hintergruende.tsx new file mode 100644 index 0000000..b33bac2 --- /dev/null +++ b/remotion/src/szenen/Hintergruende.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import {AbsoluteFill} from 'remotion'; +import {Abendhuegel, Papier, Vordergrund} from '../teile/Abendhuegel'; + +// Standbilder zum Vorberechnen (npx remotion still …). Die SVG-Filter sind +// teuer; als PNG kosten sie beim Rendern der Szenen fast nichts. + +export const HintergrundAbend: React.FC = () => ; + +export const VordergrundAbend: React.FC = () => ; + +// Nur die Papierstruktur, ohne Vignette (die ist billig und bleibt in der Szene). +export const PapierStruktur: React.FC = () => ( + + + +); diff --git a/remotion/src/szenen/VerformTest.tsx b/remotion/src/szenen/VerformTest.tsx new file mode 100644 index 0000000..13f756a --- /dev/null +++ b/remotion/src/szenen/VerformTest.tsx @@ -0,0 +1,139 @@ +import React from 'react'; +import {AbsoluteFill, Img, staticFile, useCurrentFrame} from 'remotion'; +import {feder, glatt, kreis, kurve} from '../bewegung'; +import {LULU, PINO} from '../figuren'; +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. + +const BODEN = 884; + +// ---------- Lulu ---------- +const halsKurve = (f: number) => + kurve(f, [ + [0, 0], + [26, 0], + [38, 5], // Anlauf: erst kurz zurück + [74, -30], + [118, -30], + [150, -7], + ]); +const neugierKurve = (f: number) => kurve(f, [[0, 0], [82, 0], [96, 11], [124, 11], [140, 0]]); +const ohrZucken = (f: number, bei: number[]) => (bei.some((b) => f >= b && f < b + 4) ? 1 : 0); + +const luluKnochen = (f: number): Knochen[] => { + const hals = feder(halsKurve, f, {steifheit: 0.11, daempfung: 0.2}); + const halsTraege = feder(halsKurve, f, {steifheit: 0.05, daempfung: 0.16}); + 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 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)}, + ]; +}; + +// ---------- Pino ---------- +// Sprung: Anlauf (Ducken), Flug, Landung mit Nachfedern. +const SPRUNG = {duck: 84, ab: 93, auf: 111}; +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; + 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 pinoWerte = (f: number) => { + const stauch = feder(stauchZiel, f, {steifheit: 0.3, daempfung: 0.22}); + const hoch = sprungHoehe(f) * 80; + 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 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)}, + ]; + return {stauch, hoch, knochen}; +}; + +export const VerformTest: React.FC = () => { + const f = useCurrentFrame(); + const zoom = 1.04 - f * 0.0002; + const pino = pinoWerte(f); + const luluNeigen = kurve(f, [[30, 0], [74, -2.5], [118, -2.5], [150, 0]]) + Math.sin(f * 0.05) * 0.5; + const abend = 'brightness(0.86) saturate(0.95)'; + + return ( + + + + + + + + + + + ); +}; diff --git a/remotion/src/teile/Abendhuegel.tsx b/remotion/src/teile/Abendhuegel.tsx index e4d7804..83b29c7 100644 --- a/remotion/src/teile/Abendhuegel.tsx +++ b/remotion/src/teile/Abendhuegel.tsx @@ -32,7 +32,7 @@ export const AquarellFilter: React.FC = () => ( {/* großflächige, weiche Farbschwankungen statt Körnung */} - + @@ -143,21 +143,24 @@ export const Vordergrund: React.FC<{kameraX: number}> = ({kameraX}) => { }; // Papierstruktur und Vignette über allem. -export const Papier: React.FC = () => ( +export const Papier: React.FC<{nurStruktur?: boolean}> = ({nurStruktur = false}) => ( <> - + -
+ />} ); diff --git a/remotion/src/teile/VerformFigur.tsx b/remotion/src/teile/VerformFigur.tsx new file mode 100644 index 0000000..4dc449b --- /dev/null +++ b/remotion/src/teile/VerformFigur.tsx @@ -0,0 +1,162 @@ +import React, {useLayoutEffect, useRef, useState} from 'react'; +import {continueRender, delayRender, staticFile} from 'remotion'; +import * as THREE from 'three'; +import type {FigurDaten} from '../figuren'; + +// Eine Figur als verformbares Gitter (WebGL). Statt das Bild starr zu +// 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; + +export type Knochen = + | {art: 'drehen'; um: [number, number]; grad: number; gewicht: Gewicht} + | {art: 'stauchen'; um: [number, number]; sx: number; sy: number; gewicht: Gewicht}; + +const RAND = 0.35; // Platz um das Bild, falls Teile über den Rand hinausschwingen +const SPALTEN = 48; +const ZEILEN = 64; + +// 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] => { + let px = x; + let py = y; + for (const k of knochen) { + const w = k.gewicht(x, y); + if (w <= 0) continue; + const [ux, uy] = k.um; + if (k.art === 'drehen') { + const a = (k.grad * w * Math.PI) / 180; + const dx = px - ux; + const dy = py - uy; + px = ux + dx * Math.cos(a) - dy * Math.sin(a); + py = uy + dx * Math.sin(a) + dy * Math.cos(a); + } else { + px = ux + (px - ux) * (1 + (k.sx - 1) * w); + py = uy + (py - uy) * (1 + (k.sy - 1) * w); + } + } + return [px, py]; +}; + +type Props = { + daten: FigurDaten; + hoehe: number; + x: number; + y: number; + knochen: Knochen[]; + drehung?: number; + stauchX?: number; + stauchY?: number; + filter?: string; +}; + +type Szene = { + renderer: THREE.WebGLRenderer; + szene: THREE.Scene; + kamera: THREE.OrthographicCamera; + pos: THREE.BufferAttribute; + ruhe: Float32Array; +}; + +export const VerformFigur: React.FC = ({daten, hoehe, x, y, knochen, drehung = 0, stauchX = 1, stauchY = 1, filter}) => { + const canvas = useRef(null); + const szene = useRef(null); + const [bereit, setBereit] = useState(false); + const [warten] = useState(() => delayRender(`Figur ${daten.datei}`)); + + const W = daten.breite; + const H = daten.hoehe; + const mx = W * RAND; + const my = H * RAND; + const s = hoehe / H; + + useLayoutEffect(() => { + const renderer = new THREE.WebGLRenderer({ + canvas: canvas.current!, + alpha: true, + antialias: true, + premultipliedAlpha: true, + preserveDrawingBuffer: true, + }); + renderer.setClearColor(0x000000, 0); + renderer.outputColorSpace = THREE.SRGBColorSpace; + const kamera = new THREE.OrthographicCamera(-mx, W + mx, my, -(H + my), -10, 10); + + const ruhe = new Float32Array((SPALTEN + 1) * (ZEILEN + 1) * 2); + const uv = new Float32Array((SPALTEN + 1) * (ZEILEN + 1) * 2); + const index: number[] = []; + for (let j = 0; j <= ZEILEN; j++) { + for (let i = 0; i <= SPALTEN; i++) { + const n = j * (SPALTEN + 1) + i; + ruhe[n * 2] = (i / SPALTEN) * W; + ruhe[n * 2 + 1] = (j / ZEILEN) * H; + uv[n * 2] = i / SPALTEN; + uv[n * 2 + 1] = 1 - j / ZEILEN; + if (i < SPALTEN && j < ZEILEN) { + const a = n; + const b = n + 1; + const c = n + SPALTEN + 1; + const d = c + 1; + index.push(a, c, b, b, c, d); + } + } + } + const geo = new THREE.BufferGeometry(); + const pos = new THREE.BufferAttribute(new Float32Array((SPALTEN + 1) * (ZEILEN + 1) * 3), 3); + geo.setAttribute('position', pos); + geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2)); + geo.setIndex(index); + + const welt = new THREE.Scene(); + new THREE.TextureLoader().load(staticFile(daten.datei), (tex) => { + 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}; + setBereit(true); + }); + return () => renderer.dispose(); + }, [daten.datei, W, H, mx, my]); + + useLayoutEffect(() => { + const sz = szene.current; + if (!sz) return; + const n = sz.ruhe.length / 2; + for (let i = 0; i < n; i++) { + const [px, py] = verforme(sz.ruhe[i * 2], sz.ruhe[i * 2 + 1], knochen); + sz.pos.setXYZ(i, px, -py, 0); + } + sz.pos.needsUpdate = true; + sz.renderer.render(sz.szene, sz.kamera); + if (bereit) continueRender(warten); + }); + + const breite = (W + 2 * mx) * s; + const hoeheGesamt = (H + 2 * my) * s; + // Füße (Bildmitte unten) liegen auf (x, y) + const fussX = (mx + W / 2) * s; + const fussY = (my + H) * s; + + return ( + + ); +};