From ccd70bd585f20cc77b43dd5982def1311e660757 Mon Sep 17 00:00:00 2001 From: Ferdinand Urban Date: Tue, 29 Sep 2026 11:00:59 +0000 Subject: [PATCH] =?UTF-8?q?Schnitt-Oberfl=C3=A4che=20mit=20Optionen,=20Mix?= =?UTF-8?q?er,=20Voice-over=20und=20Export=20bauen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Im Projekt lassen sich Intro, Outro, Beschleunigen, Stummschalten und Backing Track anhaken, Dateien hochladen und alle Tonspuren von 0 bis 100 regeln. Pro Clip nimmt der Browser ein Voice-over auf, während der Clip im späteren Tempo läuft. Einstellungen werden verzögert gespeichert; der Export zeigt Fortschritt, Vorschau und Download. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 24 +++- src/AssetField.jsx | 67 +++++++++++ src/CutSettings.jsx | 112 +++++++++++++++++++ src/ProjectView.jsx | 204 +++++++++++++++++++++++++--------- src/RenderPanel.jsx | 81 ++++++++++++++ src/Slider.jsx | 21 ++++ src/VoiceOver.jsx | 263 ++++++++++++++++++++++++++++++++++++++++++++ src/lib/plan.js | 46 ++++++++ src/lib/upload.js | 29 ++++- src/styles.css | 126 +++++++++++++++++++++ 10 files changed, 916 insertions(+), 57 deletions(-) create mode 100644 src/AssetField.jsx create mode 100644 src/CutSettings.jsx create mode 100644 src/RenderPanel.jsx create mode 100644 src/Slider.jsx create mode 100644 src/VoiceOver.jsx create mode 100644 src/lib/plan.js diff --git a/README.md b/README.md index 950b9cf..3c054f7 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,6 @@ automatisch passieren soll: Ausgabe ist ein MP4 in 1920×1080 mit 30 fps. Hochkant-Clips werden mittig mit unscharfem Hintergrund eingesetzt. Zugang ist über eine PIN geschützt. -> Stand: Anmeldung, Import des Exports und Upload der Videos laufen. Der Schnitt folgt. - ## Voraussetzungen - Node.js 20 @@ -57,6 +55,26 @@ NODE_ENV=production APP_PIN=… npm start 3. Videos prüfen (Duplikate sind abgewählt) und hochladen. Nur die gewählten Videos werden übertragen, in Stücken von 8 MB; ein Abbruch lässt sich durch erneutes Wählen des Ordners fortsetzen. +4. Im Projekt anhaken, was passieren soll, Intro, Outro und Backing Track + hochladen, Lautstärken einstellen und pro Clip ein Voice-over aufnehmen + (der Clip läuft dabei im späteren Tempo) oder als Datei hochladen. +5. „Video erstellen“ rendert im Hintergrund; danach herunterladen. + +## Wie geschnitten wird + +- Jeder Clip wird einzeln auf 1920×1080 bei 30 fps gebracht. Hochkant-Clips + stehen mittig vor einer unscharfen, vergrößerten Kopie ihrer selbst. +- Clips über 10 Sekunden werden per `setpts` auf genau 10 Sekunden + beschleunigt; ihr Ton (falls nicht stumm) per verketteter `atempo`-Stufen. +- Voice-over und Clip-Ton werden pro Clip gemischt und auf Clip-Länge gekürzt. +- Die Clip-Segmente werden aneinandergehängt, der Backing Track darunter + wiederholt, gekürzt und über 2 Sekunden ausgeblendet; danach kommen Intro + und Outro dazu. +- Segmente liegen zwischengespeichert unter `render/cache`; wer nur die + Musik ändert, wartet nicht auf alle Clips. Zwischenstände tragen + unkomprimierten Ton, erst das Endergebnis wird AAC, damit sich über viele + Übergänge keine Verschiebung aufsummiert. +- Exporte laufen nacheinander, weil ffmpeg alle Kerne nutzt. ## Befehle @@ -67,6 +85,8 @@ NODE_ENV=production APP_PIN=… npm start ## Aufbau - `server/` – Express-Server: Anmeldung, Projekte, Upload in Stücken, ffprobe +- `server/render.js` – Schnittplan und ffmpeg-Kette +- `server/renderQueue.js` – Exporte im Hintergrund - `src/` – React-Oberfläche (Vite) - `src/lib/whatsapp.js` – liest den Chatexport und ordnet die Videos - `src/lib/upload.js` – Upload in Stücken mit Wiederaufnahme diff --git a/src/AssetField.jsx b/src/AssetField.jsx new file mode 100644 index 0000000..16a519f --- /dev/null +++ b/src/AssetField.jsx @@ -0,0 +1,67 @@ +import { useRef, useState } from 'react' +import { api } from './api.js' +import { formatDuration } from './lib/format.js' +import { uploadAsset } from './lib/upload.js' + +// Datei für Intro, Outro oder Backing Track wählen, ersetzen oder entfernen. +export default function AssetField({ project, slot, label, accept, onProject }) { + const input = useRef(null) + const [progress, setProgress] = useState(null) + const [error, setError] = useState('') + const asset = project.assets[slot] + const busy = progress !== null + + async function pick(event) { + const file = event.target.files[0] + event.target.value = '' + if (!file) return + setError('') + setProgress(0) + try { + onProject(await uploadAsset({ projectId: project.id, slot, file, onProgress: setProgress })) + } catch (err) { + setError(err.message) + } finally { + setProgress(null) + } + } + + async function remove() { + setError('') + try { + onProject(await api(`projects/${project.id}/assets/${slot}`, { method: 'DELETE' })) + } catch (err) { + setError(err.message) + } + } + + return ( +
+ {busy && ( + + {Math.round(progress * 100)} % + + )} + {!busy && asset?.status === 'ready' && ( + + {asset.file} · {formatDuration(asset.media.duration)} + + )} + {!busy && asset?.status === 'error' && {asset.error}} + {!busy && asset?.status === 'pending' && Upload unvollständig} + {!busy && !asset && Keine Datei} +
+ + {asset && ( + + )} +
+ + {error &&

{error}

} +
+ ) +} diff --git a/src/CutSettings.jsx b/src/CutSettings.jsx new file mode 100644 index 0000000..1572cd7 --- /dev/null +++ b/src/CutSettings.jsx @@ -0,0 +1,112 @@ +import AssetField from './AssetField.jsx' +import Slider from './Slider.jsx' +import { formatDuration } from './lib/format.js' +import { TARGET_SECONDS, estimateDuration, includedClips } from './lib/plan.js' + +function Option({ checked, onChange, disabled = false, children, hint }) { + return ( + + ) +} + +export default function CutSettings({ project, settings, onSettings, onProject }) { + const clipCount = includedClips(project, settings).length + + return ( +
+
+

Automatisch bearbeiten

+ + {clipCount} Clips · Länge etwa {formatDuration(estimateDuration(project, settings))} min + +
+ +
+ + {settings.intro.enabled && ( + + )} +
+ +
+ + {settings.outro.enabled && ( + + )} +
+ + + + + + + +
+ + {settings.music.enabled && ( + + )} +
+ +

Lautstärke

+
+ onSettings({ intro: { volume } })} + /> + onSettings({ outro: { volume } })} + /> + onSettings({ clipVolume })} + /> + onSettings({ music: { volume } })} + /> + onSettings({ voiceVolume })} + /> +
+

Voice-overs nimmst du unten in der Clip-Liste pro Video auf.

+
+ ) +} diff --git a/src/ProjectView.jsx b/src/ProjectView.jsx index f039e42..4876f4f 100644 --- a/src/ProjectView.jsx +++ b/src/ProjectView.jsx @@ -1,11 +1,17 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { api } from './api.js' +import CutSettings from './CutSettings.jsx' import ExportPicker from './ExportPicker.jsx' +import RenderPanel from './RenderPanel.jsx' import UploadProgress from './UploadProgress.jsx' +import VoiceOver from './VoiceOver.jsx' import { filesByBaseName } from './lib/exportFiles.js' import { formatDate, formatDateTime, formatDuration } from './lib/format.js' +import { mergePatch, renderBlockers } from './lib/plan.js' import { uploadProject } from './lib/upload.js' +const SAVE_DELAY_MS = 400 + function clipDetails(clip) { if (clip.status === 'error') return `Fehler: ${clip.error}` if (clip.status !== 'ready') return 'Upload offen' @@ -17,14 +23,20 @@ function clipDetails(clip) { export default function ProjectView({ id, onBack }) { const [project, setProject] = useState(null) - const [preview, setPreview] = useState(null) + // Die Einstellungen führt die Oberfläche selbst; der Server bekommt sie verzögert. + const [settings, setSettings] = useState(null) + const [open, setOpen] = useState(null) const [progress, setProgress] = useState(null) const [busy, setBusy] = useState(false) const [error, setError] = useState('') + const pendingPatch = useRef({}) + const saveTimer = useRef(null) const load = useCallback(async () => { try { - setProject(await api(`projects/${id}`)) + const loaded = await api(`projects/${id}`) + setProject(loaded) + setSettings((current) => current ?? loaded.settings) } catch (err) { setError(err.message) } @@ -34,6 +46,42 @@ export default function ProjectView({ id, onBack }) { load() }, [load]) + const flushSettings = useCallback(async () => { + clearTimeout(saveTimer.current) + const patch = pendingPatch.current + if (Object.keys(patch).length === 0) return + pendingPatch.current = {} + try { + setProject(await api(`projects/${id}/settings`, { method: 'PATCH', body: patch })) + } catch (err) { + setError(`Einstellungen nicht gespeichert: ${err.message}`) + } + }, [id]) + + // Beim Verlassen noch nicht Gespeichertes nachreichen + useEffect(() => () => void flushSettings(), [flushSettings]) + + const renderActive = project && ['queued', 'running'].includes(project.render.status) + useEffect(() => { + if (!renderActive) return undefined + const timer = setInterval(load, 1500) + return () => clearInterval(timer) + }, [renderActive, load]) + + function changeSettings(patch) { + setSettings((current) => mergePatch(current, patch)) + pendingPatch.current = mergePatch(pendingPatch.current, patch) + clearTimeout(saveTimer.current) + saveTimer.current = setTimeout(flushSettings, SAVE_DELAY_MS) + } + + function toggleClip(index, included) { + const excluded = included + ? settings.excluded.filter((value) => value !== index) + : [...settings.excluded, index] + changeSettings({ excluded }) + } + async function resume(fileList) { setBusy(true) setError('') @@ -56,6 +104,27 @@ export default function ProjectView({ id, onBack }) { } } + async function startRender() { + setBusy(true) + setError('') + try { + await flushSettings() + setProject(await api(`projects/${id}/render`, { method: 'POST' })) + } catch (err) { + setError(err.message) + } finally { + setBusy(false) + } + } + + async function cancelRender() { + try { + setProject(await api(`projects/${id}/render`, { method: 'DELETE' })) + } catch (err) { + setError(err.message) + } + } + async function remove() { if (!window.confirm('Projekt samt hochgeladenen Videos löschen?')) return try { @@ -66,13 +135,17 @@ export default function ProjectView({ id, onBack }) { } } - if (!project) { - return

{error}

+ if (!project || !settings) { + return ( +

+ {error} +

+ ) } const pending = project.clips.filter((clip) => clip.status === 'pending') - const ready = project.clips.filter((clip) => clip.status === 'ready') - const totalSeconds = ready.reduce((sum, clip) => sum + Math.min(clip.media.duration, 10), 0) + const toggleOpen = (index, mode) => + setOpen(open?.index === index && open.mode === mode ? null : { index, mode }) return (
@@ -99,53 +172,84 @@ export default function ProjectView({ id, onBack }) { )} + +
-

- {project.clips.length} Clips - {ready.length > 0 && ( - · gekürzt etwa {formatDuration(totalSeconds)} min - )} -

+

Clips

    - {project.clips.map((clip) => ( -
  1. -
    - {formatDateTime(clip.timestamp)} - - {clip.sender ?? 'Unbekannt'} - {clip.caption && · {clip.caption}} - - - {clipDetails(clip)} - - {clip.status === 'ready' && ( - + {project.clips.map((clip) => { + const included = !settings.excluded.includes(clip.index) + const hasVoice = project.assets[`voice-${clip.index}`]?.status === 'ready' + const isOpen = (mode) => open?.index === clip.index && open.mode === mode + return ( +
  2. +
    + toggleClip(clip.index, event.target.checked)} + aria-label={`Clip ${clip.index + 1} von ${clip.sender ?? 'Unbekannt'} ins Video`} + /> + {formatDateTime(clip.timestamp)} + + {clip.sender ?? 'Unbekannt'} + {clip.caption && · {clip.caption}} + + + {clipDetails(clip)} + + {clip.status === 'ready' && ( + + + + + )} +
    + {isOpen('preview') && ( +
- {preview === clip.index && ( -