diff --git a/README.md b/README.md index 7f7650e..950b9cf 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ 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: Grundgerüst mit Anmeldung. Import, Schnitt und Oberfläche folgen. +> Stand: Anmeldung, Import des Exports und Upload der Videos laufen. Der Schnitt folgt. ## Voraussetzungen @@ -29,11 +29,11 @@ unscharfem Hintergrund eingesetzt. Zugang ist über eine PIN geschützt. ```bash npm install cp .env.example .env # APP_PIN eintragen -APP_PIN=1234 npm run dev +npm run dev ``` `npm run dev` startet den Express-Server samt Vite im Entwicklungsmodus auf -einem Port (`PORT`, Vorgabe 3000). In Produktion: +einem Port (`PORT`, Vorgabe 3000) und liest dabei die lokale `.env`. In Produktion: ```bash npm run build @@ -49,6 +49,15 @@ NODE_ENV=production APP_PIN=… npm start | `PORT` | Port des Servers, Vorgabe 3000 | | `VCH_DATA_DIR` | Ablage für Projekte und Uploads, Vorgabe `./data` | +## Ablauf + +1. WhatsApp-Chat mit „Medien anhängen“ exportieren und entpacken. +2. In der App „Neues Video“, den Exportordner wählen. Der Browser liest den + Chat selbst; vorgewählt ist Montag bis Freitag der Vorwoche. +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. + ## Befehle - `npm test` – Tests (Node-Testrunner) @@ -57,8 +66,10 @@ NODE_ENV=production APP_PIN=… npm start ## Aufbau -- `server/` – Express-Server: Anmeldung, API, später Import und ffmpeg-Schnitt +- `server/` – Express-Server: Anmeldung, Projekte, Upload in Stücken, ffprobe - `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 - `test/` – Tests - `Dockerfile` – Produktionsimage mit ffmpeg; Deploy über VCH (`.vch/deploy.yaml`) diff --git a/server/index.js b/server/index.js index fc9d93d..c192a85 100644 --- a/server/index.js +++ b/server/index.js @@ -10,6 +10,15 @@ import { createProjectStore } from './projects.js'; const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +// Lokal liest der Entwicklungsserver eine .env; in Produktion setzt VCH die Umgebung. +if (process.env.NODE_ENV !== 'production') { + try { + process.loadEnvFile(path.join(rootDir, '.env')); + } catch { + // keine .env vorhanden + } +} + const config = loadConfig(); if (!config.pin) { console.warn('APP_PIN ist nicht gesetzt – Anmeldung ist nicht möglich.'); diff --git a/src/App.jsx b/src/App.jsx index 3bae47b..a51cc5c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,10 +1,14 @@ import { useEffect, useState } from 'react' import { api } from './api.js' +import Home from './Home.jsx' +import ImportView from './ImportView.jsx' import Login from './Login.jsx' +import ProjectView from './ProjectView.jsx' export default function App() { const [session, setSession] = useState(null) const [loadError, setLoadError] = useState('') + const [view, setView] = useState({ name: 'home' }) async function loadSession() { try { @@ -29,17 +33,19 @@ export default function App() { return } + const home = () => setView({ name: 'home' }) + const openProject = (id) => setView({ name: 'project', id }) + return (
- FuerDieCuts +
-
-

Neues Video

-

Hier kommt als Nächstes der Import des WhatsApp-Exports hin.

-
+ {view.name === 'home' && setView({ name: 'import' })} onOpen={openProject} />} + {view.name === 'import' && } + {view.name === 'project' && }
) diff --git a/src/ExportPicker.jsx b/src/ExportPicker.jsx new file mode 100644 index 0000000..5810548 --- /dev/null +++ b/src/ExportPicker.jsx @@ -0,0 +1,30 @@ +import { useRef } from 'react' + +// Ordnerauswahl für den entpackten Export, dazu die Auswahl einzelner Dateien. +export default function ExportPicker({ onPick, disabled, folderLabel = 'Exportordner wählen' }) { + const folderInput = useRef(null) + const filesInput = useRef(null) + + function handleChange(event) { + if (event.target.files.length > 0) onPick(event.target.files) + event.target.value = '' + } + + return ( +
+ + + + +
+ ) +} diff --git a/src/Home.jsx b/src/Home.jsx new file mode 100644 index 0000000..09ec881 --- /dev/null +++ b/src/Home.jsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react' +import { api } from './api.js' +import { formatDate } from './lib/format.js' + +export default function Home({ onNew, onOpen }) { + const [projects, setProjects] = useState(null) + const [error, setError] = useState('') + + useEffect(() => { + api('projects').then(setProjects, (err) => setError(err.message)) + }, []) + + return ( +
+
+

Deine Videos

+ +
+ + {projects && projects.length === 0 && ( +
+

Noch keine Projekte. Starte mit „Neues Video“ und dem WhatsApp-Export.

+
+ )} + + {projects && projects.length > 0 && ( + + )} + +

{error}

+
+ ) +} diff --git a/src/ImportView.jsx b/src/ImportView.jsx new file mode 100644 index 0000000..66fb5ae --- /dev/null +++ b/src/ImportView.jsx @@ -0,0 +1,233 @@ +import { useState } from 'react' +import { api } from './api.js' +import ExportPicker from './ExportPicker.jsx' +import UploadProgress from './UploadProgress.jsx' +import { filesByBaseName, readExport } from './lib/exportFiles.js' +import { daysInRange, formatBytes, formatDateTime, formatDay } from './lib/format.js' +import { uploadProject } from './lib/upload.js' +import { clipsInRange, collectClips, parseChat, previousWorkWeek } from './lib/whatsapp.js' + +const inDayRange = (timestamp, range) => { + const day = timestamp.slice(0, 10) + return day >= range.from && day <= range.to +} + +export default function ImportView({ onCancel, onDone }) { + const [source, setSource] = useState(null) + const [range, setRange] = useState(() => previousWorkWeek()) + const [excluded, setExcluded] = useState(() => new Set()) + const [project, setProject] = useState(null) + const [progress, setProgress] = useState(null) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + + async function pick(fileList) { + setError('') + setBusy(true) + try { + const { chatText, files } = await readExport(fileList) + const { clips, missing } = collectClips(parseChat(chatText), files) + if (clips.length === 0) { + throw new Error('Im Export sind keine Videos. Wurde er mit „Medien anhängen“ erstellt?') + } + setSource({ clips, missing, fileByName: filesByBaseName(files) }) + setExcluded(new Set(clips.filter((clip) => clip.duplicateOf).map((clip) => clip.file))) + } catch (err) { + setError(err.message) + } finally { + setBusy(false) + } + } + + const clips = source ? clipsInRange(source.clips, range.from, range.to) : [] + const missing = source ? source.missing.filter((entry) => inDayRange(entry.timestamp, range)) : [] + const selected = clips.filter((clip) => !excluded.has(clip.file)) + const selectedBytes = selected.reduce((sum, clip) => sum + clip.size, 0) + const days = daysInRange(range.from, range.to).map((day) => ({ + day, + count: clips.filter((clip) => clip.timestamp.startsWith(day)).length, + })) + const locked = Boolean(project) || busy + + function toggle(file) { + setExcluded((current) => { + const next = new Set(current) + if (next.has(file)) next.delete(file) + else next.add(file) + return next + }) + } + + function selectAll(include) { + setExcluded((current) => { + const next = new Set(current) + for (const clip of clips) { + if (include) next.delete(clip.file) + else next.add(clip.file) + } + return next + }) + } + + // Legt das Projekt einmal an; ein zweiter Aufruf setzt einen abgebrochenen Upload fort. + async function upload() { + setBusy(true) + setError('') + try { + let current = project + if (current) { + current = await api(`projects/${current.id}`) + } else { + current = await api('projects', { + method: 'POST', + body: { + range, + clips: selected.map(({ file, size, timestamp, sender, caption }) => ({ + file, + size, + timestamp, + sender, + caption, + })), + }, + }) + setProject(current) + } + await uploadProject({ project: current, fileByName: source.fileByName, onProgress: setProgress }) + onDone(current.id) + } catch (err) { + setError(err.message) + } finally { + setBusy(false) + } + } + + return ( +
+
+

Neues Video

+ +
+ +
+

1. WhatsApp-Export wählen

+

+ Den Chat mit „Medien anhängen“ exportieren und den entpackten Ordner wählen. Hochgeladen + werden später nur die Videos aus dem gewählten Zeitraum. +

+ + {source && ( +

+ {source.clips.length} Videos im Export gefunden + {source.clips.length > 0 && + `, vom ${formatDateTime(source.clips[0].timestamp)} bis ${formatDateTime(source.clips.at(-1).timestamp)}`} + . +

+ )} +
+ + {source && ( +
+

2. Zeitraum und Videos

+
+ + +
+ +
    + {days.map(({ day, count }) => ( +
  • + {formatDay(day)}: {count} +
  • + ))} +
+ + {missing.length > 0 && ( +

+ {missing.length} Video(s) aus diesem Zeitraum stehen im Chat, fehlen aber im Export. + Wahrscheinlich sind sie nicht auf dem Handy gespeichert. +

+ )} + {days.some(({ count }) => count === 0) && ( +

+ An manchen Tagen gibt es keine Videos. Fehlen Videos, waren sie beim Export + vermutlich nicht auf dem Handy heruntergeladen. +

+ )} + + {clips.length > 0 ? ( + <> +
+ + +
+
    + {clips.map((clip) => ( +
  • + +
  • + ))} +
+ + ) : ( +

Keine Videos in diesem Zeitraum.

+ )} +
+ )} + + {source && ( +
+

3. Hochladen

+ +
+ +
+
+ )} + +

{error}

+
+ ) +} diff --git a/src/ProjectView.jsx b/src/ProjectView.jsx new file mode 100644 index 0000000..f039e42 --- /dev/null +++ b/src/ProjectView.jsx @@ -0,0 +1,159 @@ +import { useCallback, useEffect, useState } from 'react' +import { api } from './api.js' +import ExportPicker from './ExportPicker.jsx' +import UploadProgress from './UploadProgress.jsx' +import { filesByBaseName } from './lib/exportFiles.js' +import { formatDate, formatDateTime, formatDuration } from './lib/format.js' +import { uploadProject } from './lib/upload.js' + +function clipDetails(clip) { + if (clip.status === 'error') return `Fehler: ${clip.error}` + if (clip.status !== 'ready') return 'Upload offen' + const { duration, width, height, hasAudio } = clip.media + return [formatDuration(duration), width > height ? 'Quer' : 'Hoch', !hasAudio && 'ohne Ton'] + .filter(Boolean) + .join(' · ') +} + +export default function ProjectView({ id, onBack }) { + const [project, setProject] = useState(null) + const [preview, setPreview] = useState(null) + const [progress, setProgress] = useState(null) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + + const load = useCallback(async () => { + try { + setProject(await api(`projects/${id}`)) + } catch (err) { + setError(err.message) + } + }, [id]) + + useEffect(() => { + load() + }, [load]) + + async function resume(fileList) { + setBusy(true) + setError('') + try { + const files = Array.from(fileList).map((file) => ({ + name: file.webkitRelativePath || file.name, + file, + })) + await uploadProject({ + project: await api(`projects/${id}`), + fileByName: filesByBaseName(files), + onProgress: setProgress, + }) + setProgress(null) + } catch (err) { + setError(err.message) + } finally { + setBusy(false) + load() + } + } + + async function remove() { + if (!window.confirm('Projekt samt hochgeladenen Videos löschen?')) return + try { + await api(`projects/${id}`, { method: 'DELETE' }) + onBack() + } catch (err) { + setError(err.message) + } + } + + if (!project) { + 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) + + return ( +
+
+

+ {project.range.from && project.range.to + ? `${formatDate(project.range.from)} – ${formatDate(project.range.to)}` + : 'Projekt'} +

+ +
+ + {pending.length > 0 && ( +
+

Upload unvollständig

+

+ {pending.length} Video(s) fehlen noch. Wähle den Exportordner erneut, der Upload macht + dort weiter, wo er aufgehört hat. +

+ + +
+ )} + +
+

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

+
    + {project.clips.map((clip) => ( +
  1. +
    + {formatDateTime(clip.timestamp)} + + {clip.sender ?? 'Unbekannt'} + {clip.caption && · {clip.caption}} + + + {clipDetails(clip)} + + {clip.status === 'ready' && ( + + )} +
    + {preview === clip.index && ( +
  2. + ))} +
+
+ +
+

Schnitt

+

Die Einstellungen für Intro, Outro, Musik und Voice-over folgen im nächsten Schritt.

+
+ +
+ +
+ +

{error}

+
+ ) +} diff --git a/src/UploadProgress.jsx b/src/UploadProgress.jsx new file mode 100644 index 0000000..2efd145 --- /dev/null +++ b/src/UploadProgress.jsx @@ -0,0 +1,15 @@ +import { formatBytes } from './lib/format.js' + +export default function UploadProgress({ progress }) { + if (!progress) return null + const percent = progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 100 + return ( +
+ {percent} % +

+ Video {progress.current} von {progress.count} · {formatBytes(progress.done)} von{' '} + {formatBytes(progress.total)} · {percent} % +

+
+ ) +} diff --git a/src/lib/exportFiles.js b/src/lib/exportFiles.js new file mode 100644 index 0000000..d03743a --- /dev/null +++ b/src/lib/exportFiles.js @@ -0,0 +1,31 @@ +import { baseName } from './whatsapp.js' + +function pickChatFile(files) { + const texts = files.filter((file) => /\.txt$/i.test(file.name)) + return ( + texts.find((file) => baseName(file.name) === '_chat.txt') ?? + texts.find((file) => /chat/i.test(baseName(file.name))) ?? + (texts.length === 1 ? texts[0] : null) + ) +} + +// Nimmt die Auswahl aus einem Ordner- oder Dateidialog entgegen. +export async function readExport(fileList) { + const files = Array.from(fileList).map((file) => ({ + name: file.webkitRelativePath || file.name, + size: file.size, + file, + })) + if (files.some((file) => /\.zip$/i.test(file.name)) && !pickChatFile(files)) { + throw new Error('Bitte die ZIP-Datei zuerst entpacken (Doppelklick) und dann den Ordner wählen.') + } + const chat = pickChatFile(files) + if (!chat) { + throw new Error('Keine Chatdatei (_chat.txt) gefunden. Bitte den ganzen Exportordner wählen.') + } + return { chatText: await chat.file.text(), files } +} + +export function filesByBaseName(files) { + return new Map(files.map((file) => [baseName(file.name), file.file])) +} diff --git a/src/lib/format.js b/src/lib/format.js new file mode 100644 index 0000000..cbdf783 --- /dev/null +++ b/src/lib/format.js @@ -0,0 +1,48 @@ +import { toDateString } from './whatsapp.js' + +const WEEKDAYS = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'] + +export function formatBytes(bytes) { + if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1).replace('.', ',')} GB` + return `${Math.max(1, Math.round(bytes / 1e6))} MB` +} + +// "2026-09-21T10:15:30" → "21.09. 10:15" +export function formatDateTime(timestamp) { + const [date, time] = timestamp.split('T') + const [, month, day] = date.split('-') + return `${day}.${month}. ${time.slice(0, 5)}` +} + +// "2026-09-21" → "Mo 21.09." +export function formatDay(date) { + const [year, month, day] = date.split('-').map(Number) + const weekday = WEEKDAYS[new Date(year, month - 1, day).getDay()] + return `${weekday} ${String(day).padStart(2, '0')}.${String(month).padStart(2, '0')}.` +} + +// "2026-09-21" → "21.09.2026" +export function formatDate(date) { + const [year, month, day] = date.split('-') + return `${day}.${month}.${year}` +} + +export function formatDuration(seconds) { + const whole = Math.round(seconds) + return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}` +} + +// Alle Tage von from bis to, beide eingeschlossen, höchstens 62. +export function daysInRange(from, to) { + if (!from || !to || from > to) return [] + const days = [] + const [year, month, day] = from.split('-').map(Number) + const cursor = new Date(year, month - 1, day) + while (days.length < 62) { + const value = toDateString(cursor) + if (value > to) break + days.push(value) + cursor.setDate(cursor.getDate() + 1) + } + return days +} diff --git a/src/lib/upload.js b/src/lib/upload.js new file mode 100644 index 0000000..1725431 --- /dev/null +++ b/src/lib/upload.js @@ -0,0 +1,78 @@ +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 +// wiederholt; weicht der Stand ab, nennt der Server den richtigen Offset. +async function uploadClip({ projectId, index, blob, startAt, onProgress, signal }) { + let offset = startAt + let failures = 0 + + const retry = async (message) => { + failures += 1 + if (failures > MAX_RETRIES) throw new Error(message) + await wait(Math.min(30000, 1000 * 2 ** failures)) + } + + for (;;) { + let res + try { + res = await fetch(`api/projects/${projectId}/clips/${index}?offset=${offset}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/octet-stream' }, + body: blob.slice(offset, Math.min(offset + CHUNK_BYTES, blob.size)), + credentials: 'same-origin', + signal, + }) + } catch (error) { + if (signal?.aborted) throw error + await retry('Verbindung verloren. Bitte erneut versuchen.') + continue + } + + const data = await res.json().catch(() => ({})) + if (res.status === 409) { + if (Number.isSafeInteger(data.received)) offset = data.received + else await retry(data.error || 'Upload hängt.') + continue + } + if (res.status >= 500) { + await retry(data.error || `Serverfehler (${res.status})`) + continue + } + if (!res.ok) throw new Error(data.error || `Upload fehlgeschlagen (${res.status})`) + + failures = 0 + offset = data.received + onProgress(offset) + if (data.status !== 'pending') return data.status + } +} + +// Lädt alle noch offenen Clips eines Projekts hoch. fileByName: Dateiname → File. +export async function uploadProject({ project, fileByName, onProgress, signal }) { + const pending = project.clips.filter((clip) => clip.status === 'pending') + const missing = pending.filter((clip) => !fileByName.has(clip.file)) + if (missing.length > 0) { + throw new Error(`${missing.length} Video(s) fehlen in der Auswahl, z. B. ${missing[0].file}.`) + } + + const total = pending.reduce((sum, clip) => sum + clip.size, 0) + let completed = 0 + + for (const [position, clip] of pending.entries()) { + 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, + blob: fileByName.get(clip.file), + startAt: clip.received, + signal, + onProgress: report, + }) + completed += clip.size + } +} diff --git a/src/styles.css b/src/styles.css index 34d935c..1cc82e3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -7,6 +7,8 @@ --accent: #1f6f4a; --accent-text: #ffffff; --error: #b3261e; + --warn-bg: #fff4d6; + --warn-text: #6b4e00; --radius: 10px; color-scheme: light; } @@ -21,6 +23,8 @@ --accent: #4cc38a; --accent-text: #0d1f16; --error: #ff8a80; + --warn-bg: #3a300f; + --warn-text: #f3d77a; color-scheme: dark; } } @@ -34,6 +38,8 @@ --accent: #4cc38a; --accent-text: #0d1f16; --error: #ff8a80; + --warn-bg: #3a300f; + --warn-text: #f3d77a; color-scheme: dark; } @@ -137,6 +143,10 @@ input { .brand { font-weight: 700; + background: none; + border: none; + color: var(--text); + padding: 0.25rem 0; } .content { @@ -145,7 +155,190 @@ input { padding: 1.5rem 16px; } -.empty p { - color: var(--muted); + +h2 { + margin: 0; + font-size: 1.1rem; +} + +p { margin: 0; } + +.muted { + color: var(--muted); +} + +.stack { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.stack-tight { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: flex-end; +} + +.heading-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + justify-content: space-between; +} + +.heading-row h1 { + margin: 0; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.9rem; + color: var(--muted); +} + +button.small { + padding: 0.3rem 0.7rem; + font-size: 0.9rem; +} + +button.danger { + background: transparent; + color: var(--error); + border-color: var(--error); +} + +.warning { + background: var(--warn-bg); + color: var(--warn-text); + padding: 0.6rem 0.8rem; + border-radius: var(--radius); +} + +.days { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + list-style: none; + margin: 0; + padding: 0; +} + +.days li { + padding: 0.25rem 0.6rem; + border: 1px solid var(--border); + border-radius: 999px; + font-size: 0.9rem; +} + +.days li.warn { + background: var(--warn-bg); + color: var(--warn-text); + border-color: transparent; +} + +.clip-list { + list-style: none; + margin: 0; + padding: 0; + border-top: 1px solid var(--border); +} + +.clip-list li { + border-bottom: 1px solid var(--border); + padding: 0.5rem 0; +} + +.clip-list label, +.clip-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem 0.75rem; +} + +.clip-list label { + cursor: pointer; +} + +.clip-list input[type='checkbox'] { + width: 1.2rem; + height: 1.2rem; + accent-color: var(--accent); +} + +.clip-time { + font-variant-numeric: tabular-nums; + color: var(--muted); + white-space: nowrap; +} + +.clip-text { + flex: 1 1 14rem; + min-width: 0; + overflow-wrap: anywhere; +} + +.clip-size { + white-space: nowrap; + font-size: 0.9rem; +} + +.badge { + font-size: 0.8rem; + padding: 0.1rem 0.5rem; + border-radius: 999px; + background: var(--warn-bg); + color: var(--warn-text); +} + +.preview { + width: 100%; + max-height: 60vh; + background: #000; + border-radius: var(--radius); +} + +.progress-block { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +progress { + width: 100%; + height: 0.75rem; + accent-color: var(--accent); +} + +.project-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.project-card { + width: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + text-align: left; + background: var(--surface); + color: var(--text); + border: 1px solid var(--border); +}