import {AbsoluteFill, useCurrentFrame, interpolate, Sequence, Img} from 'remotion' import React from 'react' export type Scene = { type: string heading: string text: string image: string items: string[] pexels_images?: string[] } export type Caption = { start: number end: number text: string } export type StoryVideoProps = { title: string domain: string scenes: Scene[] postUrl: string thumbnail?: string audioFile?: string audioDurationSeconds?: number captions?: Caption[] } const MOTION_PRESETS = [ { sx: 1.00, ex: 1.15, tx0: 0, tx1: 0, ty0: 0, ty1: 0 }, { sx: 1.15, ex: 1.00, tx0: 0, tx1: 0, ty0: 0, ty1: 0 }, { sx: 1.09, ex: 1.09, tx0: 55, tx1: -55, ty0: 0, ty1: 0 }, { sx: 1.09, ex: 1.09, tx0: -55, tx1: 55, ty0: 0, ty1: 0 }, { sx: 1.09, ex: 1.09, tx0: 0, tx1: 0, ty0: 38, ty1: -38 }, { sx: 1.09, ex: 1.09, tx0: 0, tx1: 0, ty0: -38, ty1: 38 }, { sx: 1.00, ex: 1.13, tx0: -38, tx1: 12, ty0: 0, ty1: 0 }, { sx: 1.13, ex: 1.00, tx0: 12, tx1: -38, ty0: 0, ty1: 0 }, { sx: 1.00, ex: 1.13, tx0: 0, tx1: 0, ty0: 28, ty1: -28 }, { sx: 1.13, ex: 1.00, tx0: 0, tx1: 0, ty0: -28, ty1: 28 }, { sx: 1.00, ex: 1.13, tx0: -28, tx1: 28, ty0: -20, ty1: 20 }, { sx: 1.13, ex: 1.00, tx0: 28, tx1: -28, ty0: 20, ty1: -20 }, ] as const const NUM_MOTIONS = MOTION_PRESETS.length const INTRO = 45 const OUTRO = 30 const FPS = 30 const CROSSFADE_FRAMES = 10 const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)) const normalizeText = (v?: string) => (v ?? '').replace(/\s+/g, ' ').trim() const normalizeScene = (s: Partial): Scene => ({ type: String(s.type ?? 'section'), heading: normalizeText(s.heading), text: normalizeText(s.text), image: String(s.image ?? ''), items: Array.isArray(s.items) ? s.items.map(i => normalizeText(String(i))).filter(Boolean) : [], pexels_images: Array.isArray(s.pexels_images) ? s.pexels_images.filter(Boolean) : [], }) const getSafeScenes = (scenes: Scene[] | undefined): Scene[] => { if (!Array.isArray(scenes) || scenes.length === 0) { return [{type: 'section', heading: '', text: '', image: '', items: [], pexels_images: []}] } return scenes.map(normalizeScene) } function buildGlobalImagePlan(scenes: Scene[]): { scenePexels: string[][] globalStartIndex: number[] } { const scenePexels: string[][] = [] const globalStartIndex: number[] = [] let cursor = 0 for (const scene of scenes) { const imgs = (scene.pexels_images ?? []).filter(Boolean) scenePexels.push(imgs) globalStartIndex.push(cursor) cursor += imgs.length } return {scenePexels, globalStartIndex} } const normalizeForMatch = (v?: string) => (v ?? '') .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/[„“"‚‘'`´]/g, '') .replace(/[^a-z0-9äöüß\s]/gi, ' ') .replace(/\s+/g, ' ') .trim() const wordsOf = (v?: string): string[] => normalizeForMatch(v).split(' ').filter(Boolean) const buildSceneBlockText = (scene: Scene): string => { const parts: string[] = [] if (scene.heading) parts.push(scene.heading) if (scene.text) parts.push(scene.text) if (scene.items.length) parts.push(scene.items.join('. ')) return normalizeText(parts.join('. ')) } const buildAnchors = (scene: Scene): string[] => { const anchors: string[] = [] if (scene.heading) { const hw = wordsOf(scene.heading) if (hw.length >= 2) anchors.push(hw.slice(0, Math.min(5, hw.length)).join(' ')) } if (scene.text) { const tw = wordsOf(scene.text) if (tw.length >= 3) anchors.push(tw.slice(0, Math.min(6, tw.length)).join(' ')) } for (const item of scene.items.slice(0, 3)) { const iw = wordsOf(item) if (iw.length >= 2) anchors.push(iw.slice(0, Math.min(5, iw.length)).join(' ')) } return anchors.filter(Boolean) } type SceneTiming = { from: number dur: number headingFrame: number itemsBaseFrame: number itemFrames: number[] } const fallbackSceneDurationInFrames = (scene: Scene) => { const combined = [scene.heading, scene.text, scene.items.join(' ')].filter(Boolean).join(' ') const words = combined.split(/\s+/).filter(Boolean).length const pauses = (combined.match(/[,:;.!?]/g) ?? []).length const bullets = scene.items.length * 10 const headWords = scene.heading ? scene.heading.split(/\s+/).length : 0 const headWeight = scene.heading ? Math.max(8, Math.round(headWords * 1.8)) : 0 const bonus = scene.type === 'bullets' ? 12 : scene.type === 'fazit' ? 8 : 0 const units = Math.max(12, words + pauses * 2 + bullets + headWeight + bonus) return clamp(Math.round((units / 2.7) * FPS), 84, 900) } const getFallbackSceneTimings = (scenes: Scene[], audioDurationSeconds?: number): SceneTiming[] => { const safe = getSafeScenes(scenes) let durs: number[] if (!audioDurationSeconds || audioDurationSeconds <= 0) { durs = safe.map(fallbackSceneDurationInFrames) } else { const raw = safe.map(fallbackSceneDurationInFrames) const availableFrames = Math.max(safe.length, Math.round(audioDurationSeconds * FPS)) const total = raw.reduce((a, b) => a + b, 0) || 1 durs = raw.map(v => Math.max(1, Math.floor((v / total) * availableFrames))) let rem = availableFrames - durs.reduce((a, b) => a + b, 0) for (let i = 0; rem > 0; i++, rem--) durs[i % durs.length] += 1 durs[durs.length - 1] += availableFrames - durs.reduce((a, b) => a + b, 0) } let cursor = INTRO return safe.map((scene, idx) => { const dur = durs[idx] const headingWords = wordsOf(scene.heading).length const textWords = wordsOf(scene.text).length const allWords = wordsOf(buildSceneBlockText(scene)).length || 1 const itemsBaseFrame = Math.max(10, Math.round(((headingWords + textWords) / allWords) * dur)) const timing: SceneTiming = { from: cursor, dur, headingFrame: 0, itemsBaseFrame, itemFrames: scene.items.map((_, i) => itemsBaseFrame + i * 18), } cursor += dur return timing }) } const cueContainsPhrase = (cue: Caption, phrase: string) => { if (!phrase) return false return normalizeForMatch(cue.text).includes(phrase) } const findAnchorCueIndex = (captions: Caption[], anchors: string[], fromIndex: number): number | null => { if (!anchors.length) return null for (let i = fromIndex; i < captions.length; i++) { for (const a of anchors) { if (cueContainsPhrase(captions[i], a)) return i } } return null } const buildSceneTimingsFromCaptions = ( scenes: Scene[], captions: Caption[], audioDurationSeconds?: number ): SceneTiming[] => { const safeScenes = getSafeScenes(scenes) const fallback = getFallbackSceneTimings(safeScenes, audioDurationSeconds) if (!captions || captions.length === 0) return fallback const totalAudioFrames = audioDurationSeconds && audioDurationSeconds > 0 ? Math.round(audioDurationSeconds * FPS) : Math.max( 1, Math.round((captions[captions.length - 1]?.end ?? 0) * FPS) ) const timings: SceneTiming[] = [] let searchIndex = 0 for (let s = 0; s < safeScenes.length; s++) { const scene = safeScenes[s] const nextScene = safeScenes[s + 1] const anchors = buildAnchors(scene) const startIdx = findAnchorCueIndex(captions, anchors, searchIndex) if (startIdx === null) { timings.push(fallback[s]) continue } let endIdx = captions.length - 1 if (nextScene) { const nextAnchors = buildAnchors(nextScene) const nextStartIdx = findAnchorCueIndex(captions, nextAnchors, startIdx + 1) if (nextStartIdx !== null) { endIdx = Math.max(startIdx, nextStartIdx - 1) } } const sceneStartFrame = INTRO + Math.round(captions[startIdx].start * FPS) const sceneEndFrame = INTRO + Math.round(captions[endIdx].end * FPS) const dur = Math.max(24, sceneEndFrame - sceneStartFrame) let itemsBaseFrame = Math.max(10, Math.round(dur * 0.45)) const itemFrames: number[] = [] for (const item of scene.items) { const iw = wordsOf(item) const phrase = iw.slice(0, Math.min(5, iw.length)).join(' ') let local = itemsBaseFrame if (phrase) { const hit = captions.findIndex((c, i) => i >= startIdx && i <= endIdx && cueContainsPhrase(c, phrase)) if (hit !== -1) { local = Math.max(10, Math.round(captions[hit].start * FPS) + INTRO - sceneStartFrame) } } itemFrames.push(local) } if (itemFrames.length > 0) { itemsBaseFrame = itemFrames[0] } timings.push({ from: sceneStartFrame, dur, headingFrame: 0, itemsBaseFrame, itemFrames, }) searchIndex = Math.max(searchIndex, startIdx + 1) } for (let i = 0; i < timings.length; i++) { const cur = timings[i] const next = timings[i + 1] if (next) { cur.dur = Math.max(24, next.from - cur.from) } else { const endOfAudio = INTRO + totalAudioFrames cur.dur = Math.max(24, endOfAudio - cur.from) } } return timings } const CaptionOverlay: React.FC<{captions: Caption[]; fps: number}> = ({captions, fps}) => { const frame = useCurrentFrame() const currentSec = frame / fps const current = captions.find(c => currentSec >= c.start && currentSec < c.end) if (!current) return null const words = current.text.split(' ') const progress = (currentSec - current.start) / Math.max(0.1, current.end - current.start) const activeWord = Math.min(Math.floor(progress * words.length), words.length - 1) return (
{words.map((w, i) => ( {w} ))}
) } const PexelsSlideshow: React.FC<{ images: string[] dur: number globalStartIndex: number }> = ({images, dur}) => { const frame = useCurrentFrame() if (!images || images.length === 0) return null const numImages = images.length const framesPerImage = dur / numImages const imgIdx = Math.min(Math.floor(frame / framesPerImage), numImages - 1) const localFrame = frame - imgIdx * framesPerImage const src = images[imgIdx] const motionIdx = imgIdx % NUM_MOTIONS const m = MOTION_PRESETS[motionIdx] const t = localFrame / Math.max(framesPerImage, 1) const sc = m.sx + (m.ex - m.sx) * t const tx = m.tx0 + (m.tx1 - m.tx0) * t const ty = m.ty0 + (m.ty1 - m.ty0) * t const fade = localFrame < CROSSFADE_FRAMES ? interpolate(localFrame, [0, CROSSFADE_FRAMES], [0, 1], {extrapolateRight: 'clamp'}) : 1 return (
) } const IntroVisual: React.FC<{thumbnail?: string; pexelImages: string[]}> = ({thumbnail, pexelImages}) => { const frame = useCurrentFrame() const opacity = interpolate(frame, [0, 16], [0, 1], {extrapolateRight: 'clamp'}) const src = pexelImages[0] || thumbnail return ( {src ? ( ) : null} ) } const FAQOverlay: React.FC<{items: string[]; heading: string; dur: number}> = ({items, heading}) => { return (
{heading}
{items.map((it, i) => (
{it}
))}
) } const BulletsOverlay: React.FC<{ items: string[] heading: string dur: number headingFrame?: number itemsBaseFrame?: number itemFrames?: number[] }> = ({items, heading, dur, headingFrame = 0, itemsBaseFrame = 10, itemFrames = []}) => { const frame = useCurrentFrame() const safeItems = items.slice(0, 8) const count = safeItems.length const itemFontSize = count <= 5 ? 42 : count <= 7 ? 36 : 32 const gap = count <= 5 ? 14 : count <= 7 ? 10 : 8 const availableFrames = Math.max(count * 12, dur - itemsBaseFrame - 20) const staggerFrames = Math.max(10, Math.floor(availableFrames / (count + 1))) const fadeDuration = Math.max(6, Math.min(14, staggerFrames - 2)) const headOpacity = interpolate( frame, [headingFrame, headingFrame + 18], [0, 1], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'} ) const headY = interpolate( frame, [headingFrame, headingFrame + 18], [20, 0], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'} ) return (
{heading}
{safeItems.map((item, i) => { const startF = itemFrames[i] ?? (itemsBaseFrame + i * staggerFrames) const op = interpolate(frame, [startF, startF + fadeDuration], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', }) const tx = interpolate(frame, [startF, startF + fadeDuration], [-28, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', }) return (
{i + 1}
{item}
) })}
) } const SceneVisual: React.FC<{ scene: Scene dur: number sceneIndex: number thumbnail?: string pexelImages: string[] globalStartIndex: number timing: SceneTiming }> = ({scene, dur, sceneIndex, thumbnail, pexelImages, globalStartIndex, timing}) => { const frame = useCurrentFrame() const fadeIn = interpolate(frame, [0, 14], [0, 1], {extrapolateRight: 'clamp'}) const fadeOut = interpolate(frame, [dur - 14, dur], [1, 0], {extrapolateLeft: 'clamp'}) const opacity = Math.min(fadeIn, fadeOut) const progressPct = clamp((frame / Math.max(dur, 1)) * 100, 0, 100) const hasPexels = pexelImages.length > 0 const fallbackSrc = thumbnail || scene.image || null const motionIdx = sceneIndex % NUM_MOTIONS const m = MOTION_PRESETS[motionIdx] const t = frame / Math.max(dur, 1) const fbSc = m.sx + (m.ex - m.sx) * t const fbTx = m.tx0 + (m.tx1 - m.tx0) * t const fbTy = m.ty0 + (m.ty1 - m.ty0) * t return ( {hasPexels ? ( ) : fallbackSrc ? (
) : (
)}
{scene.type === 'faq' && scene.items.length > 0 && ( )} {scene.type === 'bullets' && scene.items.length > 0 && ( )} ) } const OutroVisual: React.FC = () => { const frame = useCurrentFrame() const opacity = interpolate(frame, [0, 22], [0, 1], {extrapolateRight: 'clamp'}) const scale = interpolate(frame, [0, OUTRO], [1.04, 1.0], {extrapolateRight: 'clamp'}) return (
) } export const StoryVideo: React.FC = ({scenes, thumbnail, audioDurationSeconds, captions}) => { const rawScenes = getSafeScenes(scenes) const {scenePexels, globalStartIndex} = React.useMemo( () => buildGlobalImagePlan(rawScenes), [scenes] ) const sceneTimings = React.useMemo( () => buildSceneTimingsFromCaptions(rawScenes, captions ?? [], audioDurationSeconds), [rawScenes, captions, audioDurationSeconds] ) const introPexels = scenePexels[0] return ( {captions && captions.length > 0 && } {rawScenes.map((scene, idx) => { const timing = sceneTimings[idx] if (!timing || timing.dur <= 0) return null return ( ) })} ) } export const STORY_DURATION = ( scenes: Scene[], audioDurationSeconds?: number ): number => { const safe = getSafeScenes(scenes) if (audioDurationSeconds && audioDurationSeconds > 0) { return INTRO + Math.round(audioDurationSeconds * FPS) + OUTRO } return INTRO + safe.reduce((sum, s) => sum + fallbackSceneDurationInFrames(s), 0) + OUTRO } WordPress › Fehler
Technischer Fehler bei der Sitemap-Generierung. Bitte Log prüfen.