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
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
+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 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 <p className="error" role="alert">{error}</p>
if (!project || !settings) {
return (
<p className="error" role="alert">
{error}
</p>
)
}
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 (
<div className="stack">
@@ -99,53 +172,84 @@ export default function ProjectView({ id, onBack }) {
</section>
)}
<CutSettings project={project} settings={settings} onSettings={changeSettings} onProject={setProject} />
<section className="card stack">
<h2>
{project.clips.length} Clips
{ready.length > 0 && (
<span className="muted"> · gekürzt etwa {formatDuration(totalSeconds)} min</span>
)}
</h2>
<h2>Clips</h2>
<ol className="clip-list">
{project.clips.map((clip) => (
<li key={clip.index} className="stack-tight">
<div className="clip-row">
<span className="clip-time">{formatDateTime(clip.timestamp)}</span>
<span className="clip-text">
<strong>{clip.sender ?? 'Unbekannt'}</strong>
{clip.caption && <span className="muted"> · {clip.caption}</span>}
</span>
<span className={`clip-size ${clip.status === 'error' ? 'error' : 'muted'}`}>
{clipDetails(clip)}
</span>
{clip.status === 'ready' && (
<button
type="button"
className="secondary small"
aria-expanded={preview === clip.index}
onClick={() => setPreview(preview === clip.index ? null : clip.index)}
>
{preview === clip.index ? 'Schließen' : 'Ansehen'}
</button>
{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 (
<li key={clip.index} className={`stack-tight${included ? '' : ' excluded'}`}>
<div className="clip-row">
<input
type="checkbox"
checked={included}
disabled={clip.status !== 'ready'}
onChange={(event) => toggleClip(clip.index, event.target.checked)}
aria-label={`Clip ${clip.index + 1} von ${clip.sender ?? 'Unbekannt'} ins Video`}
/>
<span className="clip-time">{formatDateTime(clip.timestamp)}</span>
<span className="clip-text">
<strong>{clip.sender ?? 'Unbekannt'}</strong>
{clip.caption && <span className="muted"> · {clip.caption}</span>}
</span>
<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>
{preview === clip.index && (
<video
className="preview"
src={`api/projects/${project.id}/clips/${clip.index}/file`}
controls
autoPlay
/>
)}
</li>
))}
{isOpen('voice') && (
<VoiceOver
project={project}
clip={clip}
settings={settings}
onProject={setProject}
onVolume={(volume) => changeSettings({ voiceVolumes: { [clip.index]: volume } })}
/>
)}
</li>
)
})}
</ol>
</section>
<section className="card stack">
<h2>Schnitt</h2>
<p className="muted">Die Einstellungen für Intro, Outro, Musik und Voice-over folgen im nächsten Schritt.</p>
</section>
<RenderPanel
project={project}
blockers={renderBlockers(project, settings)}
busy={busy}
onStart={startRender}
onCancel={cancelRender}
/>
<div className="actions">
<button type="button" className="danger" onClick={remove} disabled={busy}>
@@ -153,7 +257,9 @@ export default function ProjectView({ id, onBack }) {
</button>
</div>
<p className="error" role="alert">{error}</p>
<p className="error" role="alert">
{error}
</p>
</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 MAX_RETRIES = 6
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.
async function uploadClip({ projectId, index, blob, startAt, onProgress, signal }) {
async function uploadFile({ url, blob, startAt, onProgress, signal }) {
let offset = startAt
let failures = 0
@@ -18,7 +20,7 @@ async function uploadClip({ projectId, index, blob, startAt, onProgress, signal
for (;;) {
let res
try {
res = await fetch(`api/projects/${projectId}/clips/${index}?offset=${offset}`, {
res = await fetch(`${url}?offset=${offset}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/octet-stream' },
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) =>
onProgress({ done: completed + received, total, current: position + 1, count: pending.length })
report(clip.received)
await uploadClip({
projectId: project.id,
index: clip.index,
await uploadFile({
url: `api/projects/${project.id}/clips/${clip.index}`,
blob: fileByName.get(clip.file),
startAt: clip.received,
signal,
@@ -76,3 +77,19 @@ export async function uploadProject({ project, fileByName, onProgress, signal })
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);
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;
}