Schnitt-Oberfläche mit Optionen, Mixer, Voice-over und Export bauen

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) <noreply@anthropic.com>
This commit is contained in:
ferdi2goandClaude Opus 5.5 committed 2026-09-29 11:00:59 +00:00
1 parent 18d5b80959
commit ccd70bd585
10 files changed
+916 -57

No files matched your search

+22 -2
View File
@@ -17,8 +17,6 @@ automatisch passieren soll:
Ausgabe ist ein MP4 in 1920×1080 mit 30 fps. Hochkant-Clips werden mittig mit 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. unscharfem Hintergrund eingesetzt. Zugang ist über eine PIN geschützt.
> Stand: Anmeldung, Import des Exports und Upload der Videos laufen. Der Schnitt folgt.
## Voraussetzungen ## Voraussetzungen
- Node.js 20 - 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 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 Videos werden übertragen, in Stücken von 8 MB; ein Abbruch lässt sich durch
erneutes Wählen des Ordners fortsetzen. 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 ## Befehle
@@ -67,6 +85,8 @@ NODE_ENV=production APP_PIN=… npm start
## Aufbau ## Aufbau
- `server/` – Express-Server: Anmeldung, Projekte, Upload in Stücken, ffprobe - `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/` – React-Oberfläche (Vite)
- `src/lib/whatsapp.js` – liest den Chatexport und ordnet die Videos - `src/lib/whatsapp.js` – liest den Chatexport und ordnet die Videos
- `src/lib/upload.js` – Upload in Stücken mit Wiederaufnahme - `src/lib/upload.js` – Upload in Stücken mit Wiederaufnahme
+67
View File
@@ -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 (
<div className="asset-field">
{busy && (
<progress max="1" value={progress} aria-label={`${label} wird hochgeladen`}>
{Math.round(progress * 100)} %
</progress>
)}
{!busy && asset?.status === 'ready' && (
<span>
{asset.file} <span className="muted">· {formatDuration(asset.media.duration)}</span>
</span>
)}
{!busy && asset?.status === 'error' && <span className="error">{asset.error}</span>}
{!busy && asset?.status === 'pending' && <span className="muted">Upload unvollständig</span>}
{!busy && !asset && <span className="muted">Keine Datei</span>}
<div className="actions">
<button type="button" className="secondary small" onClick={() => input.current.click()} disabled={busy}>
{asset ? 'Ersetzen' : 'Datei wählen'}
</button>
{asset && (
<button type="button" className="secondary small" onClick={remove} disabled={busy}>
Entfernen
</button>
)}
</div>
<input ref={input} type="file" accept={accept} hidden onChange={pick} aria-label={`${label}-Datei`} />
{error && <p className="error" role="alert">{error}</p>}
</div>
)
}
+112
View File
@@ -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 (
<label className={`option${disabled ? ' disabled' : ''}`}>
<input type="checkbox" checked={checked} disabled={disabled} onChange={(event) => onChange?.(event.target.checked)} />
<span>
{children}
{hint && <span className="muted"> · {hint}</span>}
</span>
</label>
)
}
export default function CutSettings({ project, settings, onSettings, onProject }) {
const clipCount = includedClips(project, settings).length
return (
<section className="card stack">
<div className="heading-row">
<h2>Automatisch bearbeiten</h2>
<span className="muted">
{clipCount} Clips · Länge etwa {formatDuration(estimateDuration(project, settings))} min
</span>
</div>
<div className="option-group">
<Option checked={settings.intro.enabled} onChange={(enabled) => onSettings({ intro: { enabled } })}>
Intro einfügen
</Option>
{settings.intro.enabled && (
<AssetField project={project} slot="intro" label="Intro" accept="video/*" onProject={onProject} />
)}
</div>
<div className="option-group">
<Option checked={settings.outro.enabled} onChange={(enabled) => onSettings({ outro: { enabled } })}>
Outro einfügen
</Option>
{settings.outro.enabled && (
<AssetField project={project} slot="outro" label="Outro" accept="video/*" onProject={onProject} />
)}
</div>
<Option checked disabled hint="immer aktiv">
Videos in Chat-Reihenfolge aneinanderreihen
</Option>
<Option checked={settings.speedUp} onChange={(speedUp) => onSettings({ speedUp })}>
Videos kürzen: jedes auf {TARGET_SECONDS} Sekunden beschleunigen
</Option>
<Option
checked={settings.muteClips}
onChange={(muteClips) => onSettings({ muteClips })}
hint="Intro und Outro behalten ihren Ton"
>
Ton der Videos stummschalten
</Option>
<div className="option-group">
<Option
checked={settings.music.enabled}
onChange={(enabled) => onSettings({ music: { enabled } })}
hint="läuft nur unter den Clips, wird wiederholt und am Ende ausgeblendet"
>
Backing Track unterlegen
</Option>
{settings.music.enabled && (
<AssetField project={project} slot="music" label="Backing Track" accept="audio/*" onProject={onProject} />
)}
</div>
<h2>Lautstärke</h2>
<div className="mixer">
<Slider
label="Intro"
value={settings.intro.volume}
disabled={!settings.intro.enabled}
onChange={(volume) => onSettings({ intro: { volume } })}
/>
<Slider
label="Outro"
value={settings.outro.volume}
disabled={!settings.outro.enabled}
onChange={(volume) => onSettings({ outro: { volume } })}
/>
<Slider
label="Ton der Videos"
value={settings.clipVolume}
disabled={settings.muteClips}
onChange={(clipVolume) => onSettings({ clipVolume })}
/>
<Slider
label="Backing Track"
value={settings.music.volume}
disabled={!settings.music.enabled}
onChange={(volume) => onSettings({ music: { volume } })}
/>
<Slider
label="Voice-over (alle)"
value={settings.voiceVolume}
onChange={(voiceVolume) => onSettings({ voiceVolume })}
/>
</div>
<p className="muted">Voice-overs nimmst du unten in der Clip-Liste pro Video auf.</p>
</section>
)
}
+155 -49
View File
@@ -1,11 +1,17 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from './api.js' import { api } from './api.js'
import CutSettings from './CutSettings.jsx'
import ExportPicker from './ExportPicker.jsx' import ExportPicker from './ExportPicker.jsx'
import RenderPanel from './RenderPanel.jsx'
import UploadProgress from './UploadProgress.jsx' import UploadProgress from './UploadProgress.jsx'
import VoiceOver from './VoiceOver.jsx'
import { filesByBaseName } from './lib/exportFiles.js' import { filesByBaseName } from './lib/exportFiles.js'
import { formatDate, formatDateTime, formatDuration } from './lib/format.js' import { formatDate, formatDateTime, formatDuration } from './lib/format.js'
import { mergePatch, renderBlockers } from './lib/plan.js'
import { uploadProject } from './lib/upload.js' import { uploadProject } from './lib/upload.js'
const SAVE_DELAY_MS = 400
function clipDetails(clip) { function clipDetails(clip) {
if (clip.status === 'error') return `Fehler: ${clip.error}` if (clip.status === 'error') return `Fehler: ${clip.error}`
if (clip.status !== 'ready') return 'Upload offen' if (clip.status !== 'ready') return 'Upload offen'
@@ -17,14 +23,20 @@ function clipDetails(clip) {
export default function ProjectView({ id, onBack }) { export default function ProjectView({ id, onBack }) {
const [project, setProject] = useState(null) 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 [progress, setProgress] = useState(null)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const pendingPatch = useRef({})
const saveTimer = useRef(null)
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
setProject(await api(`projects/${id}`)) const loaded = await api(`projects/${id}`)
setProject(loaded)
setSettings((current) => current ?? loaded.settings)
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} }
@@ -34,6 +46,42 @@ export default function ProjectView({ id, onBack }) {
load() load()
}, [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) { async function resume(fileList) {
setBusy(true) setBusy(true)
setError('') 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() { async function remove() {
if (!window.confirm('Projekt samt hochgeladenen Videos löschen?')) return if (!window.confirm('Projekt samt hochgeladenen Videos löschen?')) return
try { try {
@@ -66,13 +135,17 @@ export default function ProjectView({ id, onBack }) {
} }
} }
if (!project) { if (!project || !settings) {
return <p className="error" role="alert">{error}</p> return (
<p className="error" role="alert">
{error}
</p>
)
} }
const pending = project.clips.filter((clip) => clip.status === 'pending') const pending = project.clips.filter((clip) => clip.status === 'pending')
const ready = project.clips.filter((clip) => clip.status === 'ready') const toggleOpen = (index, mode) =>
const totalSeconds = ready.reduce((sum, clip) => sum + Math.min(clip.media.duration, 10), 0) setOpen(open?.index === index && open.mode === mode ? null : { index, mode })
return ( return (
<div className="stack"> <div className="stack">
@@ -99,53 +172,84 @@ export default function ProjectView({ id, onBack }) {
</section> </section>
)} )}
<CutSettings project={project} settings={settings} onSettings={changeSettings} onProject={setProject} />
<section className="card stack"> <section className="card stack">
<h2> <h2>Clips</h2>
{project.clips.length} Clips
{ready.length > 0 && (
<span className="muted"> · gekürzt etwa {formatDuration(totalSeconds)} min</span>
)}
</h2>
<ol className="clip-list"> <ol className="clip-list">
{project.clips.map((clip) => ( {project.clips.map((clip) => {
<li key={clip.index} className="stack-tight"> const included = !settings.excluded.includes(clip.index)
<div className="clip-row"> const hasVoice = project.assets[`voice-${clip.index}`]?.status === 'ready'
<span className="clip-time">{formatDateTime(clip.timestamp)}</span> const isOpen = (mode) => open?.index === clip.index && open.mode === mode
<span className="clip-text"> return (
<strong>{clip.sender ?? 'Unbekannt'}</strong> <li key={clip.index} className={`stack-tight${included ? '' : ' excluded'}`}>
{clip.caption && <span className="muted"> · {clip.caption}</span>} <div className="clip-row">
</span> <input
<span className={`clip-size ${clip.status === 'error' ? 'error' : 'muted'}`}> type="checkbox"
{clipDetails(clip)} checked={included}
</span> disabled={clip.status !== 'ready'}
{clip.status === 'ready' && ( onChange={(event) => toggleClip(clip.index, event.target.checked)}
<button aria-label={`Clip ${clip.index + 1} von ${clip.sender ?? 'Unbekannt'} ins Video`}
type="button" />
className="secondary small" <span className="clip-time">{formatDateTime(clip.timestamp)}</span>
aria-expanded={preview === clip.index} <span className="clip-text">
onClick={() => setPreview(preview === clip.index ? null : clip.index)} <strong>{clip.sender ?? 'Unbekannt'}</strong>
> {clip.caption && <span className="muted"> · {clip.caption}</span>}
{preview === clip.index ? 'Schließen' : 'Ansehen'} </span>
</button> <span className={`clip-size ${clip.status === 'error' ? 'error' : 'muted'}`}>
{clipDetails(clip)}
</span>
{clip.status === 'ready' && (
<span className="actions">
<button
type="button"
className="secondary small"
aria-expanded={isOpen('preview')}
onClick={() => toggleOpen(clip.index, 'preview')}
>
Ansehen
</button>
<button
type="button"
className={`secondary small${hasVoice ? ' has-voice' : ''}`}
aria-expanded={isOpen('voice')}
onClick={() => toggleOpen(clip.index, 'voice')}
>
{hasVoice ? '● Voice-over' : 'Voice-over'}
</button>
</span>
)}
</div>
{isOpen('preview') && (
<video
className="preview"
src={`api/projects/${project.id}/clips/${clip.index}/file`}
controls
autoPlay
/>
)} )}
</div> {isOpen('voice') && (
{preview === clip.index && ( <VoiceOver
<video project={project}
className="preview" clip={clip}
src={`api/projects/${project.id}/clips/${clip.index}/file`} settings={settings}
controls onProject={setProject}
autoPlay onVolume={(volume) => changeSettings({ voiceVolumes: { [clip.index]: volume } })}
/> />
)} )}
</li> </li>
))} )
})}
</ol> </ol>
</section> </section>
<section className="card stack"> <RenderPanel
<h2>Schnitt</h2> project={project}
<p className="muted">Die Einstellungen für Intro, Outro, Musik und Voice-over folgen im nächsten Schritt.</p> blockers={renderBlockers(project, settings)}
</section> busy={busy}
onStart={startRender}
onCancel={cancelRender}
/>
<div className="actions"> <div className="actions">
<button type="button" className="danger" onClick={remove} disabled={busy}> <button type="button" className="danger" onClick={remove} disabled={busy}>
@@ -153,7 +257,9 @@ export default function ProjectView({ id, onBack }) {
</button> </button>
</div> </div>
<p className="error" role="alert">{error}</p> <p className="error" role="alert">
{error}
</p>
</div> </div>
) )
} }
+81
View File
@@ -0,0 +1,81 @@
import { formatDuration } from './lib/format.js'
// Export starten, Fortschritt zeigen, fertiges Video ansehen und herunterladen.
export default function RenderPanel({ project, blockers, busy, onStart, onCancel }) {
const render = project.render
const active = render.status === 'queued' || render.status === 'running'
const percent = Math.round((render.progress ?? 0) * 100)
return (
<section className="card stack">
<h2>Video erstellen</h2>
{blockers.length > 0 && !active && (
<ul className="warning blockers">
{blockers.map((blocker) => (
<li key={blocker}>{blocker}</li>
))}
</ul>
)}
{render.status === 'queued' && <p aria-live="polite">Wartet, bis ein anderer Export fertig ist …</p>}
{render.status === 'running' && (
<div className="progress-block">
<progress max="100" value={percent} aria-label="Export-Fortschritt">
{percent} %
</progress>
<p className="muted" aria-live="polite">
{percent} % · Ein Best-of mit 40 Clips braucht einige Minuten. Du kannst die Seite
offen lassen oder später wiederkommen.
</p>
</div>
)}
{render.status === 'error' && (
<p className="error" role="alert">
Export fehlgeschlagen: {render.error}
</p>
)}
{render.status === 'cancelled' && <p className="muted">Export abgebrochen.</p>}
{render.status === 'done' && (
<div className="stack-tight">
{render.outdated && (
<p className="warning" role="status">
Seit diesem Export wurde etwas geändert. Erstelle das Video neu, damit die Änderungen
drin sind.
</p>
)}
<video
className="preview"
src={`api/projects/${project.id}/output?v=${encodeURIComponent(render.finishedAt)}`}
controls
preload="metadata"
/>
<div className="actions">
<a className="button" href={`api/projects/${project.id}/output?download=1`} download>
Herunterladen
</a>
<span className="muted">Länge {formatDuration(render.duration)} min · MP4, 1920×1080</span>
</div>
</div>
)}
<div className="actions">
{active ? (
<button type="button" className="secondary" onClick={onCancel} disabled={busy}>
Abbrechen
</button>
) : (
<button
type="button"
className={render.status === 'done' && !render.outdated ? 'secondary' : ''}
onClick={onStart}
disabled={busy || blockers.length > 0}
>
{render.status === 'done' ? 'Neu erstellen' : 'Video erstellen'}
</button>
)}
</div>
</section>
)
}
+21
View File
@@ -0,0 +1,21 @@
import { useId } from 'react'
export default function Slider({ label, value, onChange, disabled = false }) {
const id = useId()
return (
<div className={`slider${disabled ? ' disabled' : ''}`}>
<label htmlFor={id}>{label}</label>
<input
id={id}
type="range"
min="0"
max="100"
step="1"
value={value}
disabled={disabled}
onChange={(event) => onChange(Number(event.target.value))}
/>
<output htmlFor={id}>{value}</output>
</div>
)
}
+263
View File
@@ -0,0 +1,263 @@
import { useEffect, useRef, useState } from 'react'
import { api } from './api.js'
import Slider from './Slider.jsx'
import { formatDuration } from './lib/format.js'
import { clipSpeed } from './lib/plan.js'
import { uploadAsset } from './lib/upload.js'
// Browser spielen Videos höchstens 16-fach ab.
const MAX_PREVIEW_RATE = 16
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
function pickMimeType() {
const candidates = ['audio/webm;codecs=opus', 'audio/mp4', 'audio/webm']
return candidates.find((type) => window.MediaRecorder?.isTypeSupported?.(type)) ?? ''
}
function fileNameFor(type) {
if (type.includes('mp4')) return 'voice-over.mp4'
if (type.includes('ogg')) return 'voice-over.ogg'
return 'voice-over.webm'
}
// Voice-over zu einem Clip: aufnehmen, während der Clip im späteren Tempo läuft,
// oder als Datei hochladen.
export default function VoiceOver({ project, clip, settings, onProject, onVolume }) {
const speed = clipSpeed(clip, settings)
const length = clip.media.duration / speed
const slot = `voice-${clip.index}`
const asset = project.assets[slot]
const savedUrl =
asset?.status === 'ready' ? `api/projects/${project.id}/assets/${slot}/file?r=${asset.revision}` : null
const video = useRef(null)
const audio = useRef(null)
const fileInput = useRef(null)
const recorder = useRef(null)
const stopTimer = useRef(null)
const cancelled = useRef(false)
const [phase, setPhase] = useState('idle')
const [countdown, setCountdown] = useState(0)
const [take, setTake] = useState(null)
const [error, setError] = useState('')
useEffect(() => {
cancelled.current = false
return () => {
cancelled.current = true
clearTimeout(stopTimer.current)
if (recorder.current?.state === 'recording') recorder.current.stop()
recorder.current?.stream.getTracks().forEach((track) => track.stop())
}
}, [])
useEffect(() => () => take && URL.revokeObjectURL(take.url), [take])
function rewind() {
const player = video.current
player.pause()
player.currentTime = 0
player.playbackRate = Math.min(speed, MAX_PREVIEW_RATE)
}
function stop() {
clearTimeout(stopTimer.current)
if (recorder.current?.state === 'recording') recorder.current.stop()
audio.current?.pause()
}
async function record() {
setError('')
let stream
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true },
})
} catch {
setError('Kein Zugriff aufs Mikrofon. Bitte im Browser erlauben.')
return
}
const mimeType = pickMimeType()
const rec = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
const chunks = []
rec.ondataavailable = (event) => {
if (event.data.size > 0) chunks.push(event.data)
}
rec.onstop = () => {
stream.getTracks().forEach((track) => track.stop())
video.current?.pause()
if (cancelled.current) return
const type = rec.mimeType || mimeType || 'audio/webm'
const blob = new Blob(chunks, { type })
setTake({ blob, type, url: URL.createObjectURL(blob) })
setPhase('review')
}
recorder.current = rec
rewind()
setPhase('countdown')
for (let n = 3; n > 0; n -= 1) {
setCountdown(n)
await wait(1000)
if (cancelled.current) return
}
rec.start()
video.current.play()
setPhase('recording')
stopTimer.current = setTimeout(stop, length * 1000)
}
function playTogether() {
rewind()
audio.current.currentTime = 0
video.current.play()
audio.current.play()
}
async function save(file, name) {
setError('')
setPhase('uploading')
try {
onProject(await uploadAsset({ projectId: project.id, slot, file, name }))
setTake(null)
setPhase('idle')
} catch (err) {
setError(err.message)
setPhase(take ? 'review' : 'idle')
}
}
async function remove() {
setError('')
try {
onProject(await api(`projects/${project.id}/assets/${slot}`, { method: 'DELETE' }))
} catch (err) {
setError(err.message)
}
}
function pickFile(event) {
const file = event.target.files[0]
event.target.value = ''
if (file) save(file, file.name)
}
const idle = phase === 'idle'
return (
<div className="voice-over stack-tight">
<video
ref={video}
className="preview"
src={`api/projects/${project.id}/clips/${clip.index}/file`}
muted
playsInline
preload="metadata"
onLoadedMetadata={() => {
video.current.playbackRate = Math.min(speed, MAX_PREVIEW_RATE)
}}
onEnded={stop}
/>
<audio ref={audio} src={take?.url ?? savedUrl ?? undefined} preload="auto" />
<p className="muted">
Der Clip läuft so wie im fertigen Video
{speed > 1 && ` (${speed.toFixed(1).replace('.', ',')}-fach)`}, {formatDuration(length)} min.
Sprich, während er läuft; die Aufnahme endet mit dem Clip.
</p>
{speed > MAX_PREVIEW_RATE && (
<p className="warning">
Der Clip ist so lang, dass die Vorschau langsamer läuft als im fertigen Video. Die Aufnahme
endet trotzdem nach {formatDuration(length)} min.
</p>
)}
{phase === 'countdown' && (
<p className="countdown" aria-live="assertive">
{countdown}
</p>
)}
{phase === 'recording' && (
<p className="recording" aria-live="polite">
● Aufnahme läuft
</p>
)}
{phase === 'uploading' && <p className="muted">Wird gespeichert …</p>}
{idle && asset?.status === 'ready' && (
<p>
Voice-over vorhanden
{asset.media.duration > 0 && (
<span className="muted"> · {formatDuration(asset.media.duration)} min</span>
)}
</p>
)}
<div className="actions">
{idle && (
<>
{asset?.status === 'ready' && (
<button type="button" className="secondary small" onClick={playTogether}>
Mit Clip anhören
</button>
)}
<button type="button" className="small" onClick={record}>
{asset ? 'Neu aufnehmen' : 'Aufnehmen'}
</button>
<button type="button" className="secondary small" onClick={() => fileInput.current.click()}>
Datei hochladen
</button>
{asset && (
<button type="button" className="secondary small" onClick={remove}>
Entfernen
</button>
)}
</>
)}
{(phase === 'countdown' || phase === 'recording') && (
<button type="button" className="small" onClick={stop} disabled={phase === 'countdown'}>
Stopp
</button>
)}
{phase === 'review' && (
<>
<button type="button" className="secondary small" onClick={playTogether}>
Mit Clip anhören
</button>
<button type="button" className="small" onClick={() => save(take.blob, fileNameFor(take.type))}>
Übernehmen
</button>
<button type="button" className="secondary small" onClick={record}>
Neu aufnehmen
</button>
<button
type="button"
className="secondary small"
onClick={() => {
setTake(null)
setPhase('idle')
}}
>
Verwerfen
</button>
</>
)}
</div>
<input ref={fileInput} type="file" accept="audio/*" hidden onChange={pickFile} aria-label="Voice-over-Datei" />
{asset?.status === 'ready' && (
<Slider
label="Lautstärke dieses Voice-overs"
value={settings.voiceVolumes[clip.index] ?? 100}
onChange={onVolume}
/>
)}
{asset?.status === 'error' && <p className="error">{asset.error}</p>}
{error && (
<p className="error" role="alert">
{error}
</p>
)}
</div>
)
}
+46
View File
@@ -0,0 +1,46 @@
// Spiegelt die Regeln aus server/render.js für Anzeige und Voice-over-Vorschau.
export const TARGET_SECONDS = 10
export function clipSpeed(clip, settings) {
const duration = clip.media?.duration ?? 0
return settings.speedUp && duration > TARGET_SECONDS ? duration / TARGET_SECONDS : 1
}
export function includedClips(project, settings) {
return project.clips.filter((clip) => clip.status === 'ready' && !settings.excluded.includes(clip.index))
}
export function estimateDuration(project, settings) {
const clips = includedClips(project, settings).reduce(
(sum, clip) => sum + clip.media.duration / clipSpeed(clip, settings),
0,
)
const bookend = (slot) => {
const asset = project.assets[slot]
return settings[slot].enabled && asset?.status === 'ready' ? asset.media.duration : 0
}
return clips + bookend('intro') + bookend('outro')
}
// Gründe, warum der Export noch nicht starten kann
export function renderBlockers(project, settings) {
const blockers = []
if (includedClips(project, settings).length === 0) blockers.push('Keine fertig hochgeladenen Clips ausgewählt.')
const labels = { intro: 'Intro', outro: 'Outro', music: 'Backing Track' }
for (const [slot, label] of Object.entries(labels)) {
if (settings[slot].enabled && project.assets[slot]?.status !== 'ready') {
blockers.push(`${label} ist angehakt, aber noch keine fertige Datei hochgeladen.`)
}
}
return blockers
}
// Führt Teiländerungen zusammen; Objekte eine Ebene tief, Listen werden ersetzt.
export function mergePatch(base, patch) {
const result = { ...base }
for (const [key, value] of Object.entries(patch)) {
const isObject = value && typeof value === 'object' && !Array.isArray(value)
result[key] = isObject ? { ...base[key], ...value } : value
}
return result
}
+23 -6
View File
@@ -1,11 +1,13 @@
import { api } from '../api.js'
const CHUNK_BYTES = 8 * 1024 * 1024 const CHUNK_BYTES = 8 * 1024 * 1024
const MAX_RETRIES = 6 const MAX_RETRIES = 6
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
// Lädt ein Video in Stücken hoch. Bei Netzfehlern wird mit wachsender Pause // Lädt eine Datei in Stücken hoch. Bei Netzfehlern wird mit wachsender Pause
// wiederholt; weicht der Stand ab, nennt der Server den richtigen Offset. // wiederholt; weicht der Stand ab, nennt der Server den richtigen Offset.
async function uploadClip({ projectId, index, blob, startAt, onProgress, signal }) { async function uploadFile({ url, blob, startAt, onProgress, signal }) {
let offset = startAt let offset = startAt
let failures = 0 let failures = 0
@@ -18,7 +20,7 @@ async function uploadClip({ projectId, index, blob, startAt, onProgress, signal
for (;;) { for (;;) {
let res let res
try { try {
res = await fetch(`api/projects/${projectId}/clips/${index}?offset=${offset}`, { res = await fetch(`${url}?offset=${offset}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/octet-stream' }, headers: { 'Content-Type': 'application/octet-stream' },
body: blob.slice(offset, Math.min(offset + CHUNK_BYTES, blob.size)), body: blob.slice(offset, Math.min(offset + CHUNK_BYTES, blob.size)),
@@ -65,9 +67,8 @@ export async function uploadProject({ project, fileByName, onProgress, signal })
const report = (received) => const report = (received) =>
onProgress({ done: completed + received, total, current: position + 1, count: pending.length }) onProgress({ done: completed + received, total, current: position + 1, count: pending.length })
report(clip.received) report(clip.received)
await uploadClip({ await uploadFile({
projectId: project.id, url: `api/projects/${project.id}/clips/${clip.index}`,
index: clip.index,
blob: fileByName.get(clip.file), blob: fileByName.get(clip.file),
startAt: clip.received, startAt: clip.received,
signal, signal,
@@ -76,3 +77,19 @@ export async function uploadProject({ project, fileByName, onProgress, signal })
completed += clip.size completed += clip.size
} }
} }
// Intro, Outro, Musik oder Voice-over: anmelden, dann in Stücken hochladen.
// Liefert das aktualisierte Projekt.
export async function uploadAsset({ projectId, slot, file, name = file.name, onProgress = () => {} }) {
const base = `api/projects/${projectId}/assets/${slot}`
await api(`projects/${projectId}/assets/${slot}`, { method: 'POST', body: { file: name, size: file.size } })
const status = await uploadFile({
url: base,
blob: file,
startAt: 0,
onProgress: (received) => onProgress(received / file.size),
})
const project = await api(`projects/${projectId}`)
if (status === 'error') throw new Error(project.assets[slot]?.error || 'Datei nicht lesbar.')
return project
}
+126
View File
@@ -342,3 +342,129 @@ progress {
color: var(--text); color: var(--text);
border: 1px solid var(--border); border: 1px solid var(--border);
} }
a.button {
display: inline-block;
padding: 0.6rem 1.1rem;
border: 1px solid var(--accent);
border-radius: var(--radius);
background: var(--accent);
color: var(--accent-text);
text-decoration: none;
}
.option {
display: flex;
gap: 0.6rem;
align-items: flex-start;
cursor: pointer;
}
.option.disabled {
cursor: default;
opacity: 0.75;
}
.option input,
.clip-row input[type='checkbox'] {
width: 1.2rem;
height: 1.2rem;
margin-top: 0.15rem;
flex-shrink: 0;
accent-color: var(--accent);
}
.option-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.asset-field {
margin-left: 1.8rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 1rem;
padding: 0.6rem 0.8rem;
border: 1px dashed var(--border);
border-radius: var(--radius);
}
.asset-field progress {
flex: 1 1 10rem;
}
.mixer {
display: grid;
gap: 0.75rem;
}
.slider {
display: grid;
grid-template-columns: minmax(7rem, 11rem) 1fr 2.5rem;
align-items: center;
gap: 0.75rem;
}
.slider input {
width: 100%;
accent-color: var(--accent);
}
.slider output {
text-align: right;
font-variant-numeric: tabular-nums;
}
.slider.disabled {
opacity: 0.5;
}
@media (max-width: 480px) {
.slider {
grid-template-columns: 1fr 2.5rem;
}
.slider label {
grid-column: 1 / -1;
}
.asset-field {
margin-left: 0;
}
}
.clip-list li.excluded .clip-text,
.clip-list li.excluded .clip-time {
opacity: 0.5;
text-decoration: line-through;
}
.has-voice {
border-color: var(--accent);
color: var(--accent);
}
.voice-over {
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
}
.countdown {
font-size: 2.5rem;
font-weight: 700;
text-align: center;
}
.recording {
color: var(--error);
font-weight: 700;
}
.blockers {
margin: 0;
padding-left: 1.8rem;
}