youtube/remotion/src/lieder/lied01/Lied01.tsx
Lara 2ebdf72a66 Liedvideo Lied 1 „Eins, zwei – Pino ist dabei!“ (Entwurf) in DE/ES/FR
- src/lieder/lied01: Äpfel fliegen bei jeder Zahl in den Korb, Zahl + Finger oben rechts,
  Äpfel leuchten beim Zählen, Lulu holt sich den obersten Apfel (Clip C9), „nur noch …?“ → 9
- Wortzeiten je Sprache mit faster-whisper gemessen (Zeilenanfang: Wortende − 0,38 s, an Lied 3 geeicht)
- Komposition „Lied01“, Sprache über --props wie bei den Folgen; Gesprochenes noch ohne Aufnahmen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 08:16:40 +00:00

406 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React from 'react';
import {AbsoluteFill, Audio, Sequence, interpolate, spring, staticFile, useCurrentFrame} from 'remotion';
import {INTRO_OPTIONEN, Intro, OUTRO, Outro} from '../../baukasten/buch';
import {ClipFigur, Figur, Kamera, type Posenfolge} from '../../baukasten/figur';
import {Blende, LULU_KOPF, Vignette, huepfer, kamFahrt, luluMaulC9, punkt} from '../../baukasten/helfer';
import {Apfel, Funkeln, Ruf} from '../../baukasten/natur';
import {BODEN, Ort} from '../../baukasten/orte';
import {SCHRIFT} from '../../baukasten/untertitel';
import {plane} from '../../baukasten/zeit';
import {Karaoke} from '../karaoke';
import {Noten} from '../lied03/Lied03';
import {DAUER, ECHO, LIED, T, ZAHLEN, ZEILEN} from './zeiten';
// Liedvideo „Eins, zwei – Pino ist dabei!“ (Pinos Liederbuch, Lied 1, Zählen bis 10) – ENTWURF.
// Ton je Sprache: Suno-Fassungen auf −14 LUFS in public/musik/lieder/lied01-<sprache>.mp3 (siehe zeiten.ts).
// Sprache wie bei den Folgen: npx remotion render … Lied01 --props='{"sprache":"es"}'.
// Bei jeder Zahl fliegt ein Apfel vom alten Apfelbaum in Pinos Korb; oben rechts stehen die Zahl
// und die Finger (Daumen = 1, wie in Deutschland üblich; ab 6 ist die erste Hand voll).
// Im Instrumental holt Lulu sich den obersten Apfel: „Jetzt sind es nur noch …?“ – „Neun!“
// Gesprochenes (ERZ-Ansage, Lulus Frage, Noas Frage, „Mampf!“) fehlt noch; im Entwurf nur als Schrift.
const FPS = 30;
const s = (sek: number) => Math.round(sek * FPS);
const SCHLAG = (FPS * 60) / 102; // gemessen am Refrain: ein Takt ≈ 2,35 s
const ANFANG = s(T.c1);
const PINO_X = 600;
const KORB_X = 790;
const RAND_Y = BODEN - 118; // Oberkante des Korbs
const NOA_X = 1430;
// Lulu senkt den Hals (Clip lulu_C9_hals-runter) bis Clipbild 75: dann ist das Maul
// 302 px links von ihren Füßen genau auf Höhe des obersten Apfels im Korb.
const C9_TEMPO = 1.25;
const C9_START = 17;
const C9_TIEF = 75;
const c9 = (bilder: number) => (bilder * FPS) / 24 / C9_TEMPO;
const LULU_X = KORB_X + 302;
const LULU_Y = BODEN - 6;
// Plätze der zehn Äpfel im Korb (relativ zur Korbmitte am Rand): unten 5, darüber 4, oben 1
const PLAETZE: [number, number][] = [
[-76, -10], [-38, -13], [0, -14], [38, -13], [76, -10],
[-57, -42], [-19, -45], [19, -45], [57, -42],
[0, -76],
];
// Wo die Äpfel vorher im alten Apfelbaum hängen
const AM_BAUM: [number, number][] = [
[470, 300], [300, 340], [610, 390], [180, 420], [420, 430],
[540, 480], [250, 500], [680, 520], [360, 545], [130, 560],
];
const FLUG = 20; // Frames vom Baum in den Korb
// Zeitplan des Gags
const G = s(T.gag);
const RUNTER = G + 24;
const UNTEN = RUNTER + c9(C9_TIEF - C9_START);
const HOCH = UNTEN + 20;
const OBEN = HOCH + c9(C9_TIEF);
const MAMPF = OBEN + 8;
const imAbschnitt = (f: number, von: number, bis: number) => f >= s(von) && f < s(bis);
const kamera = (f: number) => {
const P: [number, number, number, number][] = [
[T.c1, 960, 650, 1.08],
[T.v1 - 0.6, 830, 640, 1.2], // Baum, Korb, Pino
[T.c2, 960, 650, 1.08],
[T.v2 - 0.6, 830, 640, 1.2],
[T.echo, 900, 690, 1.28], // Äpfel im Korb leuchten
[T.gag, 960, 620, 1.1], // Lulu ganz im Bild
[T.frage, 900, 670, 1.22],
[T.c3, 960, 650, 1.08],
[T.ende, 960, 630, 1.04],
];
const p: [number, number, number, number][] = [];
P.forEach(([t, x, y, z], i) => {
if (i > 0) p.push([s(t) - 21, ...P[i - 1].slice(1)] as [number, number, number, number]);
p.push([s(t), x, y, z]);
});
return kamFahrt(f, p);
};
// Noa klatscht im Refrain auf jeden zweiten Schlag
const klatschen = (von: number, bis: number): Posenfolge => {
const folge: Posenfolge = [];
for (let t = ANFANG; t < s(bis); t += 2 * SCHLAG) {
if (t < s(von)) continue;
const k = Math.round(t);
folge.push([k - 3, 'noa_A_klatschen'], [k + 4, 'noa_A3_lachen']);
}
return folge;
};
const zeigen = (zeiten: number[]): Posenfolge => zeiten.flatMap((t) => [[s(t) - 2, 'noa_B_zeigen'], [s(t) + 12, 'noa_A3_lachen']] as Posenfolge);
const NOA: Posenfolge = [
[0, 'noa_A3_lachen'],
...klatschen(T.c1, T.c1 + 9.2),
...zeigen(ZAHLEN.slice(0, 5)),
...klatschen(T.c2, T.c2 + 9.2),
...zeigen(ZAHLEN.slice(5)),
[s(T.echo) - 4, 'noa_B_zeigen'],
[s(T.echoEnde + 0.4), 'noa_A3_lachen'],
[UNTEN, 'noa_A5_blick-links'],
[MAMPF, 'noa_A4_staunen'],
[s(T.frage), 'noa_B_zeigen'],
[s(T.antwort), 'noa_A_arme-hoch'],
[s(T.antwort + 1.4), 'noa_A3_lachen'],
...klatschen(T.c3, T.c3 + 9.2),
[s(T.ende), 'noa_A_arm-hoch-winken'],
];
const PINO: Posenfolge = [
[0, 'pino_A3_lachen'],
[UNTEN - 10, 'pino_A4_staunen'],
[MAMPF + 20, 'pino_A3_lachen'],
[s(T.antwort), 'pino_A_hinterbeine'],
[s(T.antwort + 1.6), 'pino_A3_lachen'],
];
const LULU: Posenfolge = [
[0, 'lulu_A3_lachen'],
[G, 'lulu_A5_blick-links'], // „Darf ich einen Apfel?“
[OBEN, 'lulu_A_apfel'],
[MAMPF + 14, 'lulu_A1_augen-zu'],
[MAMPF + 24, 'lulu_A3_lachen'],
];
// Korb: Rückwand (vor den Äpfeln) und Vorderseite (danach)
const KorbHinten: React.FC = () => (
<svg width={260} height={60} viewBox="-130 -30 260 60" style={{position: 'absolute', left: KORB_X - 130, top: RAND_Y - 30, overflow: 'visible'}}>
<ellipse cx={0} cy={0} rx={112} ry={24} fill="#5a3a22" />
</svg>
);
const KorbVorne: React.FC = () => (
<svg width={260} height={160} viewBox="-130 -30 260 160" style={{position: 'absolute', left: KORB_X - 130, top: RAND_Y - 30, overflow: 'visible'}}>
<path d="M -112 0 C -108 60, -96 104, -78 116 L 78 116 C 96 104, 108 60, 112 0 C 80 18, -80 18, -112 0 Z" fill="#c08a4e" />
{[20, 46, 72, 98].map((y) => (
<path key={y} d={`M ${-110 + y * 0.28} ${y} C -40 ${y + 14}, 40 ${y + 14}, ${110 - y * 0.28} ${y}`} stroke="#9a6a38" strokeWidth={7} fill="none" />
))}
{[-80, -48, -16, 16, 48, 80].map((x) => (
<path key={x} d={`M ${x} 16 L ${x * 0.82} 114`} stroke="#a8763f" strokeWidth={5} opacity={0.7} />
))}
<path d="M -112 0 C -80 22, 80 22, 112 0" stroke="#8a5a2e" strokeWidth={12} fill="none" strokeLinecap="round" />
<path d="M -112 0 C -80 -24, 80 -24, 112 0" stroke="#8a5a2e" strokeWidth={8} fill="none" strokeLinecap="round" opacity={0.9} />
</svg>
);
// Ein Apfel des Liedes: hängt im Baum, fliegt bei seiner Zahl in den Korb, leuchtet beim Zählen
const Liedapfel: React.FC<{i: number; f: number}> = ({i, f}) => {
// Apfel 10 holt sich Lulu: vom Korb ins Maul, mit dem Hals wieder hoch, dann ist er im Bild lulu_A_apfel
if (i === 9 && f >= UNTEN && f < OBEN) {
const bild = f < HOCH ? C9_TIEF : C9_TIEF - ((f - HOCH) * C9_TEMPO * 24) / FPS;
const m = luluMaulC9(bild);
const maul = punkt('lulu', LULU_X, LULU_Y, m.cx, m.cy);
const p = interpolate(f, [UNTEN, UNTEN + 6], [0, 1], {extrapolateRight: 'clamp'});
const x = interpolate(p, [0, 1], [KORB_X + PLAETZE[9][0], maul.x]);
const y = interpolate(p, [0, 1], [RAND_Y + PLAETZE[9][1], maul.y + 6]);
return <Apfel x={x} y={y} s={0.82} />;
}
if (i === 9 && f >= OBEN) return null;
const start = s(ZAHLEN[i]) - 6;
const [bx, by] = AM_BAUM[i];
const zx = KORB_X + PLAETZE[i][0];
const zy = RAND_Y + PLAETZE[i][1];
if (f < start) return <Apfel x={bx} y={by + Math.sin(f * 0.06 + i) * 3} s={0.82} r={Math.sin(f * 0.06 + i) * 5} />;
const p = Math.min(1, (f - start) / FLUG);
const x = interpolate(p, [0, 1], [bx, zx]);
const y = interpolate(p, [0, 1], [by, zy]) - Math.sin(p * Math.PI) * 160;
// Beim Zählen nach Strophe 2 leuchtet der Apfel kurz auf
const leuchten = interpolate(f, [s(ECHO[i]), s(ECHO[i]) + 4, s(ECHO[i]) + 18], [0, 1, 0], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'});
const landen = p >= 1 ? 1 + 0.12 * Math.sin(Math.min(1, (f - start - FLUG) / 8) * Math.PI) : 1;
return (
<>
{leuchten > 0 && (
<div style={{position: 'absolute', left: zx - 50, top: zy - 50, width: 100, height: 100, borderRadius: '50%', background: 'radial-gradient(circle, rgba(255,230,140,0.95), rgba(255,230,140,0))', opacity: leuchten}} />
)}
<Apfel x={x} y={y} s={0.82 * landen * (1 + 0.18 * leuchten)} r={p < 1 ? p * 360 : 0} />
</>
);
};
// Hand mit hochgestreckten Fingern (Daumen zählt als 1)
const HAUT = '#f4cfa6';
const KONTUR = '#b9825a';
const FINGER: {x: number; b: number; l: number; dreh: number}[] = [
{x: 22, b: 24, l: 62, dreh: -48}, // Daumen
{x: 48, b: 22, l: 84, dreh: -6},
{x: 74, b: 22, l: 92, dreh: 0},
{x: 100, b: 22, l: 84, dreh: 5},
{x: 124, b: 20, l: 66, dreh: 12},
];
const Hand: React.FC<{finger: number; neu: number; spiegeln?: boolean}> = ({finger, neu, spiegeln = false}) => (
<svg width={170} height={230} viewBox="0 0 160 220" style={{transform: spiegeln ? 'scaleX(-1)' : undefined, overflow: 'visible'}}>
{FINGER.map((fi, k) => {
const hoch = k < finger;
// der zuletzt dazugekommene Finger schnellt hoch
const l = hoch ? (k === finger - 1 ? 18 + (fi.l - 18) * neu : fi.l) : 18;
const fussY = k === 0 ? 150 : 112;
return (
<rect
key={k}
x={fi.x - fi.b / 2 + 6}
y={fussY - l}
width={fi.b}
height={l + 20}
rx={fi.b / 2}
fill={HAUT}
stroke={KONTUR}
strokeWidth={4}
transform={`rotate(${fi.dreh} ${fi.x + 6} ${fussY + 10})`}
/>
);
})}
<rect x={30} y={100} width={112} height={100} rx={40} fill={HAUT} stroke={KONTUR} strokeWidth={4} />
{/* eingeklappte Finger als kleine Knöchel */}
{FINGER.slice(1).map((fi, k) => (k + 1 >= finger ? <path key={k} d={`M ${fi.x - 4} 118 q 11 10 22 0`} stroke={KONTUR} strokeWidth={3} fill="none" opacity={0.7} /> : null))}
</svg>
);
// Zahl und Hände oben rechts
type Anzeige = {n: number | '?'; seit: number; deck: number};
const anzeige = (f: number): Anzeige | null => {
const zaehle = (zeiten: number[], von: number, bis: number, basis = 0) => {
if (!imAbschnitt(f, von, bis)) return null;
const k = zeiten.filter((t) => f >= s(t)).length;
const seit = k > 0 ? f - s(zeiten[k - 1]) : 0;
const deck = Math.min(interpolate(f, [s(von), s(von) + 8], [0, 1], {extrapolateRight: 'clamp'}), interpolate(f, [s(bis) - 10, s(bis)], [1, 0], {extrapolateLeft: 'clamp'}));
return {n: basis + k, seit, deck};
};
return (
zaehle(ZAHLEN.slice(0, 5), ZAHLEN[0] - 0.2, T.c2 - 0.3) ??
zaehle(ZAHLEN.slice(5), ZAHLEN[5] - 0.2, T.echo - 0.1, 5) ??
zaehle(ECHO, ECHO[0] - 0.1, T.echoEnde + 1) ??
(imAbschnitt(f, T.frage, T.c3 - 0.5)
? {
n: f >= s(T.antwort) ? 9 : '?',
seit: f >= s(T.antwort) ? f - s(T.antwort) : f - s(T.frage),
deck: Math.min(interpolate(f, [s(T.frage), s(T.frage) + 8], [0, 1], {extrapolateRight: 'clamp'}), interpolate(f, [s(T.c3 - 0.5) - 10, s(T.c3 - 0.5)], [1, 0], {extrapolateLeft: 'clamp'})),
}
: null)
);
};
const Zahltafel: React.FC = () => {
const f = useCurrentFrame();
const a = anzeige(f);
if (!a || a.n === 0) return null;
const pop = spring({frame: a.seit, fps: FPS, config: {damping: 9, stiffness: 160}});
const neu = interpolate(a.seit, [0, 8], [0, 1], {extrapolateRight: 'clamp'});
let inhalt: React.ReactNode;
if (a.n === '?') {
// drei Punkte zählen herunter bis zur Antwort
const frei = s(T.antwort) - s(T.frage + 1.2);
const punkte = f < s(T.frage + 1.2) ? 3 : Math.max(1, 3 - Math.floor(((f - s(T.frage + 1.2)) / frei) * 3));
inhalt = <div style={{fontSize: 150, color: '#b8a68a', width: 330, textAlign: 'center'}}>{f < s(T.frage + 1.2) ? '?' : '•'.repeat(punkte)}</div>;
} else {
const n = a.n;
inhalt = (
<>
<div style={{fontSize: 190, lineHeight: 1, color: '#c8502c', minWidth: 150, textAlign: 'center', transform: `scale(${0.6 + 0.4 * pop})`}}>{n}</div>
<div style={{display: 'flex', gap: 4, alignItems: 'flex-end'}}>
<Hand finger={Math.min(n, 5)} neu={n <= 5 ? neu : 1} />
{n > 5 && <Hand finger={n - 5} neu={neu} spiegeln />}
</div>
</>
);
}
return (
<div
style={{
position: 'absolute',
top: 44,
right: 56,
display: 'flex',
alignItems: 'center',
gap: 18,
padding: '18px 34px',
borderRadius: 36,
background: 'rgba(251,244,228,0.94)',
border: '8px solid #3f5a8a',
boxShadow: '0 8px 26px rgba(60,40,20,0.22)',
fontFamily: SCHRIFT,
fontWeight: 600,
opacity: a.deck,
transform: `scale(${0.88 + 0.12 * a.deck})`,
transformOrigin: '100% 0',
}}
>
{inhalt}
</div>
);
};
const Garten: React.FC = () => {
const f = useCurrentFrame();
const kam = kamera(f);
const ph = (f - ANFANG) / SCHLAG;
const schlag = Math.abs(Math.sin(ph * Math.PI));
const wipp = (phase: number, h: number) => -Math.abs(Math.sin(ph * Math.PI + phase)) * h;
const schwank = (phase: number, g: number) => Math.sin((ph * Math.PI) / 2 + phase) * g;
const ruhig = f >= s(T.c3) ? 0.6 : 1;
const singt = f >= ANFANG && f < s(T.ende + 1);
const pinoHuepf = [...ZAHLEN, ...ECHO].reduce((a, t) => a + huepfer(f, s(t) + 14, 24, 11), 0) + huepfer(f, s(T.antwort), 40, 14);
const luluImClip = f >= RUNTER && f < OBEN;
const abend = interpolate(f, [s(T.c3 - 1), s(T.c3 + 2)], [0, 1], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'});
const chor = [T.c1, T.c2, T.c3].some((c) => imAbschnitt(f, c, c + 9.6));
const kopf = punkt('lulu', LULU_X, LULU_Y, LULU_KOPF.cx, LULU_KOPF.cy);
return (
<>
<Kamera {...kam}>
<Ort name="garten_herbst" />
<div style={{position: 'absolute', inset: 0, opacity: abend}}>
<Ort name="garten_herbst_abend" />
</div>
<KorbHinten />
{luluImClip ? (
f < HOCH ? (
<ClipFigur name="lulu_C9_hals-runter" start={RUNTER - c9(C9_START)} tempo={C9_TEMPO} bis={C9_TIEF / 24} x={LULU_X} y={LULU_Y} ersatz="lulu_A_hals-unten" />
) : (
<ClipFigur name="lulu_C9_hals-runter" start={HOCH} tempo={C9_TEMPO} bis={C9_TIEF / 24} rueckwaerts x={LULU_X} y={LULU_Y} ersatz="lulu_A_hals-unten" />
)
) : (
<Figur pose={LULU} x={LULU_X} y={LULU_Y} drehung={singt ? schwank(0.5, 2 * ruhig) : 0} stauchY={1 - 0.012 * schlag} blende={6} />
)}
{new Array(10).fill(0).map((_, i) => (
<Liedapfel key={i} i={i} f={f} />
))}
<KorbVorne />
<Figur pose={NOA} x={NOA_X} y={BODEN + (singt ? wipp(0.3, 10 * ruhig) : 0)} drehung={singt ? schwank(0, 3 * ruhig) : 0} blende={3} />
<Figur pose={PINO} x={PINO_X} y={BODEN + (singt ? wipp(0, 12 * ruhig) : 0) - pinoHuepf} drehung={singt ? schwank(1, 4 * ruhig) : 0} stauchY={1 - 0.03 * schlag} blende={6} />
<Ruf text={LIED.mampf} x={kopf.x - 140} y={kopf.y - 40} start={MAMPF} dauer={30} groesse={80} farbe="#c8362c" />
<Funkeln x={kopf.x - 60} y={kopf.y + 20} start={s(T.antwort + 0.6)} dauer={50} s={1.2} />
</Kamera>
{chor && <Noten />}
<Zahltafel />
{/* Liedtitel im ersten Refrain */}
<div
style={{
position: 'absolute',
top: 46,
left: 0,
right: 0,
textAlign: 'center',
fontFamily: SCHRIFT,
fontWeight: 600,
fontSize: 44,
color: '#8a6a4a',
opacity: interpolate(f, [s(T.c1), s(T.c1 + 0.6), s(T.v1 - 1.2), s(T.v1 - 0.4)], [0, 1, 1, 0], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}),
}}
>
♫ {LIED.titel} ♫
</div>
{/* Lulus Frage im Instrumental, solange die Aufnahme fehlt */}
<div
style={{
position: 'absolute',
top: 50,
left: 60,
maxWidth: 1560,
padding: '12px 30px 16px',
borderRadius: 26,
background: 'rgba(251,244,228,0.92)',
boxShadow: '0 6px 24px rgba(60,40,20,0.18)',
fontFamily: SCHRIFT,
fontWeight: 600,
fontSize: 46,
color: '#4f7a3a',
opacity: interpolate(f, [G, G + 8, RUNTER + 40, RUNTER + 50], [0, 1, 1, 0], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}),
}}
>
{LIED.bitte}
</div>
<Vignette />
</>
);
};
// Intro (Buch geht auf) bis kurz vor das erste gesungene Wort
const INTRO_DAUER = s(T.c1 - 0.2);
const INTRO = plane([{id: 'buch', dauer: 1.5}, {id: 't1', dauer: 1.2}, {id: 't2', dauer: 0.5}], {...INTRO_OPTIONEN, vorlauf: 0.2, nachlauf: 0.4});
const OUTRO_START = DAUER - OUTRO.dauer;
export const Lied01: React.FC = () => (
<AbsoluteFill style={{background: '#f4ead8'}}>
<Sequence durationInFrames={OUTRO_START + 12}>
<Garten />
</Sequence>
<Sequence from={INTRO_DAUER} durationInFrames={OUTRO_START - INTRO_DAUER + 12}>
<Blende dauer={OUTRO_START - INTRO_DAUER + 12} rein={10} raus={12} />
</Sequence>
<Sequence durationInFrames={INTRO_DAUER}>
<AbsoluteFill style={{background: '#f4ead8'}}>
<Intro plan={INTRO} titel={LIED.buchTitel} posen={['pino_A3_lachen', 'noa_A3_lachen', 'lulu_A3_lachen']} />
</AbsoluteFill>
</Sequence>
<Sequence from={OUTRO_START}>
<Outro posen={['pino_A3_lachen', 'noa_A_arm-hoch-winken', 'lulu_A3_lachen']} />
</Sequence>
<Karaoke zeilen={ZEILEN} />
<Audio src={staticFile(LIED.datei)} />
</AbsoluteFill>
);
export const LIED01_DAUER = DAUER;