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:
1 parent
18d5b80959
commit
ccd70bd585
10 files changed
+892
-33
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+131
-25
@@ -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,17 +172,25 @@ 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">
|
||||
{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>
|
||||
@@ -119,17 +200,27 @@ export default function ProjectView({ id, onBack }) {
|
||||
{clipDetails(clip)}
|
||||
</span>
|
||||
{clip.status === 'ready' && (
|
||||
<span className="actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary small"
|
||||
aria-expanded={preview === clip.index}
|
||||
onClick={() => setPreview(preview === clip.index ? null : clip.index)}
|
||||
aria-expanded={isOpen('preview')}
|
||||
onClick={() => toggleOpen(clip.index, 'preview')}
|
||||
>
|
||||
{preview === clip.index ? 'Schließen' : 'Ansehen'}
|
||||
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>
|
||||
{preview === clip.index && (
|
||||
{isOpen('preview') && (
|
||||
<video
|
||||
className="preview"
|
||||
src={`api/projects/${project.id}/clips/${clip.index}/file`}
|
||||
@@ -137,15 +228,28 @@ export default function ProjectView({ id, onBack }) {
|
||||
autoPlay
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user