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 }
+
+ input.current.click()} disabled={busy}>
+ {asset ? 'Ersetzen' : 'Datei wählen'}
+
+ {asset && (
+
+ Entfernen
+
+ )}
+
+
+ {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 (
+
+ onChange?.(event.target.checked)} />
+
+ {children}
+ {hint && · {hint} }
+
+
+ )
+}
+
+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
+
+
+
+
+
onSettings({ intro: { enabled } })}>
+ Intro einfügen
+
+ {settings.intro.enabled && (
+
+ )}
+
+
+
+
onSettings({ outro: { enabled } })}>
+ Outro einfügen
+
+ {settings.outro.enabled && (
+
+ )}
+
+
+
+ Videos in Chat-Reihenfolge aneinanderreihen
+
+
+ onSettings({ speedUp })}>
+ Videos kürzen: jedes auf {TARGET_SECONDS} Sekunden beschleunigen
+
+
+ onSettings({ muteClips })}
+ hint="Intro und Outro behalten ihren Ton"
+ >
+ Ton der Videos stummschalten
+
+
+
+
onSettings({ music: { enabled } })}
+ hint="läuft nur unter den Clips, wird wiederholt und am Ende ausgeblendet"
+ >
+ Backing Track unterlegen
+
+ {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) => (
-
-
-
{formatDateTime(clip.timestamp)}
-
- {clip.sender ?? 'Unbekannt'}
- {clip.caption && · {clip.caption} }
-
-
- {clipDetails(clip)}
-
- {clip.status === 'ready' && (
-
setPreview(preview === clip.index ? null : clip.index)}
- >
- {preview === clip.index ? 'Schließen' : 'Ansehen'}
-
+ {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 (
+
+
+ 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' && (
+
+ toggleOpen(clip.index, 'preview')}
+ >
+ Ansehen
+
+ toggleOpen(clip.index, 'voice')}
+ >
+ {hasVoice ? '● Voice-over' : 'Voice-over'}
+
+
+ )}
+
+ {isOpen('preview') && (
+
)}
-
- {preview === clip.index && (
-
- )}
-
- ))}
+ {isOpen('voice') && (
+ changeSettings({ voiceVolumes: { [clip.index]: volume } })}
+ />
+ )}
+
+ )
+ })}
-
- Schnitt
- Die Einstellungen für Intro, Outro, Musik und Voice-over folgen im nächsten Schritt.
-
+
@@ -153,7 +257,9 @@ export default function ProjectView({ id, onBack }) {
-
{error}
+
+ {error}
+
)
}
diff --git a/src/RenderPanel.jsx b/src/RenderPanel.jsx
new file mode 100644
index 0000000..fc695ac
--- /dev/null
+++ b/src/RenderPanel.jsx
@@ -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 (
+
+ Video erstellen
+
+ {blockers.length > 0 && !active && (
+
+ {blockers.map((blocker) => (
+ {blocker}
+ ))}
+
+ )}
+
+ {render.status === 'queued' && Wartet, bis ein anderer Export fertig ist …
}
+ {render.status === 'running' && (
+
+
+ {percent} %
+
+
+ {percent} % · Ein Best-of mit 40 Clips braucht einige Minuten. Du kannst die Seite
+ offen lassen oder später wiederkommen.
+
+
+ )}
+ {render.status === 'error' && (
+
+ Export fehlgeschlagen: {render.error}
+
+ )}
+ {render.status === 'cancelled' && Export abgebrochen.
}
+
+ {render.status === 'done' && (
+
+ {render.outdated && (
+
+ Seit diesem Export wurde etwas geändert. Erstelle das Video neu, damit die Änderungen
+ drin sind.
+
+ )}
+
+
+
+ )}
+
+
+ {active ? (
+
+ Abbrechen
+
+ ) : (
+ 0}
+ >
+ {render.status === 'done' ? 'Neu erstellen' : 'Video erstellen'}
+
+ )}
+
+
+ )
+}
diff --git a/src/Slider.jsx b/src/Slider.jsx
new file mode 100644
index 0000000..54053ea
--- /dev/null
+++ b/src/Slider.jsx
@@ -0,0 +1,21 @@
+import { useId } from 'react'
+
+export default function Slider({ label, value, onChange, disabled = false }) {
+ const id = useId()
+ return (
+
+ {label}
+ onChange(Number(event.target.value))}
+ />
+ {value}
+
+ )
+}
diff --git a/src/VoiceOver.jsx b/src/VoiceOver.jsx
new file mode 100644
index 0000000..0c8665c
--- /dev/null
+++ b/src/VoiceOver.jsx
@@ -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 (
+
+
{
+ video.current.playbackRate = Math.min(speed, MAX_PREVIEW_RATE)
+ }}
+ onEnded={stop}
+ />
+
+
+
+ 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.
+
+ {speed > MAX_PREVIEW_RATE && (
+
+ Der Clip ist so lang, dass die Vorschau langsamer läuft als im fertigen Video. Die Aufnahme
+ endet trotzdem nach {formatDuration(length)} min.
+
+ )}
+
+ {phase === 'countdown' && (
+
+ {countdown}
+
+ )}
+ {phase === 'recording' && (
+
+ ● Aufnahme läuft
+
+ )}
+ {phase === 'uploading' && Wird gespeichert …
}
+ {idle && asset?.status === 'ready' && (
+
+ Voice-over vorhanden
+ {asset.media.duration > 0 && (
+ · {formatDuration(asset.media.duration)} min
+ )}
+
+ )}
+
+
+ {idle && (
+ <>
+ {asset?.status === 'ready' && (
+
+ Mit Clip anhören
+
+ )}
+
+ {asset ? 'Neu aufnehmen' : 'Aufnehmen'}
+
+ fileInput.current.click()}>
+ Datei hochladen
+
+ {asset && (
+
+ Entfernen
+
+ )}
+ >
+ )}
+ {(phase === 'countdown' || phase === 'recording') && (
+
+ Stopp
+
+ )}
+ {phase === 'review' && (
+ <>
+
+ Mit Clip anhören
+
+ save(take.blob, fileNameFor(take.type))}>
+ Übernehmen
+
+
+ Neu aufnehmen
+
+ {
+ setTake(null)
+ setPhase('idle')
+ }}
+ >
+ Verwerfen
+
+ >
+ )}
+
+
+
+ {asset?.status === 'ready' && (
+
+ )}
+ {asset?.status === 'error' && {asset.error}
}
+ {error && (
+
+ {error}
+
+ )}
+
+ )
+}
diff --git a/src/lib/plan.js b/src/lib/plan.js
new file mode 100644
index 0000000..563dcab
--- /dev/null
+++ b/src/lib/plan.js
@@ -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
+}
diff --git a/src/lib/upload.js b/src/lib/upload.js
index 1725431..39536b9 100644
--- a/src/lib/upload.js
+++ b/src/lib/upload.js
@@ -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
+}
diff --git a/src/styles.css b/src/styles.css
index 1cc82e3..7c154e1 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -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;
+}