youtube/remotion/src/folge90/hochformat.tsx
Lara d605395fa7 Lern-Shorts: Mitmach-Fragezeichen unter der Frage-Karte
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 11:28:37 +00:00

106 lines
4.6 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, interpolate, spring, useCurrentFrame, useVideoConfig} from 'remotion';
import {SCHRIFT, Untertitel} from '../baukasten/untertitel';
import type {Geplant} from '../baukasten/zeit';
import {TEXTE} from './plan';
// Lern-Shorts im Hochformat (1080 × 1920). Die Szene spielt auf der gewohnten Bühne (1920 × 1080,
// Figuren und Orte wie in den Folgen); `Buehne` schneidet daraus den Hochformat-Ausschnitt.
// Frage oben, Untertitel über dem Bereich, den YouTube unten mit Titel und Knöpfen verdeckt.
export const W = 1080;
export const H = 1920;
export const VOLL = H / 1080; // kleinste Vergrößerung, bei der die Bühne das Hochformat füllt
export const Buehne: React.FC<{x: number; y?: number; z?: number; children: React.ReactNode}> = ({x, y = 540, z = VOLL, children}) => {
const halbB = W / 2 / z;
const halbH = H / 2 / z;
// Ist der Ausschnitt größer als die Bühne, bleibt sie mittig (oben/unten Papierrand)
const bx = halbB * 2 >= 1920 ? 960 : Math.min(1920 - halbB, Math.max(halbB, x));
const by = halbH * 2 >= 1080 ? 540 : Math.min(1080 - halbH, Math.max(halbH, y));
return (
<div style={{position: 'absolute', width: 1920, height: 1080, transformOrigin: '0 0', transform: `translate(${W / 2}px, ${H / 2}px) scale(${z}) translate(${-bx}px, ${-by}px)`}}>
{children}
</div>
);
};
const KARTE = {
background: 'rgba(255, 250, 240, 0.92)',
borderRadius: 40,
boxShadow: '0 8px 30px rgba(60, 40, 20, 0.22)',
fontFamily: SCHRIFT,
textAlign: 'center',
} as const;
// Frage oben, darunter klein der Kanalname
export const FrageKarte: React.FC<{text: string}> = ({text}) => {
const f = useCurrentFrame();
const {fps} = useVideoConfig();
const s = spring({frame: f - 4, fps, config: {damping: 14}});
return (
<div style={{position: 'absolute', top: 150, left: 60, right: 60, display: 'flex', justifyContent: 'center', transform: `scale(${0.8 + 0.2 * s})`, opacity: s}}>
<div style={{...KARTE, padding: '26px 44px 22px'}}>
<div style={{fontSize: 74, fontWeight: 700, color: '#3f5a8a', lineHeight: 1.1}}>{text}</div>
<div style={{fontSize: 32, fontWeight: 600, color: '#c8502c', marginTop: 8}}>{TEXTE.zeilen.kanal}</div>
</div>
</div>
);
};
// Untertitel der Folgen, nach oben versetzt (unten liegen im Shorts-Player Titel und Knöpfe);
// das Mitmach-„?“ erscheint unter der Frage-Karte statt darauf
export const HochUntertitel: React.FC<{zeilen: Geplant[]}> = ({zeilen}) => (
<div style={{position: 'absolute', left: 40, right: 40, top: 380, bottom: 340}}>
<Untertitel zeilen={zeilen} />
</div>
);
// Runde Bildkarte, die bei `ab` aufploppt (Bildschirm-Koordinaten des Hochformats)
export const BildKarte: React.FC<{x: number; y: number; ab: number; r?: number; aus?: number; children: React.ReactNode}> = ({x, y, ab, r = 150, aus = 1e9, children}) => {
const f = useCurrentFrame();
const {fps} = useVideoConfig();
const s = spring({frame: f - ab, fps, config: {damping: 11}});
const weg = interpolate(f, [aus, aus + 10], [1, 0], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'});
if (f < ab) return null;
return (
<div style={{position: 'absolute', left: x - r, top: y - r, width: 2 * r, height: 2 * r, ...KARTE, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', transform: `scale(${s * weg})`}}>
{children}
</div>
);
};
// Großes Wort zum Mitsagen (klein – mittel – groß)
// `puls`: Zeitpunkte, an denen das Wort noch einmal kurz groß wird (beim Mitsagen)
export const Wort: React.FC<{text: string; x: number; y: number; ab: number; puls?: number[]; groesse?: number; farbe?: string}> = ({text, x, y, ab, puls = [], groesse = 90, farbe = '#c8502c'}) => {
const f = useCurrentFrame();
const {fps} = useVideoConfig();
if (f < ab) return null;
const pulsen = puls.reduce((m, p) => m + 0.35 * Math.max(0, Math.sin(Math.PI * Math.min(1, Math.max(0, (f - p) / 18)))), 0);
const s = spring({frame: f - ab, fps, config: {damping: 10}}) + pulsen;
return (
<div
style={{
position: 'absolute',
left: x - 300,
width: 600,
top: y - groesse / 2,
textAlign: 'center',
fontFamily: SCHRIFT,
fontWeight: 700,
fontSize: groesse,
color: farbe,
WebkitTextStroke: '10px #fbf4e4',
paintOrder: 'stroke fill',
transform: `scale(${s})`,
}}
>
{text}
</div>
);
};
// `hintergrund`: füllt oben und unten, wenn die Bühne das Hochformat nicht ganz ausfüllt
export const Hochformat: React.FC<{hintergrund?: string; children: React.ReactNode}> = ({hintergrund = '#f4ead8', children}) => (
<AbsoluteFill style={{background: hintergrund, overflow: 'hidden'}}>{children}</AbsoluteFill>
);