Compare commits

...
13 Commits
Author SHA1 Message Date
ferdi2goandClaude Opus 5.5 9cb7ce9555 App nach dem tradeo Design-System gestalten
Kopfzeile und primäre Aktionen in tradeo Blue, Poppins für Titel, Inter
für UI und Text, 8-px-Raster, Abschnitte durch Weißraum statt Kästen,
keine Symbole als Icon-Ersatz. Grautöne, Statusfarben, Radius und
Schriftgrößen legt das Design-System 0.1 noch nicht fest; sie stehen als
vorläufige, aus tradeo Blue abgeleitete Werte gesammelt in styles.css.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:37:53 +00:00
ferdi2goandClaude Opus 5.5 ccd70bd585 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>
2026-09-29 11:00:59 +00:00
ferdi2goandClaude Opus 5.5 18d5b80959 Länge von Browser-Aufnahmen ohne Kopfangabe ausmessen
WebM aus dem MediaRecorder nennt oft keine Gesamtlänge; ffprobe liest
sie dann aus dem letzten Paket. Videos ohne lesbare Länge werden als
Fehler markiert, weil sich ihr Tempo sonst nicht berechnen lässt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:00:59 +00:00
ferdi2goandClaude Opus 5.5 6e497ffc45 Export im Hintergrund mit Fortschritt, Abbruch und Download
Exporte laufen nacheinander. Der Fortschritt steht im Speicher; ein nach
einem Neustart verwaister Export wird als unterbrochen gemeldet. Nach
Änderungen an Einstellungen oder Dateien gilt ein fertiges Video als
veraltet. Der Download trägt den Zeitraum im Dateinamen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:54:31 +00:00
ferdi2goandClaude Opus 5.5 03bfda09b2 Schnitt-Kette mit ffmpeg bauen
Jeder Clip wird einzeln auf 1920x1080 bei 30 fps gebracht, bei Bedarf
auf 10 Sekunden beschleunigt und hochkant mit unscharfem Hintergrund
gesetzt; Clip-Ton oder Stille plus Voice-over werden eingemischt. Der
Backing Track läuft nur unter den Clips, wird wiederholt, gekürzt und
ausgeblendet. Segmente werden zwischengespeichert, Zwischenstände tragen
unkomprimierten Ton, damit sich über viele Übergänge nichts verschiebt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:53:14 +00:00
ferdi2goandClaude Opus 5.5 72e4b8ab7a Einstellungen und Zusatzdateien für den Schnitt ablegen
Intro, Outro, Backing Track und Voice-overs pro Clip laufen über
denselben Upload in Stücken wie die Clips. Die Schnitt-Einstellungen
werden feldweise geprüft; ffprobe akzeptiert jetzt auch reine Tonspuren.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:48:53 +00:00
ferdi2goandClaude Opus 5.5 d7102d04a4 Import-Oberfläche mit Zeitraum, Clipauswahl und Upload bauen
Der Browser liest den entpackten Exportordner selbst, schlägt Montag bis
Freitag der Vorwoche vor, zeigt Videos pro Tag und warnt bei Lücken und
fehlenden Dateien. Nur die gewählten Videos werden hochgeladen; ein
abgebrochener Upload lässt sich in der Projektansicht fortsetzen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:04:40 +00:00
ferdi2goandClaude Opus 5.5 782d306cb1 Projekte anlegen und Clips in Stücken hochladen
Ein Projekt hält die ausgewählten Clips samt Absender und Beschriftung.
Videos kommen in Stücken bis 16 MB; die Größe der Teildatei ist der
Stand, sodass ein abgebrochener Upload dort weitermacht. Nach dem
letzten Stück liest ffprobe Länge, Größe, Drehung und Tonspur aus.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:42:19 +00:00
ferdi2goandClaude Opus 5.5 6f583c8f5b Videos nach Chatreihenfolge statt nach Zeitstempel ordnen
WhatsApp stempelt beim Synchronisieren gelegentlich alte Nachrichten
neu, im echten Export mehrere Nachrichten am 19.08.26 um 13:17. Die
Position im Chat bildet die tatsächliche Reihenfolge verlässlich ab.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:29:43 +00:00
ferdi2goandClaude Opus 5.5 ce84e1103d Ordner für private Beispielexporte von Git und Docker ausnehmen
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:25:31 +00:00
ferdi2goandClaude Opus 5.5 899b212cba WhatsApp-Chatexport einlesen und Videos chronologisch ordnen
Erkennt iPhone- und Android-Exporte auf Deutsch und Englisch, ordnet
Videos nach Zeitstempel statt nach Chatreihenfolge, meldet fehlende
Dateien, markiert doppelt gepostete Videos und filtert auf Montag bis
Freitag der Vorwoche.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:25:22 +00:00
ferdi2goandClaude Opus 5.5 f7d249f28b Grundgerüst mit Server, PIN-Anmeldung und Docker-Deploy aufbauen
Die alte Clip-Auswahl-Oberfläche entfällt. Neu sind ein Express-Server,
der im Entwicklungsbetrieb Vite einbindet, eine PIN-Anmeldung mit
signiertem Sitzungs-Cookie und ein Docker-Image mit ffmpeg für VCH.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:15:01 +00:00
ferdi2goandClaude Opus 5.5 8ed1396b1c VCH-Plattformregeln einbinden
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 08:48:58 +00:00
52 changed files with 9393 additions and 1427 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
data
.git
.env
.vch
samples
+4
View File
@@ -0,0 +1,4 @@
# PIN für die Anmeldung (Pflicht)
APP_PIN=
# Signiert die Sitzungs-Cookies; ohne Wert wird eines im Datenverzeichnis erzeugt
SESSION_SECRET= # optional
+2
View File
@@ -3,3 +3,5 @@ dist/
.env
.env.local
.env.*.local
data/
samples/
+1 -1
View File
@@ -1 +1 @@
A desktop tool for assembling weekly "Best-of" video reels from WhatsApp clips. Import clips, get automatic transcriptions via Whisper, review AI recommendations for each clip, select what goes into the reel, and reorder via drag-and-drop before exporting with ffmpeg.
FuerDieCuts macht aus einem WhatsApp-Chatexport automatisch ein fertiges Video für YouTube. Man lädt den Export mit allen Medien hoch, hakt an, was passieren soll, und bekommt ein geschnittenes Video zurück: alle Clips in der Reihenfolge, in der sie im Chat gepostet wurden, jeder auf höchstens zehn Sekunden beschleunigt, mit eigenem Intro und Outro, Musik darunter und auf Wunsch einem gesprochenen Kommentar zu jedem Clip. Das ersetzt stundenlanges Schneiden von Hand, wenn man regelmäßig aus Dutzenden Handyvideos einer Gruppe einen Zusammenschnitt macht.
+447
View File
@@ -0,0 +1,447 @@
# VCH-Plattform — die Regeln dieser Umgebung
Diese Datei gehört VCH und wird bei jedem Abgleich **überschrieben**. Projektwissen gehört
in die `CLAUDE.md` daneben, nicht hierher.
Sie beschreibt, was VCH mit diesem Projekt macht — vor allem die Stellen, an denen ein
Verstoß **nicht als Fehler auffällt, sondern als falsches Ergebnis**: ein Deploy, der grün
meldet und nichts ausliefert, eine Datenbank, die niemand verbindet, ein Audit, das etwas
prüft, das du gar nicht gepusht hast.
## 1. Zwei Maschinen, nicht eine
- **Hier (DEV):** ein Debian-LXC. Nutzer `vchuser`, Heimat `/home/vchuser`, Projekte unter
`/home/vchuser/projects/<name>`. `vchuser` hat `sudo` ohne Passwort und ist in der
Docker-Gruppe. Vorhanden: Node 20 (npm; pnpm und yarn sind NICHT installiert — `corepack
enable` holt sie, wenn dein Projekt sie braucht), Python samt venv, Docker, tmux, git,
code-server auf Port 8080, Claude Code als `claude`, OpenCode als `opencode`; Git gegen Gitea
vorkonfiguriert. Das Terminal läuft in tmux — ein Dev-Server im Vordergrund blockiert die
Sitzung, starte ihn in einem eigenen tmux-Fenster oder mit `&`.
- **Dort (PROD):** eine **andere** Maschine mit demselben Werkzeugsatz. VCH klont dein
Repository dorthin und startet es. Der Build läuft dort mit **gelöschtem `NODE_ENV`**, damit
devDependencies installiert werden; ein `NODE_ENV=production` aus `.env.example` gilt erst
zur Laufzeit.
Daraus folgt die wichtigste Regel überhaupt: **VCH deployt und auditiert deinen gepushten
Commit, nie dein Arbeitsverzeichnis.** Nicht committete Änderungen existieren für Deploy,
Audit und Veröffentlichung nicht — auch dann nicht, wenn hier alles läuft.
## 2. Wegweiser — welcher Weg in welcher Lage
Bevor du baust: such deine Lage, nimm den Weg, kenn die Folgen. Die Abschnitte dahinter
erklären das Wie.
**Eine Datenbank, Schlüssel in `.env.example`.** Der Mensch stellt sie im Projektarbeitsplatz
bereit, VCH trägt die Werte in genau deine Schlüssel ein (Abschnitt 8). Folgen: Datenbank und
Benutzer heißen `app`, sofern beim Anlegen nichts anderes gewählt wurde; Migrationen laufen in
deinem `build` oder `start`, VCH führt keine aus; DB-Zeilen im Env-Editor werden gestrichen,
solange die Datenbank läuft; erst der nächste Deploy trägt die Werte ein.
<!-- Beleg: prodDatabase.ts provision (DB_DEFAULT_NAME), prodEnvFiles.ts stripManagedLines -->
**Zwei Datenbanken — oder Redis, Mongo, Elasticsearch.** Compose mit eigenen Diensten
(Abschnitt 13). Folgen: ein Compose mit eigener Datenbank deployt **nicht blau-grün, sondern
in place** — kurze Auszeit beim Umschalten, das Audit läuft als Torprüfung vor dem Start; VCH
sichert diese Datenbank nicht (kein Backup über die Karte); jeder Datenbankdienst braucht ein
Named Volume; den DB-Port **nicht** als festen Host-Port veröffentlichen. An der verwalteten
Datenbank landen zwei deklarierte Verbindungen (`DATABASE_URL` und `ANALYTICS_DATABASE_URL`)
auf **derselben** — deshalb Compose.
<!-- Beleg: deploy.ts skipBlueGreen (composeHasDbService), dbEnvRecognizer.ts recognizeDbEnv -->
**Nur Dateien oder SQLite.** Keine Datenbank; alles, was bleiben soll, nach `VCH_DATA_DIR`
(Abschnitt 9). Folgen: `HISTORY_DB=/pfad.sqlite` ist bewusst keine Verbindung und wird nicht
überschrieben; ein Pfad relativ zum Release ist beim nächsten Deploy weg.
<!-- Beleg: dbEnvRecognizer.ts classify (nameSuffix-Regel) -->
**Eine Datenbank zum Entwickeln, hier auf DEV.** VCH stellt hier keine bereit. Starte selbst
eine — Docker ist da, `vchuser` ist in der Docker-Gruppe:
`docker run -d --name dev-db -p 127.0.0.1:5432:5432 -e POSTGRES_PASSWORD=dev postgres:16`.
Lege eine lokale `.env` an, deklariere die Schlüssel in `.env.example`. Folgen: die Bindung an
`127.0.0.1` ist Pflicht — ohne Adresse vor dem Port lauscht die Datenbank für jeden im LAN; die
`.env` gehört nicht ins Repository; für PROD trägt VCH die verwaltete Datenbank ein.
<!-- Beleg: template-build.ts vchuser (usermod -aG docker, sudoers NOPASSWD) -->
**Ein zweiter Prozess neben der App** (Worker, Cron, Queue). Compose (Abschnitt 13). Folgen:
systemd startet genau **einen** Befehl, den aus `start`; im Compose läuft jeder Dienst mit,
Dienste hinter `profiles:` bleiben aus.
<!-- Beleg: deployCommands.ts renderSystemdUnit (ein ExecStart) -->
**Reines Frontend ohne Server.** Statisch ausliefern, irgendetwas muss auf `$PORT` lauschen
(Abschnitt 6.2, „Statische Seite"). Folgen: relative Pfade und Vite `base: './'` (Abschnitt 3),
sonst bricht die Vorschau hinter dem Pfad-Proxy.
<!-- Beleg: Abschnitt 6.2 dieses Textes; routes/proxy.ts Pfadpräfix -->
**Code in einem Unterordner.** `cd` in `build` und `start` (Abschnitt 6.2, „Monorepo"), die
`.env.example` in den Ordner, der sie liest. Folgen: jede Beispieldatei bestimmt ihr eigenes
`.env`-Ziel (Abschnitt 7).
<!-- Beleg: prodEnvFiles.ts envFilePathForExample -->
**Die Vorschau zeigt nichts, der Port wird aber erkannt.** Dein Server lauscht auf `127.0.0.1`.
Lausche auf `0.0.0.0`: `vite --host`, `next dev -H 0.0.0.0`, `HOST=0.0.0.0`. Folgen: die
Porterkennung sieht auch lokale Lauscher, der Proxy erreicht sie nicht — ein erkannter Port
beweist keine erreichbare Vorschau.
<!-- Beleg: routes/lxc.ts GET /instances/:id/ports (ss -tlnp ohne Bindeadresse), routes/proxy.ts target instance.ip -->
**Ein Geheimnis wird gebraucht** (API-Schlüssel, Token). In `.env.example` **leer** deklarieren
(`API_KEY=`); den Wert hinterlegt der Mensch im Env-Editor (Abschnitt 7). Folgen: leer ohne
Vorgabe blockiert den Deploy, bis der Wert da ist — gewollt; die Prüfung `secrets` findet
eingecheckte Werte (Abschnitt 11).
<!-- Beleg: Abschnitt 7 dieses Textes (blockierende Schlüssel); AUDIT_RUNNER_MANIFEST secrets -->
**Der Deploy schlägt fehl.** Du siehst weder das Deploy-Log noch Logs der PROD-Maschine — lass
dir den Fehlertext aus dem Projektarbeitsplatz geben. Dann prüfen: lauscht die App auf `PORT`,
antwortet der `health`-Pfad innerhalb von 30 Sekunden, liest sie ihre Werte aus der Umgebung?
Folgen: im Deploy-Log stehen die letzten 50 Logzeilen der App — logge nach stdout, nicht in
Dateien.
<!-- Beleg: deployCommands.ts buildJournalTailCmd (-n 50) -->
**Dem Projekt ist eine Integration zugeordnet.** Die Zugangsdaten liegen in
`.vch-integrations.env` im Projektstamm: `<NAME>_API_URL`, `<NAME>_USER`, `<NAME>_PASSWORD`,
`<NAME>_TOKEN`. Folgen: die Datei liegt **nur hier auf DEV**, im Klartext, wird nicht deployt
und ist nicht ignoriert — trag sie in `.gitignore` ein und committe sie nie; für PROD dieselben
Schlüssel in `.env.example` deklarieren, die Werte in den Env-Editor.
<!-- Beleg: routes/integrations.ts buildEnvFile/deployEnvFile; kein Verweis in deploy.ts (gemessen 2026-09-17) -->
## 3. Vorschau im Browser läuft über einen Proxy
Deine Entwicklungs-Server sind nicht direkt erreichbar. VCH erkennt lauschende Ports und
stellt zwei Wege bereit:
1. **Subdomain** (bevorzugt): `https://lxc<VMID>-<PORT>.<domain>/` — deine App liegt auf der
Wurzel, alles verhält sich normal.
2. **Pfad-Proxy** (Rückfallweg): `https://<host>/proxy/<instanz>/p/<port>/` — deine App liegt
hinter einem Pfadpräfix.
Damit beides funktioniert: **relative Pfade verwenden.**
- `css/style.css` ✅ — nicht `/css/style.css` ❌
- `fetch('api/daten')` ✅ — nicht `fetch('/api/daten')` ❌
- Wenn absolute Pfade unvermeidbar sind: `<base href="./">` in den `<head>`.
Rahmenwerke: **Vite** `base: './'` · **Create React App** `"homepage": "."` in der
`package.json` · **Next.js** Vorgabe belassen (Subdomain) oder `basePath` setzen (Pfad-Proxy)
· **Express** `express.static` genügt, solange das HTML relativ verweist.
## 4. Dateien, die VCH im Projektstamm liest
| Datei | Pflicht | Wozu |
|---|---|---|
| `README.md` | ja | Audit-Prüfung `readme` — blockiert die Veröffentlichung |
| `.vch-description` | ja | Audit-Prüfung `description`; der Text steht später im Showcase |
| `.env.example` | wenn die App Umgebungsvariablen liest | die **Deklaration** aller Variablen (Abschnitt 7) |
| `.vch/deploy.yaml` | wenn die Autoerkennung nicht reicht | wie gebaut und gestartet wird (Abschnitt 6) |
| `CLAUDE.md` | — | dein Projektwissen; VCH fasst sie nicht mehr an |
`README.md` muss beschreiben, **was** das Projekt tut, wie man es installiert, wie man es
startet und worauf es aufbaut. Die unveränderte Vorlage besteht die Prüfung nicht.
`.vch-description` ist **nicht technisch**: was das Projekt tut, welches Problem es löst,
für wen. Kein Technologie-Stapel, keine Installationsanleitung — die stehen im README.
Halte beide aktuell, wenn sich Zweck oder Aufbau ändern.
`.vch-description` legt VCH beim Anlegen als **Platzhalter** an, der die Prüfung nicht besteht —
dass die Datei existiert, heißt nicht, dass sie fertig ist.
**VCH-eigen, nicht anfassen:** `.vch/PLATFORM.md`, `~/.vch_env`, `~/.config/vch/` und
`/etc/profile.d/vch-opencode.sh`. Wer „die Shell-Umgebung aufräumt", kappt sich den AI-Server.
## 5. Der Port gehört VCH
VCH weist jedem Projekt auf der PROD-Maschine einen festen Port zu (Bereich `13000–13999`)
und hält ihn über alle Deploys hinweg stabil. Du wählst ihn nicht und stimmst dich mit
niemandem ab.
**Deine App MUSS auf `PORT` aus der Umgebung lauschen:**
- Node/Express: `app.listen(process.env.PORT || 3000)`
- Next.js: `next start` beachtet `PORT` von selbst
- Python: `gunicorn app:app -b 0.0.0.0:$PORT` bzw. `uvicorn main:app --port $PORT`
- Go/andere: den Wert aus der Umgebung lesen, nicht hartkodieren
Ein fest verdrahteter Port läuft hier und scheitert dort am Healthcheck. `PORT` und
`VCH_DATA_DIR` sind **VCH-eigene** Schlüssel: setzt du sie selbst — in `.env.example`, im
Env-Editor oder im Compose — wird dein Wert verworfen.
## 6. `.vch/deploy.yaml` — die vollständige Referenz
Ohne diese Datei versucht VCH, das Projekt selbst zu erkennen (Abschnitt 6.3). Reicht das
nicht, bricht der Deploy ab **und legt dir eine fertige Vorlage vor**, die du nur noch
committen musst.
### 6.1 Alle Felder
| Feld | Typ | Vorgabe | Bedeutung |
|---|---|---|---|
| `runtime` | `auto` \| `systemd` \| `docker` | `auto` | Wie gestartet wird. `auto`: Dockerfile oder Compose vorhanden → `docker`, sonst `systemd`. |
| `build` | Zeichenkette | — | Shell-Befehl im Release-Verzeichnis. **Nur `systemd`** — bei `docker` baut das Dockerfile. |
| `start` | Zeichenkette | — | Startbefehl des Dienstes. **Nur `systemd`.** Muss `$PORT` binden. |
| `port` | Ganzzahl 1–65535 | `3000` | **Nur `docker`:** der Port, auf dem deine App **im Container** lauscht. VCH bildet den zugewiesenen Host-Port darauf ab. |
| `health` | Pfad | `/` | Pfad, der 2xx oder 3xx liefert, sobald die App bereit ist. |
| `env` | Liste von Zeichenketten | `[]` | Zusätzliche Variablennamen, die VCH ausliefern soll. Wird mit den Schlüsseln aus `.env.example` vereinigt. |
| `compose` | Pfad | — | Welche Compose-Datei gilt, wenn mehrere gefunden werden. |
Unbekannte Felder und Tippfehler in `runtime` brechen den Deploy mit einer benannten
Fehlermeldung ab — sie werden nicht still ignoriert.
### 6.2 Beispiele
Node mit eigenem Build (systemd):
```yaml
runtime: systemd
build: "npm ci && npm run build"
start: "npm start"
health: /
```
Python (systemd) — beachte `$PORT` im Startbefehl:
```yaml
runtime: systemd
build: "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt"
start: ".venv/bin/gunicorn app:app -b 0.0.0.0:$PORT"
health: /
```
Statische Seite (systemd) — irgendetwas muss lauschen:
```yaml
runtime: systemd
build: "npm ci && npm run build"
start: "python3 -m http.server $PORT --directory dist"
```
Monorepo, Code in einem Unterordner:
```yaml
runtime: systemd
build: "cd backend && npm ci && npm run build"
start: "cd backend && npm start"
```
Dockerfile:
```yaml
runtime: docker
port: 3000 # der Port INNERHALB des Containers
health: /
```
Mehrere Compose-Dateien im Repository:
```yaml
runtime: docker
compose: docker/compose.yaml
```
### 6.3 Was ohne die Datei passiert
In dieser Reihenfolge:
1. Ein echtes `Dockerfile` gewinnt → `runtime: docker` mit dem Image.
2. Eine Compose-Datei, die `${VCH_PORT}` veröffentlicht → Compose-Deploy.
3. Eine Compose-Datei, die **nur Infrastruktur** startet (Postgres, Redis, … und keinen
App-Dienst), wird als Entwicklungs-Beiwerk erkannt und **nicht** deployt — VCH sucht
weiter nach der Sprache.
4. Eine Compose-Datei mit App-Dienst, die `${VCH_PORT}` **nicht** veröffentlicht → Abbruch
mit Vorlage (Abschnitt 13).
5. `package.json` im Stamm: `build`-Skript → Build, `start`-Skript bzw. `main` → Start.
Paketmanager wird am Lockfile erkannt (`pnpm`/`yarn`/`npm`).
6. Alles andere — Python, PHP/Laravel, Go, reines Frontend ohne `start`, Code nur in
Unterordnern — endet mit einer passenden Vorlage zum Committen.
## 7. Umgebungsvariablen: `.env.example` ist die Deklaration
VCH liest keine Umgebungsvariablen aus deinem Code. **Was in einer Beispieldatei steht,
existiert; alles andere nicht.**
- Erkannte Dateinamen: `.env.example`, `.env.sample`, `.env.template` sowie dieselben ohne
führenden Punkt (`env.example`, …). Der erste Fund je Schlüssel gewinnt.
- Gesucht wird **im ganzen Repository** (ohne `node_modules`, `.git`, `dist`, `build`,
`.next`, `out`, `vendor`, `coverage`, `.turbo`), Stammdateien zuerst, höchstens 25 Dateien.
- Jede gefundene Beispieldatei bestimmt ein Ziel: `backend/.env.example` → VCH schreibt die
Produktionswerte nach `backend/.env`. Für Monorepos ist das der Weg, mehrere `.env` zu
bekommen.
**Was den Deploy blockiert:** ein Schlüssel, der **keinen Wert nennt** (`KEY=`) und für den
noch kein Produktionswert hinterlegt ist. Nicht blockierend sind:
```dotenv
API_URL=https://beispiel.test # Vorgabewert vorhanden
OPTIONAL_TOKEN="" # ausdrücklich leer erlaubt
FEATURE_FLAG= # optional
```
Der Marker muss direkt hinter dem `#` stehen (`# optional`); Fließtext, in dem das Wort
vorkommt, zählt nicht. Die Vorgabewerte sind **Hinweise für den Menschen** — in Produktion
wird nichts davon verwendet.
Im Zweifel:
```bash
grep -RhoE "process\.env\.[A-Z_]+" src/ | sort -u
```
und jeden Treffer in die `.env.example` schreiben.
**Bei Docker:** nur Variablen mit dem Präfix `NEXT_PUBLIC_`, `VITE_` oder `PUBLIC_` gehen als
`--build-arg` in den Build (sie sind ohnehin im Auslieferungsstand sichtbar) — dafür brauchst
du passende `ARG`-Zeilen im Dockerfile. Alle übrigen Werte kommen erst zur Laufzeit über
`--env-file` und landen nie im Image.
## 8. Datenbank
Deklariert deine App Verbindungsvariablen, bietet VCH eine verwaltete Datenbank an und trägt
die Werte **automatisch** in genau die Schlüssel ein, die du deklariert hast. Du musst deine
Variablennamen also nicht ändern — VCH erkennt sie.
**Was du bekommst:** genau **eine** verwaltete Datenbank je Projekt — Postgres 16 oder MySQL 8
als eigener Container auf der PROD-Maschine, Host `127.0.0.1`, Port aus `15000–15999`, nicht
aus dem LAN erreichbar. Datenbank und Benutzer heißen `app`; der Mensch kann beim Anlegen unter
„Erweitert" eigene Werte wählen (Kleinbuchstaben, Ziffern, Unterstrich, Buchstabe vorne;
reservierte Namen und das Präfix `pg_` sind gesperrt) — **nur beim Anlegen**,
danach nicht mehr. Das Passwort wird erzeugt oder gewählt und beim Deploy in deine Schlüssel
eingetragen; deklarierst du keine erkennbaren Schlüssel, zeigt die Karte im Projektarbeitsplatz
die Verbindung samt Passwort zum Kopieren. Auf DEV gibt es diese Datenbank nicht (Wegweiser).
Migrationen macht deine App im `build` oder `start`. Backup: der Abzug liegt im Projekt-Backup,
vor dem Löschen zieht VCH einen Pflicht-Abzug. Solange die Datenbank läuft, werden DB-Zeilen im
Env-Editor gestrichen — dein eigener Wert hält nicht. Deklarierst du **zwei** Verbindungen,
landen beide auf dieser einen Datenbank: dann Compose (Wegweiser).
**Erkannt wird** (Groß-/Kleinschreibung egal):
- Verbindungszeichenketten: `DATABASE_URL`, `DB_URL`, jeder Name auf `_DSN`
- Einzelteile mit Familienwort (`DB`, `DATABASE`, `POSTGRES`, `MYSQL`) plus Rolle:
`..._HOST`, `..._PORT`, `..._USER`/`..._USERNAME`, `..._PASSWORD`/`..._PASS`,
`..._NAME`/`..._DATABASE`/`..._DB`
- Die üblichen nackten Namen: `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`,
`MYSQL_HOST`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE`, `POSTGRES_DB`
**Bewusst nicht erkannt** (und deshalb nie überschrieben):
- alles mit `ROOT` im Namen (`POSTGRES_ROOT_PASSWORD`, `MYSQL_ROOT_PASSWORD`) — das sind die
Zugangsdaten des Datenbankservers, nicht die deiner App
- `SENTRY_DSN` und verwandte Fehlermelder (`GLITCHTIP`, `BUGSNAG`, `ROLLBAR`, `AIRBRAKE`,
`RAYGUN`, `APPSIGNAL`) — ein DSN ist dort keine Datenbank
- `HISTORY_DB=/data/verlauf.sqlite` und Ähnliches: ein `..._DB` ohne Datenbank-Familienwort
im übrigen Namen gilt nicht als Verbindung
Die **Engine** (Postgres oder MySQL) leitet VCH aus deinen Schlüsselnamen und dem Schema in
`DATABASE_URL` ab; ist das mehrdeutig, entscheidest du im Projektarbeitsplatz.
**Bringt dein Compose seine eigene Datenbank mit**, startet VCH sie mit und bietet **keine**
zweite an. Achte dann selbst auf ein dauerhaftes Volume — ein Datenbankdienst ohne Volume
verliert seine Daten beim nächsten Neubau, und VCH warnt nur. Erkannt wird der Dienst **am
Image-Namen**: `postgres`, `postgresql`, `pgvector`, `postgis`, `timescaledb`, `mysql`,
`mariadb`, `percona`/`percona-server`. Ein anderes Image (eigener Build, fremder Name) gilt
nicht als Datenbank — dann bietet VCH eine verwaltete an, und der Deploy läuft blau-grün mit
einem zweiten Stapel.
<!-- Beleg: deployManifest.ts PG_IMAGE_RE/MYSQL_IMAGE_RE, deploy.ts skipBlueGreen -->
Eine Compose-Datei, die **nur** eine Datenbank startet, ist Entwicklungs-Beiwerk: sie läuft in
Produktion nicht mit. Nimm dort die verwaltete Datenbank.
## 9. Dauerhafte Daten: `VCH_DATA_DIR`
Jedes Release liegt in einem eigenen Verzeichnis und verschwindet wieder. Was überleben soll —
SQLite-Datei, Uploads, erzeugte Dateien — gehört in das Verzeichnis aus **`VCH_DATA_DIR`**.
```js
const datenVerzeichnis = process.env.VCH_DATA_DIR || './data';
```
VCH setzt die Variable bei allen Betriebsarten (bei Docker als Einhängepunkt `/data`). **Lies
sie zur Laufzeit**, schreibe den Pfad nirgends fest und trage sie nicht in `.env`-Dateien ein.
Ein Verzeichnis relativ zum Release (`./data`) ist beim nächsten Deploy weg.
Der Prüfling während des Audits (Abschnitt 10) bekommt diesen Speicher **nicht** — er läuft
gegen einen leeren. Das ist Absicht: zwei Prozesse dürfen nicht gleichzeitig auf dieselbe
SQLite-Datei schreiben.
## 10. Wie ein Deploy abläuft
```
Klonen → Bauen → Healthcheck (Prüfling) → Audit → Umschalten → Live
```
1. **Klonen.** VCH klont den Deploy-Branch auf die PROD-Maschine — genau den gepushten Stand.
2. **Torprüfung.** Fehlt ein blockierender Wert aus `.env.example` (Abschnitt 7), endet es hier,
bevor irgendetwas läuft.
3. **Bauen** nach `.vch/deploy.yaml` bzw. Autoerkennung.
4. **Healthcheck.** Der neue Stand startet als **Prüfling** auf einem freien Port aus dem
Bereich `14000–14999`, neben der weiterhin laufenden alten Fassung. VCH ruft dort deinen
`health`-Pfad auf: rund 30 Sekunden lang, alle 1,5 s ein Versuch, Erfolg beim ersten
2xx/3xx. Bis dahin muss die App antworten.
5. **Audit** (Abschnitt 11) gegen den deployten Commit.
6. **Umschalten.** Erst wenn das Audit besteht, wird der Prüfling zur laufenden Fassung auf
dem stabilen Port. **Ein durchgefallenes Audit heißt: der alte Stand bleibt live.**
Vor dem Umschalten legt VCH einen Datenschnappschuss an. Geht das Umschalten schief, werden
erst die Daten und dann der Code zurückgerollt. Frühere Releases lassen sich im
Projektarbeitsplatz per Rollback zurückholen.
**Auto-Deploy:** ist er eingeschaltet, löst jeder Push auf den konfigurierten Deploy-Branch
einen Deploy aus. Pushes auf andere Branches werden ignoriert.
- **Das Ergebnis jedes Deploys liegt in `.vch/deploy-report.md`** (nicht im Git — VCH trägt
die Datei wie den Audit-Bericht in `.git/info/exclude` ein). Er nennt Commit, Stufe,
Fehlermeldung, das Ende des Logs (Geheimnisse geschwärzt) und eine Einschätzung:
„probably fixable in this repository" heißt, die Ursache liegt vermutlich im Code — beheben,
committen, pushen. „platform error" heißt: **nicht** im Code suchen, sondern dem Nutzer sagen,
was dort steht. Nach einem erfolgreichen Deploy steht dort genau das; ein alter Fehlerbericht
bleibt nicht liegen. Nennt der Bericht einen anderen Commit als den, nach dem du gefragt
wirst, ist er noch nicht aktualisiert.
<!-- Beleg: services/deployReportFile.ts -->
## 11. Audit — was geprüft wird
Das Audit läuft nicht hier, sondern in einem eigenen Runner gegen einen **sauberen Checkout
des deployten Commits**. Nicht Gepushtes wird nicht geprüft — und ein `.gitignore`-Eintrag
macht eine Datei für das Audit unsichtbar.
| Prüfung | Schwere | Darf entfallen |
|---|---|---|
| `readme` | Fehler | nein |
| `secrets` | Fehler | nein |
| `description` | Warnung | nein |
| `lint` | Fehler | ja |
| `typescript` | Fehler | ja |
| `npm-audit` | Fehler | ja |
- Eine **Warnung** blockiert nie. `description` hält dich also nicht auf, kostet dich aber den
Platz im Showcase.
- „Darf entfallen" heißt: die Prüfung meldet `skipped`, wenn sie auf dein Projekt nicht passt
(kein TypeScript, kein npm). Eine Prüfung, die **nicht** entfallen darf, aber trotzdem
`skipped` meldet, gilt als **durchgefallen** — sie ist nie gelaufen.
- `secrets` sucht eingecheckte Zugangsdaten. Echte Werte gehören nie ins Repository: in die
`.env.example` nur Beispiele, die echten Werte in den Env-Editor von VCH.
- **Das Ergebnis liegt nach jedem Audit in `.vch/audit-report.md`** (nicht im Git — VCH trägt
die Datei in `.git/info/exclude` ein). Bei einem Fehlschlag: lesen, beheben, committen,
pushen — erst der nächste Deploy auditiert den neuen Commit. Der Kopf nennt Commit und
Zeitpunkt; lief die Instanz beim Audit nicht, ist die Datei älter oder fehlt, dann steht der
aktuelle Bericht im Audit-Fenster von VCH zum Kopieren.
## 12. Veröffentlichen
- **Intern erreichbar** ist dein Projekt, sobald ein Deploy läuft — ohne Audit, ohne weiteren
Schritt, unter `http://<prod-ip>:<port>`.
- **Öffentlich** unter einer Subdomain mit SSL wird es erst mit laufendem Deploy **und**
bestandenem Audit für genau diesen Deploy. Die Subdomain kommt oben drauf; entfernst du sie
wieder, bleibt das Projekt intern erreichbar.
- Im **Showcase** erscheinen veröffentlichte Projekte mit bestandenem Audit und einer
eingecheckten `.vch-description`.
## 13. Compose — die vier Regeln, an denen es sonst scheitert
1. **Der App-Dienst muss seinen Port als `ports: ["${VCH_PORT}:<intern>"]` veröffentlichen.**
VCH setzt `VCH_PORT` und kann so einen Prüfling neben deiner laufenden Fassung starten.
Ohne diese Zeile bricht der Deploy ab.
2. **VCH führt genau EINE Compose-Datei aus — keine `-f`-Ketten.** Gesucht wird
`compose.yaml`/`compose.yml`/`docker-compose.yaml`/`docker-compose.yml` im Stamm **oder in
einem Unterordner**. Eine Override-Datei (`docker-compose.ports.yml`, `*.override.yml`)
wird **ignoriert** — die `${VCH_PORT}`-Zeile gehört in die Hauptdatei. Bei mehreren
Fundstellen: `compose: <pfad>` in die `.vch/deploy.yaml`.
3. **Jeder Dienst, der Produktionswerte braucht, bekommt `env_file: [.env]`.** VCH schreibt
deine Werte in eine `.env` neben der Compose-Datei.
4. **Der ganze Stapel startet.** Datenbanken, Caches und Ähnliches aus deinem Compose laufen
automatisch mit — eine zusätzliche verwaltete Datenbank brauchst du dafür nicht. Dienste
hinter einem `profiles:`-Schlüssel bleiben aus.
+3
View File
@@ -0,0 +1,3 @@
runtime: docker
port: 3000
health: /healthz
+25 -78
View File
@@ -1,85 +1,32 @@
# Project
@.vch/PLATFORM.md
## Environment
<!--
Die Regeln dieser Umgebung — Deployment, Datenbank, Audit, Veröffentlichen — stehen in
.vch/PLATFORM.md. Diese Datei wird von VCH gepflegt und überschrieben; lies sie, bevor du
deployst. Alles ab hier gehört dem Projekt: VCH fasst diese CLAUDE.md nie wieder an.
-->
You are working inside a VCH cloud development environment.
# FuerDieCuts
- **OS:** Debian-based LXC container
- **Editor:** code-server (VS Code in browser) on port 8080
- **CLI:** Claude Code is available as `claude` in terminal
- **Git:** Pre-configured. Gitea integration available through VCH.
- **Preview:** Start a dev server on any port — VCH detects listening ports automatically and provides preview URLs with SSL.
- **Publishing:** Assign a public subdomain to any port via the VCH Publish page (automatic SSL).
- **User:** `vchuser` with home at `/home/vchuser`
Wofür dieses Projekt gut ist, und was jemand wissen muss, der hier weiterarbeitet.
## Preview Access (IMPORTANT)
## Befehle
Apps are accessed through a **reverse proxy**, not directly. There are two modes:
- **Abhängigkeiten installieren:** `npm install`
- **Entwicklungsserver:** `npm run dev`
- **Tests:** `npm test`
- **Bauen:** `npm run build`
- **Linten:** `npm run lint`
1. **Subdomain proxy** (preferred): `https://lxc{VMID}-{PORT}.dev.example.com/` — your app is at the domain root, everything works normally.
2. **Path-based proxy** (fallback): `https://host/proxy/{INSTANCE}/p/{PORT}/` — your app is behind a path prefix.
## Konventionen
### Rules for portable apps that work in both modes:
- **ALWAYS use relative paths** for assets, API calls, and links:
- `css/style.css` ✅ — NOT `/css/style.css` ❌
- `api/users` ✅ — NOT `/api/users` ❌
- `fetch('api/data')` ✅ — NOT `fetch('/api/data')` ❌
- **Static file serving:** Configure your server to serve from the request path, not the filesystem root.
- **`<base>` tag:** If you must use absolute paths, add `<base href="./">` in `<head>`.
### Framework-specific configuration:
- **Express:** `app.use(express.static('public'))` works — just ensure HTML references are relative.
- **Vite:** Set `base: './'` in `vite.config.ts`.
- **Next.js:** Set `basePath` in `next.config.js` if using path-based proxy, or leave default for subdomain proxy.
- **Create React App:** Set `"homepage": "."` in `package.json`.
## Development Conventions
- Write clear, descriptive commit messages (imperative mood: "Add feature", not "Added feature")
- Prefer small, focused commits over large ones
- Run linters and formatters before committing
- Write tests for critical business logic
- Keep dependencies minimal — use native browser/Node APIs where possible
## README Requirement (MANDATORY)
Every project MUST have a meaningful `README.md` in the project root. This is enforced by the VCH audit system — projects without a proper README cannot be published.
Your README must include at minimum:
- **Project description** — what the project does (not just the template placeholder)
- **Installation instructions** — how to install dependencies
- **Development instructions** — how to start the dev server
- **Tech stack** — what technologies are used
Update the README whenever you add features, change setup steps, or modify the tech stack. The audit will reject READMEs that are still just the default template.
## Project Description (MANDATORY)
Every project has a `.vch-description` file in the project root. Keep this file up to date with a clear, non-technical description of your project:
- What the project does
- What problem it solves or what it's used for
- Who it's for
Do NOT include technical details (tech stack, dependencies, setup instructions) — those belong in the README. The `.vch-description` content is shown in the VCH Showcase and is synced automatically when an audit runs.
Update `.vch-description` whenever the project's purpose or scope changes significantly.
## Web Best Practices
- Use semantic HTML elements (`nav`, `main`, `article`, `section`, etc.)
- Mobile-first responsive design
- Follow accessibility guidelines (ARIA labels, keyboard navigation, color contrast)
- Optimize images and assets for performance
- Use environment variables for configuration — never hardcode secrets
## Commands
Fill in project-specific commands below:
- **Install dependencies:** `npm install`
- **Start dev server:** `npm run dev`
- **Run tests:** `npm test`
- **Build for production:** `npm run build`
- **Lint:** `npm run lint`
- Aussagekräftige Commit-Nachrichten im Imperativ („Suche ergänzen", nicht „Suche ergänzt")
- Lieber kleine, abgeschlossene Commits als große
- Vor dem Commit linten und formatieren
- Tests für die tragende Fachlogik
- Wenig Abhängigkeiten — was Browser und Node schon können, reicht meistens
- Semantisches HTML, mobil zuerst, bedienbar per Tastatur, ausreichender Kontrast
- Konfiguration über Umgebungsvariablen; keine Zugangsdaten im Quelltext
- Gestaltung nach dem tradeo Design-System in `docs/tradeo-design-system.md`: keine
eigenen Farben, Schriften oder Icons erfinden. Was dort noch offen ist, steht als
vorläufiger Wert im Block „Offene Designentscheidungen“ in `src/styles.css`.
+16
View File
@@ -0,0 +1,16 @@
FROM node:20-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
# APP_PORT hält den Server im Container fest auf dem Port aus .vch/deploy.yaml.
ENV NODE_ENV=production APP_PORT=3000
EXPOSE 3000
CMD ["node", "server/index.js"]
+80 -22
View File
@@ -1,38 +1,96 @@
# Reelkit
# FuerDieCuts
Desktop UI for assembling weekly "Best-of" video reels from WhatsApp clips. Shows the Clip-Auswahl (clip selection) screen: browse transcribed clips, review AI recommendations, pick what goes into the reel, and reorder via drag-and-drop.
Schneidet aus einem WhatsApp-Chatexport automatisch ein YouTube-Video.
## Installation
Der Export (ZIP mit Medien) wird hochgeladen, die Videos werden anhand der
Zeitstempel im Chat chronologisch sortiert. Über eine Maske wählt man, was
automatisch passieren soll:
- Intro und Outro einfügen (eigene Dateien)
- Clips chronologisch aneinanderreihen
- Clips über 10 Sekunden auf genau 10 Sekunden beschleunigen
- Ton der Clips stummschalten (Intro und Outro ausgenommen)
- Backing Track unter den Clip-Teil legen (wird wiederholt und passend gekürzt)
- Voice-over pro Clip aufnehmen oder hochladen
- Lautstärke jeder Tonspur von 0 bis 100
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.
## Voraussetzungen
- Node.js 20
- ffmpeg und ffprobe im `PATH` (`sudo apt install ffmpeg`)
## Installation und Start
```bash
npm install
```
## Development
```bash
cp .env.example .env # APP_PIN eintragen
npm run dev
```
## Build for production
`npm run dev` startet den Express-Server samt Vite im Entwicklungsmodus auf
einem Port (`PORT`, Vorgabe 3000) und liest dabei die lokale `.env`. In Produktion:
```bash
npm run build
NODE_ENV=production APP_PIN=… npm start
```
## Tech Stack
## Umgebungsvariablen
- React 18
- Vite 6
- Plain CSS with CSS custom properties (oklch color space)
- Inter Tight + JetBrains Mono (Google Fonts)
| Name | Bedeutung |
|---|---|
| `APP_PIN` | PIN für die Anmeldung, Pflicht |
| `SESSION_SECRET` | optional; signiert die Sitzungs-Cookies, sonst wird eines erzeugt |
| `PORT` | Port des Servers, Vorgabe 3000 |
| `VCH_DATA_DIR` | Ablage für Projekte und Uploads, Vorgabe `./data` |
## Project Structure
## Ablauf
```
src/
main.jsx — React entry point
App.jsx — Main app: clip list, sidebar, sticky footer
TweaksPanel.jsx — Floating tweaks panel (theme, density, export settings, …)
styles.css — All styles via CSS custom properties
```
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.
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
- `npm test` – Tests (Node-Testrunner)
- `npm run lint` – ESLint
- `npm run build` – Frontend bauen
## 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
- `test/` – Tests
- `Dockerfile` – Produktionsimage mit ffmpeg; Deploy über VCH (`.vch/deploy.yaml`)
Aufgebaut auf Node.js, Express, React, Vite und ffmpeg.
+734
View File
@@ -0,0 +1,734 @@
# tradeo Corporate Design System
**Version:** 0.1 — Arbeitsstand
**Zweck:** Einheitliche visuelle Referenz für Mitarbeitende, Designer, Entwickler und KI/LLM-Systeme
**Geltungsbereich:** Primär interne Kommunikation; externe Nutzung nur vereinzelt
---
## 0. Grundsatz
Dieses Dokument ist kein Inspirationskatalog, sondern ein verbindliches **Design-System**.
Neue Gestaltungen werden nicht frei erfunden. Sie werden ausschließlich aus den hier definierten bzw. aus bestehenden tradeo-Komponenten zusammengesetzt.
**Leitsatz:**
> Bestehende Komponenten kombinieren — keine neue visuelle Sprache erfinden.
Wenn eine benötigte Komponente noch nicht definiert ist, wird sie zunächst in das Design-System aufgenommen und erst danach produktiv verwendet.
---
# 1. Markencharakter
tradeo soll visuell wirken als:
- technisch
- reduziert
- hochwertig
- freundlich
- minimalistisch
- modern
- professionell
- ruhig
- funktional
## 1.1 Gestaltungsprinzipien
### 1. Klarheit vor Dekoration
Jedes Element braucht einen funktionalen Grund. Dekoration ohne Informations- oder Orientierungsfunktion wird vermieden.
### 2. Funktion vor Spielerei
Lesbarkeit, Orientierung und Verständlichkeit haben Vorrang vor visueller Originalität.
### 3. Ruhe statt visueller Lautstärke
Wenige Farben, klare Hierarchien, großzügige Abstände und kontrollierte Kontraste.
### 4. Professionell, aber nicht kühl
Die Gestaltung soll technisch und präzise sein, gleichzeitig aber zugänglich und freundlich wirken.
### 5. Einheitlichkeit vor Einzelkreativität
Ein neues Medium soll eindeutig als Teil von tradeo erkennbar sein, auch wenn kein Logo sichtbar ist.
---
# 2. Logo
## 2.1 Primärlogo
Das bestehende tradeo-Logo bleibt unverändert.
**Erlaubt:**
- Originaldarstellung
- weiße Wortmarke auf tradeo-Blau
- vorhandene, bereits eingesetzte Logo-Varianten
**Nicht erlaubt:**
- Verzerren
- Strecken
- Stauchen
- Drehen
- Umfärben außerhalb bestehender Varianten
- Schatten hinzufügen
- Konturen hinzufügen
- Buchstaben einzeln verändern
- neue Logo-Lockups erfinden
## 2.2 Schutzraum
Um das Logo ist immer ausreichend freie Fläche vorzusehen.
Bis ein exakter Schutzraum anhand der Original-Logodatei definiert ist, gilt:
> Keine Texte, Linien, Icons oder Rahmen unmittelbar an das Logo setzen.
---
# 3. Farben
## 3.1 Bestätigte Primärfarbe
### tradeo Blue
- **HEX:** `#122E45`
- **RGB:** `18 / 46 / 69`
**Verwendung:**
- zentrale Markenflächen
- Header
- Titelbereiche
- primäre Buttons
- Navigationsflächen
- zentrale Beschilderungselemente
- Hintergründe mit weißem Text
tradeo Blue ist die visuell dominierende Markenfarbe.
## 3.2 Weiß
- **HEX:** `#FFFFFF`
**Verwendung:**
- Haupt-Hintergrund
- Text auf tradeo Blue
- ruhige Negativflächen
- Karten und Inhaltsflächen
## 3.3 Weitere Farben
Weitere Farben und Gradients werden **nicht frei neu definiert**.
Es dürfen ausschließlich Farbtöne und Verläufe verwendet werden, die bereits in bestehenden tradeo-Medien — insbesondere auf der Website — vorkommen und anschließend als verbindliche Tokens in dieses Dokument übernommen wurden.
### Status: noch zu erfassen
- sekundäre Blautöne
- Grautöne
- Website-Hintergrundfarben
- vorhandene Gradients
- bestehende Akzentfarben
- funktionale Statusfarben
---
# 4. Typografie
## 4.1 Primärschrift
### Poppins
Poppins ist die primäre tradeo-Schrift.
**Bevorzugte Verwendung:**
- Hauptüberschriften
- Bereichsbezeichnungen
- Schilder
- kurze Labels
- Titel
- Key Messages
- Navigation
- prägnante Kennzeichnungen
### Empfohlene Schnitte
- **SemiBold / 600:** Hauptüberschriften
- **Medium / 500:** Unterüberschriften und Labels
- **Regular / 400:** kurze, ruhige Texte wenn Poppins eingesetzt wird
---
## 4.2 Sekundärschrift
### Inter — vorgeschlagene Ergänzung
Inter ergänzt Poppins als neutrale, professionelle Funktionsschrift.
**Bevorzugte Verwendung:**
- Fließtext
- Tabellen
- längere Beschreibungen
- UI-Texte
- Formulare
- Daten
- Präsentationsinhalte
- technische Informationen
### Empfohlene Schnitte
- **Regular / 400:** Standardtext
- **Medium / 500:** hervorgehobener Text
- **SemiBold / 600:** Labels und wichtige UI-Informationen
> Hinweis: Inter ist aktuell ein Vorschlag und sollte einmal intern freigegeben werden, bevor sie als verbindliche Zweitschrift gilt.
---
# 5. Typografische Hierarchie
Die folgenden Rollen sind verbindlich. Die konkrete Größe wird je nach Medium skaliert.
## Display / Hero
- Schrift: Poppins
- Gewicht: 600
- Einsatz: große zentrale Aussage
- maximal wenige Wörter
- hoher Weißraumanteil
## H1 / Seitentitel
- Schrift: Poppins
- Gewicht: 600
- Einsatz: Hauptüberschrift eines Mediums
## H2 / Abschnitt
- Schrift: Poppins
- Gewicht: 500–600
- Einsatz: Untergliederung
## Label
- Schrift: Poppins oder Inter
- Gewicht: 500–600
- Einsatz: kurze funktionale Bezeichnungen
## Body
- Schrift: Inter
- Gewicht: 400
- Einsatz: Fließtext
## Data / Table
- Schrift: Inter
- Gewicht: 400–500
- Einsatz: Tabellen, Kennzahlen, Statusinformationen
## Caption
- Schrift: Inter
- Gewicht: 400
- Einsatz: Zusatzinformationen
---
# 6. Schreibweise
## Standard
- bevorzugt **Sentence case**
- kurze Überschriften
- klare Substantive und Verben
- keine werbliche Übertreibung
- keine unnötig lockere Jugendsprache
## Vermeiden
- ÜBERMÄSSIGE GROSSSCHREIBUNG
- mehrere Ausrufezeichen
- verspielte Sonderzeichen
- unnötige Emojis
- pseudo-technische Buzzwords
- lange Textblöcke auf Schildern
---
# 7. Layout-System
## 7.1 Grundprinzip
Layouts folgen einer klaren visuellen Hierarchie:
1. Orientierung
2. Hauptinformation
3. unterstützende Information
4. Details
Nicht alle vier Ebenen müssen verwendet werden.
## 7.2 Ausrichtung
**Standard:** linksbündig
Zentrierung nur wenn:
- eine einzelne Aussage im Mittelpunkt steht
- keine mehrstufige Informationshierarchie vorhanden ist
- die Komposition dadurch nachweislich ruhiger wird
## 7.3 Weißraum
Weißraum ist ein aktives Gestaltungsmittel.
Elemente sollen nicht deshalb vergrößert oder ergänzt werden, nur weil Platz verfügbar ist.
## 7.4 Informationsdichte
Bevorzugt:
- wenige Elemente
- klare Gruppen
- erkennbare Abstände
- deutliche Hierarchie
Vermeiden:
- viele gleichgewichtete Elemente
- flächendeckende Boxen
- mehrere konkurrierende Überschriften
- unnötige Rahmen
---
# 8. Spacing-System für digitale Medien
Für UIs und digitale Layouts wird ein **8-Pixel-Grundraster** empfohlen.
Typische Abstände:
- `4 px` — Mikroabstand
- `8 px` — eng zusammengehörige Elemente
- `16 px` — Standardabstand
- `24 px` — Gruppenabstand
- `32 px` — Abschnitt
- `48 px` — großer Abschnitt
- `64 px+` — große Layouttrennung
Abweichungen sind nur sinnvoll, wenn sie aus einem bestehenden tradeo-Layout übernommen werden.
---
# 9. Formen
## Grundcharakter
- geometrisch
- sauber
- weich genug, um freundlich zu wirken
- nicht verspielt
## Vermeiden
- dekorative Freiformen
- Comic-Elemente
- übermäßige Rundungen
- starke 3D-Effekte
- visuell aggressive Rahmen
Für digitale Interfaces sollen Radien konsistent verwendet werden.
**Exakte Radius-Tokens:** noch anhand bestehender tradeo-UIs / Website festzulegen.
---
# 10. Gradients
Gradients dürfen verwendet werden, wenn sie aus bestehenden tradeo-Medien übernommen sind.
## Regeln
- subtil
- ruhig
- innerhalb der bestehenden Farbwelt
- keine Regenbogenverläufe
- keine Neonwirkung
- kein dominantes „Gaming“- oder „Web3“-Erscheinungsbild
**Exakte Gradient-Tokens:** noch aus der Website zu extrahieren.
---
# 11. Icons
Icons sind funktionale Orientierungselemente.
## Bevorzugt
- einheitliche Icon-Familie
- reduzierte Formen
- klare Silhouetten
- konsistente Strichstärken
- technisch-neutral
## Nicht erlaubt
- Mischung verschiedener Icon-Stile
- Emoji als regulärer Icon-Ersatz
- Cartoon-Icons
- handgezeichnete Stilwechsel
- dekorative Icons ohne Funktion
**Verbindliche Icon-Familie:** noch festzulegen.
---
# 12. Fotografie und Bildsprache
Wenn Fotos verwendet werden, sollen sie:
- authentisch
- technisch
- sauber
- ruhig
- hochwertig
- dokumentarisch
- realistisch
wirken.
Bevorzugte Motive:
- Mitarbeiter bei realer Arbeit
- Hardware
- Prozesse
- Logistik
- Technik
- Arbeitsplatzsituationen
- Details von Materialien und Produkten
Vermeiden:
- generische Stock-Fotografie
- überinszenierte Business-Szenen
- künstlich überschwängliche Gestik
- stark verspielte Inszenierung
- extreme Filter
---
# 13. Beschilderungen
Beschilderungen haben besonders hohe Anforderungen an Verständlichkeit.
## Prioritäten
1. Erkennbarkeit
2. Lesbarkeit
3. Orientierung
4. CI-Konsistenz
5. Gestaltung
## Grundaufbau
**Ebene 1 — Hauptinformation**
z. B. `Wareneingang`
**Ebene 2 — Funktion / Präzisierung**
z. B. `Anlieferung & Prüfung`
**Ebene 3 — optionale Zusatzinformation**
z. B. Öffnungszeiten oder Prozesshinweis
**Ebene 4 — optionale Kennzeichnung**
Icon, Nummer, Richtungspfeil
## Regeln
- möglichst nur eine zentrale Aussage
- Poppins für Hauptinformation
- hoher Kontrast
- keine langen Texte
- Symbole nur mit funktionalem Nutzen
- keine unnötigen Zierrahmen
- Pfeile und Orientierungselemente immer konsistent
---
# 14. Präsentationen
## Grundprinzip
Eine Folie = eine Kernaussage.
## Gestaltung
- Poppins für Titel
- Inter für Inhalte
- großzügiger Weißraum
- wenig Text
- visuelle Hierarchie statt Textmenge
- Diagramme reduziert
- tradeo Blue als primäre Markenfarbe
## Nicht verwenden
- dekorative PowerPoint-Effekte
- WordArt
- übermäßige Animationen
- 3D-Diagramme
- bunte SmartArt-Stile
- Clipart
---
# 15. Thumbnails und Banner
## Ziel
Schnell erfassbare zentrale Aussage.
## Aufbau
- starke Headline
- maximal wenige unterstützende Elemente
- klare Bild-/Text-Hierarchie
- tradeo-Farbwelt
- Poppins dominant
## Nicht verwenden
- Meme-Ästhetik
- Clickbait-Stil
- schrille Farben
- zu viele Textblöcke
- mehrere konkurrierende Blickfänge
---
# 16. Interne Anwendungen und UIs
tradeo-UIs sollen wie ein Teil derselben visuellen Organisation wirken.
## Typografie
- Poppins: Page Title / Module Title
- Inter: Navigation, Tabellen, Formulare, Controls, Fließtext
## Grundregeln
- klare Navigation
- wenige visuelle Ebenen
- großzügige Abstände
- einfache Statusdarstellung
- konsistente Buttons
- konsistente Karten
- klare Interaktionszustände
## Primäre Aktion
Primäre Aktionen werden bevorzugt in tradeo Blue dargestellt.
## Sekundäre Aktion
Zurückhaltender als die primäre Aktion.
## Destruktive Aktion
Nur funktional markieren; niemals als dekorative Akzentfarbe verwenden.
---
# 17. Tabellen und Daten
Daten sollen funktional und scanbar dargestellt werden.
## Regeln
- Inter verwenden
- klare Spalten
- Zahlen sauber ausrichten
- dezente Trennlinien
- geringe visuelle Dichte
- Hervorhebung nur bei inhaltlicher Relevanz
Vermeiden:
- abwechselnd stark gefärbte Tabellenzeilen
- dicke Rahmen
- mehrere Akzentfarben
- dekorative Tabellenköpfe
---
# 18. Do / Don't
## DO
- bestehende Komponenten wiederverwenden
- wenige Farben einsetzen
- klar hierarchisieren
- großzügigen Weißraum lassen
- Poppins bewusst einsetzen
- technische Präzision zeigen
- Inhalte schnell erfassbar machen
- Vorlagen verwenden
## DON'T
- neue Farben spontan erfinden
- neue Schriftarten einführen
- verspielte Gestaltung verwenden
- unnötige Icons ergänzen
- Elemente stark dekorieren
- mehrere Designsprachen mischen
- „jung“, „hip“ oder „quirky“ gestalten
- visuelle Trends ohne Bezug zu tradeo übernehmen
---
# 19. Entscheidungsregel für neue Medien
Vor Freigabe eines neuen Mediums müssen folgende Fragen mit **Ja** beantwortet werden:
- Verwendet es ausschließlich bestehende tradeo-Komponenten?
- Ist die Hauptinformation sofort erkennbar?
- Ist die visuelle Hierarchie eindeutig?
- Ist die Anzahl der Farben minimal?
- Ist die Typografie regelkonform?
- Sind Abstände und Ausrichtungen konsistent?
- Würde das Medium auch ohne Logo wie tradeo wirken?
- Wirkt es technisch, reduziert, hochwertig, freundlich, minimalistisch und modern?
Wenn eine Antwort **Nein** lautet, muss die Gestaltung überarbeitet werden.
---
# 20. Regel für KI / LLM-Systeme
## Systemanweisung
Bei der Erstellung visueller tradeo-Medien gilt:
> Verwende ausschließlich die in diesem Corporate Design System beschriebenen bzw. als bestehend dokumentierten tradeo-Komponenten. Erfinde keine zusätzlichen Farben, Schriftarten, Gradients, Icons, Formen oder Stilrichtungen. Wenn eine benötigte Komponente nicht definiert ist, kennzeichne dies als offene Designentscheidung statt selbstständig eine neue Lösung einzuführen.
## Prioritäten für KI-generierte Gestaltung
1. CI-Konformität
2. funktionale Verständlichkeit
3. visuelle Hierarchie
4. Lesbarkeit
5. Konsistenz
6. Ästhetik
Ästhetische Originalität steht nicht über Konsistenz.
---
# 21. KI-Kurzprofil
```yaml
brand:
name: tradeo
character:
- technical
- reduced
- premium
- friendly
- minimalist
- modern
- professional
principles:
- clarity_over_decoration
- function_over_playfulness
- calm_over_visual_noise
- consistency_over_individual_creativity
- reuse_existing_components_only
colors:
primary:
name: tradeo_blue
hex: "#122E45"
rgb: [18, 46, 69]
white:
hex: "#FFFFFF"
rule: "Do not invent new brand colors. Use only documented existing tradeo colors."
typography:
primary:
family: "Poppins"
roles:
- headlines
- labels
- signage
- key_messages
secondary:
family: "Inter"
status: "proposed"
roles:
- body
- ui
- tables
- data
- long_form_text
layout:
default_alignment: left
whitespace: generous
visual_density: low
digital_spacing_base_px: 8
style:
avoid:
- playful
- colorful
- childish
- unserious
- cartoon
- decorative_without_function
- excessive_shadows
- excessive_3d
- mixed_visual_languages
generation_rule:
"Compose from existing components. Do not invent a new visual language."
```
---
# 22. Medien-Matrix
| Medium | Primärschrift | Sekundärschrift | Hauptziel |
|---|---|---|---|
| Beschilderung | Poppins | Inter optional | Orientierung |
| Überschrift / Schriftzug | Poppins | — | Identifikation |
| Präsentation | Poppins | Inter | Kommunikation |
| Layout / Dokument | Poppins | Inter | Struktur |
| Thumbnail | Poppins | Inter optional | schnelle Erfassung |
| App / UI | Poppins | Inter | Bedienbarkeit |
| Banner | Poppins | Inter optional | zentrale Aussage |
| Tabelle / Dashboard | Poppins für Titel | Inter | Datenerfassung |
---
# 23. Noch zu definierende Design-Tokens
Diese Punkte werden anhand bestehender tradeo-Materialien exakt ergänzt:
- [ ] vollständige Farbpalette
- [ ] Website-Gradients
- [ ] Neutralfarben
- [ ] exakte Typografiegrößen pro Medium
- [ ] Zeilenhöhen
- [ ] Letter Spacing
- [ ] Corner Radii
- [ ] Border Styles
- [ ] Shadow Styles
- [ ] Icon-Familie
- [ ] Pfeil-System
- [ ] Logo-Schutzraum
- [ ] Mindestgröße Logo
- [ ] Print-Farbwerte
- [ ] RAL-Farben für physische Beschilderung
- [ ] Standardformate für Schilder
- [ ] Präsentationsmaster
- [ ] UI-Komponenten
- [ ] Thumbnail-Templates
- [ ] Banner-Templates
---
# 24. Geplante Template-Bibliothek
Langfristig sollte dieses Design-System durch konkrete Referenz-Templates ergänzt werden:
1. Türschild
2. Bereichsschild
3. Regal-/Lagerplatzkennzeichnung
4. Maschinen-/Arbeitsplatzkennzeichnung
5. Lean-/Shopfloor-Board
6. Prozesshinweis
7. SOP-Titelblatt
8. Präsentationsfolie
9. Thumbnail
10. Banner
11. Dashboard
12. interne Web-App
13. Formulare
14. Tabellen
15. Informationskarte
---
# 25. Versionierung
Änderungen an Farben, Typografie oder zentralen Design-Tokens werden dokumentiert.
Empfohlenes Schema:
- `0.x` — Aufbau / Entwicklung
- `1.0` — erste freigegebene CI
- `1.x` — Ergänzungen ohne grundlegenden Stilwechsel
- `2.0` — grundlegende visuelle Änderung
**Aktueller Stand:** `0.1`
+27
View File
@@ -0,0 +1,27 @@
import js from '@eslint/js';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import globals from 'globals';
export default [
{ ignores: ['dist/', 'data/', 'node_modules/'] },
js.configs.recommended,
{
files: ['server/**/*.js', 'test/**/*.js', '*.config.js'],
languageOptions: { globals: globals.node },
},
{
files: ['src/**/*.{js,jsx}'],
languageOptions: {
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
plugins: { react, 'react-hooks': reactHooks },
settings: { react: { version: 'detect' } },
rules: {
'react/jsx-uses-vars': 'error',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
},
},
];
+2 -2
View File
@@ -3,10 +3,10 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reelkit — Clip-Auswahl · KW 17</title>
<title>FuerDieCuts</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@500;600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
+3823 -119
View File
File diff suppressed because it is too large. Load diff
+11 -4
View File
@@ -1,20 +1,27 @@
{
"name": "reelkit",
"name": "fuerdiecuts",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"dev": "node server/index.js",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint src"
"lint": "eslint .",
"start": "node server/index.js",
"test": "node --test"
},
"dependencies": {
"express": "^5.2.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.39.5",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.12.0",
"vite": "^6.3.3"
}
}
+155
View File
@@ -0,0 +1,155 @@
import express from 'express';
import {
SESSION_COOKIE,
SESSION_MAX_AGE_MS,
createLoginLimiter,
createToken,
pinMatches,
readCookie,
verifyToken,
} from './auth.js';
import { HttpError, badRequest } from './http-error.js';
export function createApp({ config, secret, store, queue }) {
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', true);
const limiter = createLoginLimiter();
const isAuthenticated = (req) =>
verifyToken(secret, config.pin, readCookie(req, SESSION_COOKIE));
app.get('/healthz', (req, res) => res.json({ ok: true }));
app.use('/api', express.json({ limit: '100kb' }));
app.get('/api/session', (req, res) => {
res.json({ authenticated: isAuthenticated(req), pinConfigured: Boolean(config.pin) });
});
app.post('/api/login', (req, res) => {
if (limiter.isBlocked()) {
return res.status(429).json({ error: 'Zu viele Fehlversuche. Bitte später erneut versuchen.' });
}
if (!pinMatches(config.pin, req.body?.pin)) {
limiter.recordFailure();
return res.status(401).json({ error: 'PIN falsch.' });
}
limiter.reset();
res.cookie(SESSION_COOKIE, createToken(secret, config.pin), {
httpOnly: true,
sameSite: 'lax',
secure: req.secure,
maxAge: SESSION_MAX_AGE_MS,
path: '/',
});
res.json({ authenticated: true });
});
app.post('/api/logout', (req, res) => {
res.clearCookie(SESSION_COOKIE, { path: '/' });
res.json({ authenticated: false });
});
app.use('/api', (req, res, next) => {
if (!isAuthenticated(req)) return res.status(401).json({ error: 'Nicht angemeldet.' });
next();
});
const route = (handler) => (req, res, next) => Promise.resolve(handler(req, res)).catch(next);
const present = (project) => ({ ...project, render: queue.describe(project) });
app.get('/api/projects', route(async (req, res) => {
res.json(await store.list());
}));
app.post('/api/projects', route(async (req, res) => {
res.status(201).json(present(await store.create(req.body ?? {})));
}));
app.get('/api/projects/:id', route(async (req, res) => {
res.json(present(await store.get(req.params.id)));
}));
app.delete('/api/projects/:id', route(async (req, res) => {
if (queue.isRunning(req.params.id)) queue.cancel(req.params.id);
await store.remove(req.params.id);
res.status(204).end();
}));
const readOffset = (req) => {
const offset = Number(req.query.offset);
if (!Number.isSafeInteger(offset) || offset < 0) throw badRequest('Ungültiger Offset.');
return offset;
};
app.patch('/api/projects/:id/settings', route(async (req, res) => {
res.json(present(await store.updateSettings(req.params.id, req.body)));
}));
app.put('/api/projects/:id/clips/:index', route(async (req, res) => {
const target = { type: 'clip', index: req.params.index };
res.json(await store.appendChunk(req.params.id, target, readOffset(req), req));
}));
app.get('/api/projects/:id/clips/:index/file', route(async (req, res) => {
const project = await store.get(req.params.id);
res.sendFile(store.readyPath(project, { type: 'clip', index: req.params.index }));
}));
app.post('/api/projects/:id/assets/:slot', route(async (req, res) => {
res.status(201).json(present(await store.createAsset(req.params.id, req.params.slot, req.body)));
}));
app.put('/api/projects/:id/assets/:slot', route(async (req, res) => {
const target = { type: 'asset', slot: req.params.slot };
res.json(await store.appendChunk(req.params.id, target, readOffset(req), req));
}));
app.delete('/api/projects/:id/assets/:slot', route(async (req, res) => {
res.json(present(await store.removeAsset(req.params.id, req.params.slot)));
}));
app.get('/api/projects/:id/assets/:slot/file', route(async (req, res) => {
const project = await store.get(req.params.id);
res.sendFile(store.readyPath(project, { type: 'asset', slot: req.params.slot }));
}));
app.post('/api/projects/:id/render', route(async (req, res) => {
await queue.start(req.params.id);
res.status(202).json(present(await store.get(req.params.id)));
}));
app.delete('/api/projects/:id/render', route(async (req, res) => {
queue.cancel(req.params.id);
res.status(202).json(present(await store.get(req.params.id)));
}));
// ?download=1 speichert die Datei, sonst Wiedergabe im Browser
app.get('/api/projects/:id/output', route(async (req, res) => {
const project = await store.get(req.params.id);
const file = await queue.outputPath(project);
if (req.query.download === undefined) return res.sendFile(file);
const { from, to } = project.range;
res.download(file, from && to ? `FuerDieCuts_${from}_bis_${to}.mp4` : 'FuerDieCuts.mp4');
}));
app.use('/api', (req, res) => res.status(404).json({ error: 'Nicht gefunden.' }));
// eslint-disable-next-line no-unused-vars -- Express erkennt Fehlerbehandler an vier Parametern
app.use((error, req, res, next) => {
if (error instanceof HttpError) {
return res.status(error.status).json({ error: error.message, ...error.details });
}
// Fehler aus express.json (kaputtes oder zu großes JSON)
if (error.status >= 400 && error.status < 500) {
return res.status(error.status).json({ error: 'Ungültige Anfrage.' });
}
// Der Browser hat den Upload abgebrochen; der Stand bleibt gültig.
if (error.code === 'ERR_STREAM_PREMATURE_CLOSE') return;
console.error(error);
if (!res.headersSent) res.status(500).json({ error: 'Interner Fehler.' });
});
return app;
}
+81
View File
@@ -0,0 +1,81 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
export const SESSION_COOKIE = 'fdc_session';
export const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
// Ohne SESSION_SECRET wird einmalig ein Geheimnis im Datenverzeichnis erzeugt,
// damit Sitzungen einen Neustart überleben.
export function loadSessionSecret(config) {
if (config.sessionSecret) return config.sessionSecret;
const file = path.join(config.dataDir, 'session-secret');
try {
return fs.readFileSync(file, 'utf8').trim();
} catch {
const secret = crypto.randomBytes(32).toString('hex');
fs.mkdirSync(config.dataDir, { recursive: true });
fs.writeFileSync(file, secret, { mode: 0o600 });
return secret;
}
}
// Die PIN fließt in die Signatur ein: wer die PIN ändert, meldet alle Sitzungen ab.
function sign(secret, pin, payload) {
return crypto.createHmac('sha256', secret).update(`${payload}|${pin}`).digest('base64url');
}
function safeEqual(a, b) {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
return bufA.length === bufB.length && crypto.timingSafeEqual(bufA, bufB);
}
export function createToken(secret, pin, now = Date.now()) {
const payload = String(now + SESSION_MAX_AGE_MS);
return `${payload}.${sign(secret, pin, payload)}`;
}
export function verifyToken(secret, pin, token, now = Date.now()) {
if (!pin || typeof token !== 'string') return false;
const [payload, signature] = token.split('.');
if (!payload || !signature) return false;
if (!safeEqual(signature, sign(secret, pin, payload))) return false;
return Number(payload) > now;
}
export function pinMatches(expected, given) {
if (!expected || typeof given !== 'string') return false;
const hash = (value) => crypto.createHash('sha256').update(value).digest();
return crypto.timingSafeEqual(hash(expected), hash(given));
}
// Nur ein Nutzer: ein globaler Zähler reicht und greift auch hinter dem Proxy.
export function createLoginLimiter({ maxFailures = 10, windowMs = 15 * 60 * 1000 } = {}) {
let failures = [];
return {
isBlocked(now = Date.now()) {
failures = failures.filter((time) => now - time < windowMs);
return failures.length >= maxFailures;
},
recordFailure(now = Date.now()) {
failures.push(now);
},
reset() {
failures = [];
},
};
}
export function readCookie(req, name) {
const header = req.headers.cookie;
if (!header) return undefined;
for (const part of header.split(';')) {
const index = part.indexOf('=');
if (index === -1) continue;
if (part.slice(0, index).trim() === name) {
return decodeURIComponent(part.slice(index + 1).trim());
}
}
return undefined;
}
+12
View File
@@ -0,0 +1,12 @@
import path from 'node:path';
export function loadConfig(env = process.env) {
return {
// Im Docker-Image fest auf 3000 (APP_PORT), sonst der von VCH vergebene PORT.
port: Number(env.APP_PORT || env.PORT || 3000),
pin: env.APP_PIN || '',
sessionSecret: env.SESSION_SECRET || '',
dataDir: path.resolve(env.VCH_DATA_DIR || './data'),
isProduction: env.NODE_ENV === 'production',
};
}
+11
View File
@@ -0,0 +1,11 @@
export class HttpError extends Error {
constructor(status, message, details = {}) {
super(message);
this.status = status;
this.details = details;
}
}
export const badRequest = (message) => new HttpError(400, message);
export const notFound = (message = 'Nicht gefunden.') => new HttpError(404, message);
export const conflict = (message, details) => new HttpError(409, message, details);
+53
View File
@@ -0,0 +1,53 @@
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import express from 'express';
import { createApp } from './app.js';
import { loadSessionSecret } from './auth.js';
import { loadConfig } from './config.js';
import { probe } from './media.js';
import { createProjectStore } from './projects.js';
import { createRenderQueue } from './renderQueue.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.');
}
const store = createProjectStore({ dataDir: config.dataDir, probe });
const queue = createRenderQueue({ store });
const app = createApp({ config, secret: loadSessionSecret(config), store, queue });
const server = http.createServer(app);
if (config.isProduction) {
const distDir = path.join(rootDir, 'dist');
app.use(express.static(distDir));
app.use((req, res, next) => {
if (req.method !== 'GET') return next();
res.sendFile(path.join(distDir, 'index.html'));
});
} else {
// Im Entwicklungsbetrieb liefert Vite das Frontend über denselben Port aus.
const { createServer } = await import('vite');
const vite = await createServer({
root: rootDir,
appType: 'spa',
server: { middlewareMode: true, hmr: { server } },
});
app.use(vite.middlewares);
}
server.listen(config.port, '0.0.0.0', () => {
console.log(`FuerDieCuts läuft auf Port ${config.port}`);
});
+68
View File
@@ -0,0 +1,68 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
function parseRate(rate) {
const [numerator, denominator] = String(rate || '0/1').split('/').map(Number);
return denominator ? numerator / denominator : 0;
}
// Handyvideos liegen oft quer im Container und tragen die Drehung als Metadatum.
function readRotation(stream) {
const side = stream.side_data_list?.find((entry) => entry.rotation !== undefined);
if (side) return Number(side.rotation);
if (stream.tags?.rotate) return Number(stream.tags.rotate);
return 0;
}
// Länge aus dem Ende des letzten Pakets, wenn der Dateikopf keine nennt.
async function measureDuration(file) {
const { stdout } = await execFileAsync(
'ffprobe',
['-v', 'error', '-show_entries', 'packet=pts_time,duration_time', '-of', 'csv=p=0', file],
{ maxBuffer: 64 * 1024 * 1024 },
);
let end = 0;
for (const line of stdout.split('\n')) {
const [pts, duration] = line.split(',').map(Number);
if (Number.isFinite(pts)) end = Math.max(end, pts + (Number.isFinite(duration) ? duration : 0));
}
return end > 0 ? end : null;
}
// Liefert die Eckdaten einer Video- oder Audiodatei; Breite und Höhe so,
// wie das Video angezeigt wird.
export async function probe(file) {
const { stdout } = await execFileAsync(
'ffprobe',
['-v', 'error', '-print_format', 'json', '-show_streams', '-show_format', file],
{ maxBuffer: 10 * 1024 * 1024 },
);
const info = JSON.parse(stdout);
const video = info.streams.find(
(stream) => stream.codec_type === 'video' && !stream.disposition?.attached_pic,
);
const audio = info.streams.find((stream) => stream.codec_type === 'audio');
const hasAudio = Boolean(audio);
if (!video && !hasAudio) throw new Error('Weder Bild noch Ton gefunden.');
// Browser-Aufnahmen (WebM) tragen oft keine Gesamtlänge im Kopf.
const duration =
[info.format?.duration, video?.duration, audio?.duration].map(Number).find(Number.isFinite) ??
(await measureDuration(file));
if (!video) {
return { duration, hasVideo: false, width: null, height: null, rotation: 0, fps: 0, hasAudio };
}
const rotation = readRotation(video);
const sideways = Math.abs(rotation) % 180 === 90;
return {
duration,
hasVideo: true,
width: sideways ? video.height : video.width,
height: sideways ? video.width : video.height,
rotation,
fps: Math.round(parseRate(video.avg_frame_rate || video.r_frame_rate) * 100) / 100,
hasAudio,
};
}
+340
View File
@@ -0,0 +1,340 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { HttpError, badRequest, conflict, notFound } from './http-error.js';
import { defaultSettings, mergeSettings } from './settings.js';
export const MAX_CLIPS = 200;
export const MAX_CLIP_BYTES = 4 * 1024 ** 3;
export const MAX_CHUNK_BYTES = 16 * 1024 * 1024;
const ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/;
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const VIDEO_EXTENSIONS = new Set(['mp4', 'mov', 'm4v', '3gp', 'avi', 'mkv', 'webm']);
const AUDIO_EXTENSIONS = new Set([
'mp3', 'm4a', 'aac', 'wav', 'ogg', 'oga', 'opus', 'flac', 'weba', 'webm', 'mp4',
]);
// Zusatzdateien: Intro und Outro sind Videos, Musik und Voice-overs Ton.
const ASSET_SLOTS = {
intro: { needs: 'video', extensions: VIDEO_EXTENSIONS },
outro: { needs: 'video', extensions: VIDEO_EXTENSIONS },
music: { needs: 'audio', extensions: AUDIO_EXTENSIONS },
voice: { needs: 'audio', extensions: AUDIO_EXTENSIONS },
};
function extensionOf(name) {
return name.split('.').pop().toLowerCase();
}
function optionalString(value, maxLength) {
if (value === null || value === undefined) return null;
if (typeof value !== 'string') throw badRequest('Ungültige Angabe.');
return value.slice(0, maxLength);
}
function validateSize(size, label) {
if (!Number.isSafeInteger(size) || size < 1 || size > MAX_CLIP_BYTES) {
throw badRequest(`${label}: ungültige Dateigröße.`);
}
return size;
}
function validateClip(input, index) {
const file = typeof input?.file === 'string' ? input.file : '';
if (!file || file.length > 255 || !VIDEO_EXTENSIONS.has(extensionOf(file))) {
throw badRequest(`Clip ${index + 1}: kein unterstütztes Video.`);
}
if (typeof input.timestamp !== 'string' || !TIMESTAMP_PATTERN.test(input.timestamp)) {
throw badRequest(`Clip ${index + 1}: ungültiger Zeitstempel.`);
}
return {
index,
file,
size: validateSize(input.size, `Clip ${index + 1}`),
timestamp: input.timestamp,
sender: optionalString(input.sender, 200),
caption: optionalString(input.caption, 2000) ?? '',
status: 'pending',
};
}
function validateRange(range) {
const from = range?.from ?? null;
const to = range?.to ?? null;
for (const value of [from, to]) {
if (value !== null && (typeof value !== 'string' || !DATE_PATTERN.test(value))) {
throw badRequest('Ungültiger Zeitraum.');
}
}
return { from, to };
}
// "intro", "outro", "music" oder "voice-<Clip-Index>"
function parseSlot(slot, project) {
const match = /^(intro|outro|music|voice)(?:-(\d+))?$/.exec(String(slot));
if (!match) throw notFound('Unbekannte Datei.');
const [, kind, index] = match;
if ((kind === 'voice') !== (index !== undefined)) throw notFound('Unbekannte Datei.');
if (kind === 'voice' && !project.clips[Number(index)]) throw notFound('Clip nicht gefunden.');
return { kind, ...ASSET_SLOTS[kind] };
}
// Bricht ab, sobald mehr Bytes kommen als erlaubt.
function byteLimit(limit) {
let count = 0;
return new Transform({
transform(chunk, encoding, callback) {
count += chunk.length;
if (count > limit) return callback(new HttpError(413, 'Zu viele Daten.'));
callback(null, chunk);
},
});
}
async function fileSize(file) {
try {
return (await fsp.stat(file)).size;
} catch (error) {
if (error.code === 'ENOENT') return 0;
throw error;
}
}
export function createProjectStore({ dataDir, probe }) {
const root = path.join(dataDir, 'projects');
const queues = new Map();
const busyUploads = new Set();
function projectDir(id) {
if (typeof id !== 'string' || !ID_PATTERN.test(id)) throw notFound();
return path.join(root, id);
}
const projectFile = (id) => path.join(projectDir(id), 'project.json');
// Ein Upload-Ziel ist ein Clip oder eine Zusatzdatei; beide haben file, size, status.
function locate(project, target) {
if (target.type === 'clip') {
const index = String(target.index);
const item = project.clips[Number(index)];
if (!/^\d+$/.test(index) || !item) throw notFound('Clip nicht gefunden.');
return {
item,
needs: 'video',
file: path.join(projectDir(project.id), 'clips', `${item.index}.${extensionOf(item.file)}`),
pick: (current) => current.clips[item.index],
};
}
const { needs } = parseSlot(target.slot, project);
const item = project.assets?.[target.slot];
if (!item) throw notFound('Datei nicht gefunden.');
return {
item,
needs,
file: path.join(projectDir(project.id), 'assets', `${target.slot}.${extensionOf(item.file)}`),
pick: (current) => current.assets[target.slot],
};
}
async function read(id) {
try {
const project = JSON.parse(await fsp.readFile(projectFile(id), 'utf8'));
// Projekte aus älteren Ständen ergänzen
project.settings = { ...defaultSettings(), ...project.settings };
project.assets ??= {};
return project;
} catch (error) {
if (error.code === 'ENOENT') throw notFound();
throw error;
}
}
async function write(project) {
const file = projectFile(project.id);
const temp = `${file}.tmp`;
await fsp.writeFile(temp, JSON.stringify(project, null, 2));
await fsp.rename(temp, file);
}
// Änderungen an einem Projekt laufen nacheinander, damit keine verloren geht.
function update(id, change) {
const previous = queues.get(id) ?? Promise.resolve();
const next = previous.then(async () => {
const project = await read(id);
await change(project);
project.updatedAt = new Date().toISOString();
await write(project);
return project;
});
queues.set(id, next.catch(() => {}));
return next;
}
async function withProgress(project) {
const received = async (item, file) =>
item.status === 'pending' ? fileSize(`${file}.part`) : item.size;
const clips = await Promise.all(
project.clips.map(async (clip) => ({
...clip,
received: await received(clip, locate(project, { type: 'clip', index: clip.index }).file),
})),
);
const assets = {};
for (const slot of Object.keys(project.assets)) {
const { item, file } = locate(project, { type: 'asset', slot });
assets[slot] = { ...item, received: await received(item, file) };
}
return { ...project, clips, assets };
}
return {
update,
read,
projectDir,
async create({ range, clips }) {
if (!Array.isArray(clips) || clips.length === 0) throw badRequest('Keine Clips ausgewählt.');
if (clips.length > MAX_CLIPS) throw badRequest(`Höchstens ${MAX_CLIPS} Clips.`);
const now = new Date().toISOString();
const project = {
id: crypto.randomUUID(),
createdAt: now,
updatedAt: now,
range: validateRange(range),
clips: clips.map(validateClip),
assets: {},
settings: defaultSettings(),
};
await fsp.mkdir(path.join(projectDir(project.id), 'clips'), { recursive: true });
await write(project);
return withProgress(project);
},
async get(id) {
return withProgress(await read(id));
},
async list() {
let entries = [];
try {
entries = await fsp.readdir(root);
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
const projects = [];
for (const entry of entries) {
if (!ID_PATTERN.test(entry)) continue;
try {
const project = await read(entry);
projects.push({
id: project.id,
createdAt: project.createdAt,
range: project.range,
clipCount: project.clips.length,
readyCount: project.clips.filter((clip) => clip.status === 'ready').length,
});
} catch {
// Unvollständig angelegte Projekte überspringen
}
}
return projects.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
},
async remove(id) {
const dir = projectDir(id);
await read(id);
await fsp.rm(dir, { recursive: true, force: true });
},
async updateSettings(id, patch) {
const project = await update(id, (current) => {
current.settings = mergeSettings(current.settings, patch, current.clips.length);
});
return withProgress(project);
},
// Meldet eine neue Zusatzdatei an; eine vorhandene im selben Platz wird ersetzt.
async createAsset(id, slot, { file, size } = {}) {
const project = await read(id);
const { extensions } = parseSlot(slot, project);
if (typeof file !== 'string' || !file || file.length > 255 || !extensions.has(extensionOf(file))) {
throw badRequest('Dateityp wird hier nicht unterstützt.');
}
validateSize(size, 'Datei');
if (project.assets[slot]) await this.removeAsset(id, slot);
await fsp.mkdir(path.join(projectDir(id), 'assets'), { recursive: true });
const updated = await update(id, (current) => {
current.assets[slot] = { file, size, status: 'pending', revision: crypto.randomUUID() };
});
return withProgress(updated);
},
async removeAsset(id, slot) {
const project = await read(id);
const { file } = locate(project, { type: 'asset', slot });
await fsp.rm(file, { force: true });
await fsp.rm(`${file}.part`, { force: true });
const updated = await update(id, (current) => {
delete current.assets[slot];
});
return withProgress(updated);
},
// Pfad einer fertig hochgeladenen Datei, für Vorschau und Schnitt.
readyPath(project, target) {
const { item, file } = locate(project, target);
if (item.status !== 'ready') throw notFound('Datei ist noch nicht hochgeladen.');
return file;
},
// Hängt ein Stück an. Die Größe der Teildatei ist der maßgebliche Stand:
// bricht eine Übertragung ab, bleibt ein gültiger Anfang liegen.
async appendChunk(id, target, offset, stream) {
const project = await read(id);
const { item, needs, file: final, pick } = locate(project, target);
if (item.status !== 'pending') return { received: item.size, status: item.status };
const key = `${id}/${final}`;
if (busyUploads.has(key)) throw conflict('Upload läuft bereits.', { received: null });
busyUploads.add(key);
try {
const part = `${final}.part`;
const received = await fileSize(part);
if (offset !== received) throw conflict('Upload-Stand weicht ab.', { received });
// Zu viel Gesendetes wird gar nicht erst geschrieben; der Anfang bleibt gültig.
const allowed = Math.min(MAX_CHUNK_BYTES, item.size - received);
await pipeline(stream, byteLimit(allowed), fs.createWriteStream(part, { flags: 'a' }));
const total = await fileSize(part);
if (total < item.size) return { received: total, status: 'pending' };
await fsp.rename(part, final);
let media = null;
let problem = null;
try {
media = await probe(final);
if (needs === 'video' && !media.hasVideo) problem = 'Keine Videospur gefunden.';
else if (needs === 'video' && !(media.duration > 0)) problem = 'Länge des Videos nicht lesbar.';
if (needs === 'audio' && !media.hasAudio) problem = 'Keine Tonspur gefunden.';
} catch (error) {
problem = error.message;
}
const updated = await update(id, (current) => {
const entry = pick(current);
entry.status = problem ? 'error' : 'ready';
entry.media = problem ? null : media;
if (problem) entry.error = problem;
});
return { received: total, status: pick(updated).status };
} finally {
busyUploads.delete(key);
}
},
};
}
+304
View File
@@ -0,0 +1,304 @@
import { spawn } from 'node:child_process';
import crypto from 'node:crypto';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { TARGET_SECONDS } from './settings.js';
export const OUTPUT = { width: 1920, height: 1080, fps: 30, sampleRate: 48000 };
// Erhöhen, wenn sich die Filterkette ändert: dann verfallen zwischengespeicherte Segmente.
const RENDER_VERSION = 1;
const MUSIC_FADE_SECONDS = 2;
const AUDIO_FORMAT = `aformat=sample_fmts=fltp:sample_rates=${OUTPUT.sampleRate}:channel_layouts=stereo`;
// Zwischenstände tragen unkomprimierten Ton; erst das Endergebnis bekommt AAC.
// So summieren sich über viele Übergänge keine Verschiebungen auf.
const SEGMENT_CODECS = [
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p',
'-r', String(OUTPUT.fps), '-video_track_timescale', '15360',
'-c:a', 'pcm_s16le',
];
const hash = (value) =>
crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 32);
const factor = (volume) => Math.round(volume) / 100;
const seconds = (value) => value.toFixed(3);
function readyAsset(project, slot) {
const asset = project.assets?.[slot];
return asset?.status === 'ready' ? asset : null;
}
// Beschreibt, was gerendert wird – ohne Pfade, damit der Plan vergleichbar bleibt.
export function planRender(project) {
const { settings } = project;
const bookend = (slot) => {
const asset = readyAsset(project, slot);
if (!settings[slot].enabled || !asset) return null;
return {
kind: slot,
target: { type: 'asset', slot },
source: `${slot}:${asset.revision}`,
speed: 1,
outDuration: asset.media.duration,
audio: asset.media.hasAudio ? { volume: factor(settings[slot].volume), tempo: 1 } : null,
voice: null,
};
};
const clips = project.clips
.filter((clip) => clip.status === 'ready' && !settings.excluded.includes(clip.index))
.map((clip) => {
const { duration, hasAudio } = clip.media;
const speed = settings.speedUp && duration > TARGET_SECONDS ? duration / TARGET_SECONDS : 1;
const voice = readyAsset(project, `voice-${clip.index}`);
return {
kind: 'clip',
index: clip.index,
target: { type: 'clip', index: clip.index },
source: `clip:${clip.index}:${clip.file}:${clip.size}`,
speed,
outDuration: duration / speed,
audio: !settings.muteClips && hasAudio ? { volume: factor(settings.clipVolume), tempo: speed } : null,
voice: voice && {
target: { type: 'asset', slot: `voice-${clip.index}` },
source: `voice:${voice.revision}`,
volume: factor(settings.voiceVolume) * factor(settings.voiceVolumes[clip.index] ?? 100),
},
};
});
const intro = bookend('intro');
const outro = bookend('outro');
const segments = [intro, ...clips, outro].filter(Boolean);
for (const segment of segments) {
const { source, speed, outDuration, audio, voice } = segment;
segment.key = hash({
version: RENDER_VERSION,
source,
speed,
outDuration,
audio,
voice: voice && { source: voice.source, volume: voice.volume },
});
}
const clipsDuration = clips.reduce((sum, clip) => sum + clip.outDuration, 0);
const musicAsset = readyAsset(project, 'music');
const music =
settings.music.enabled && musicAsset && clips.length > 0
? {
target: { type: 'asset', slot: 'music' },
source: `music:${musicAsset.revision}`,
volume: factor(settings.music.volume),
duration: clipsDuration,
}
: null;
return {
intro,
clips,
outro,
music,
clipsDuration,
duration: segments.reduce((sum, segment) => sum + segment.outDuration, 0),
key: hash({
segments: segments.map((segment) => segment.key),
music: music && { source: music.source, volume: music.volume },
}),
};
}
// atempo schafft je Stufe höchstens Faktor 2, also verketten.
function tempoFilters(tempo) {
const filters = [];
let rest = tempo;
while (rest > 2) {
filters.push('atempo=2');
rest /= 2;
}
if (rest !== 1) filters.push(`atempo=${rest.toFixed(6)}`);
return filters;
}
// Ein Segment: auf 1920×1080 bei 30 fps, Hochkant mit unscharfem Hintergrund,
// Ton aus Clip (oder Stille) plus Voice-over, exakt auf Segmentlänge.
export function segmentArgs(segment, inputs, output) {
const { width, height, fps } = OUTPUT;
const duration = seconds(segment.outDuration);
const speed = segment.speed === 1 ? '' : `setpts=PTS/${segment.speed.toFixed(6)},`;
const filters = [
`[0:v]scale=iw*sar:ih,setsar=1,${speed}fps=${fps},split=2[bgsrc][fgsrc]`,
`[bgsrc]scale=${width / 4}:${height / 4}:force_original_aspect_ratio=increase,` +
`crop=${width / 4}:${height / 4},boxblur=8:2,scale=${width}:${height}[bg]`,
`[fgsrc]scale=${width}:${height}:force_original_aspect_ratio=decrease[fg]`,
'[bg][fg]overlay=(W-w)/2:(H-h)/2,setsar=1,format=yuv420p[v]',
segment.audio
? `[0:a]${[...tempoFilters(segment.audio.tempo), `volume=${segment.audio.volume}`, AUDIO_FORMAT].join(',')}[base]`
: `anullsrc=r=${OUTPUT.sampleRate}:cl=stereo,${AUDIO_FORMAT}[base]`,
];
if (segment.voice) {
filters.push(`[1:a]volume=${segment.voice.volume},${AUDIO_FORMAT}[voice]`);
filters.push('[base][voice]amix=inputs=2:duration=first:normalize=0[mixed]');
}
filters.push(`[${segment.voice ? 'mixed' : 'base'}]apad,atrim=0:${duration}[a]`);
return [
'-i', inputs.video,
...(segment.voice ? ['-i', inputs.voice] : []),
'-filter_complex', filters.join(';'),
'-map', '[v]', '-map', '[a]', '-t', duration,
...SEGMENT_CODECS,
output,
];
}
// Musik unter den Clip-Teil: wiederholt bis zum Ende, dort gekürzt und ausgeblendet.
export function musicArgs(music, inputs, output) {
const duration = seconds(music.duration);
const fade = Math.min(MUSIC_FADE_SECONDS, music.duration);
const fadeStart = seconds(Math.max(0, music.duration - fade));
return [
'-i', inputs.middle,
'-stream_loop', '-1', '-i', inputs.music,
'-filter_complex',
`[1:a]${AUDIO_FORMAT},volume=${music.volume},atrim=0:${duration},` +
`afade=t=out:st=${fadeStart}:d=${seconds(fade)}[m];` +
'[0:a][m]amix=inputs=2:duration=first:normalize=0[a]',
'-map', '0:v', '-map', '[a]', '-c:v', 'copy', '-c:a', 'pcm_s16le',
output,
];
}
export function concatArgs(listFile, output, { final }) {
return [
'-f', 'concat', '-safe', '0', '-i', listFile,
'-map', '0:v', '-map', '0:a', '-c:v', 'copy',
...(final ? ['-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart'] : ['-c:a', 'copy']),
output,
];
}
// Startet ffmpeg und meldet die bereits erzeugte Ausgabezeit in Sekunden.
export function runFfmpeg(args, { onTime, signal } = {}) {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(new Error('Abgebrochen.'));
const child = spawn(
'ffmpeg',
['-hide_banner', '-nostdin', '-y', '-v', 'error', '-progress', 'pipe:1', ...args],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
let stderr = '';
let buffer = '';
const abort = () => child.kill('SIGKILL');
signal?.addEventListener('abort', abort, { once: true });
child.stdout.on('data', (data) => {
buffer += data;
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
const match = /^out_time_us=(\d+)/.exec(line);
if (match && onTime) onTime(Number(match[1]) / 1e6);
}
});
child.stderr.on('data', (data) => {
stderr = (stderr + data).slice(-4000);
});
child.on('error', reject);
child.on('close', (code) => {
signal?.removeEventListener('abort', abort);
if (signal?.aborted) reject(new Error('Abgebrochen.'));
else if (code === 0) resolve();
else reject(new Error(`ffmpeg ist fehlgeschlagen: ${stderr.trim().split('\n').slice(-3).join(' | ')}`));
});
});
}
async function exists(file) {
try {
await fsp.access(file);
return true;
} catch {
return false;
}
}
async function writeConcatList(file, entries) {
const quote = (entry) => `file '${entry.replace(/'/g, "'\\''")}'`;
await fsp.writeFile(file, `${entries.map(quote).join('\n')}\n`);
}
// Rendert den Plan nach output. resolve(target) liefert den Pfad einer Eingabedatei.
export async function renderPlan({ plan, resolve, workDir, output, onProgress = () => {}, signal }) {
if (plan.clips.length === 0) throw new Error('Keine Clips für das Video ausgewählt.');
const cacheDir = path.join(workDir, 'cache');
const tempDir = path.join(workDir, 'tmp');
await fsp.mkdir(cacheDir, { recursive: true });
await fsp.rm(tempDir, { recursive: true, force: true });
await fsp.mkdir(tempDir, { recursive: true });
// Gewichte für die Fortschrittsanzeige: Segmente kosten fast die ganze Zeit.
const musicWeight = plan.music ? plan.clipsDuration * 0.1 : 0;
const finalWeight = plan.duration * 0.05;
const total = plan.duration + musicWeight + finalWeight;
let done = 0;
const report = (current = 0) => onProgress(Math.min(1, (done + current) / total));
const segmentFile = (segment) => path.join(cacheDir, `${segment.key}.mov`);
const segments = [plan.intro, ...plan.clips, plan.outro].filter(Boolean);
for (const segment of segments) {
const file = segmentFile(segment);
if (!(await exists(file))) {
const temp = path.join(tempDir, `${segment.key}.mov`);
const inputs = {
video: resolve(segment.target),
voice: segment.voice ? resolve(segment.voice.target) : null,
};
await runFfmpeg(segmentArgs(segment, inputs, temp), {
signal,
onTime: (time) => report(Math.min(time, segment.outDuration)),
});
await fsp.rename(temp, file);
}
done += segment.outDuration;
report();
}
const middleList = path.join(tempDir, 'middle.txt');
let middle = path.join(tempDir, 'middle.mov');
await writeConcatList(middleList, plan.clips.map(segmentFile));
await runFfmpeg(concatArgs(middleList, middle, { final: false }), { signal });
if (plan.music) {
const withMusic = path.join(tempDir, 'middle-music.mov');
await runFfmpeg(musicArgs(plan.music, { middle, music: resolve(plan.music.target) }, withMusic), {
signal,
onTime: (time) => report((Math.min(time, plan.clipsDuration) / plan.clipsDuration) * musicWeight),
});
middle = withMusic;
done += musicWeight;
report();
}
const finalList = path.join(tempDir, 'final.txt');
const parts = [plan.intro && segmentFile(plan.intro), middle, plan.outro && segmentFile(plan.outro)];
await writeConcatList(finalList, parts.filter(Boolean));
const tempOutput = path.join(tempDir, 'output.mp4');
await runFfmpeg(concatArgs(finalList, tempOutput, { final: true }), {
signal,
onTime: (time) => report((Math.min(time, plan.duration) / plan.duration) * finalWeight),
});
await fsp.rename(tempOutput, output);
onProgress(1);
// Aufräumen: Zwischenstände und Segmente, die der aktuelle Plan nicht mehr braucht.
await fsp.rm(tempDir, { recursive: true, force: true });
const keep = new Set(segments.map((segment) => `${segment.key}.mov`));
for (const entry of await fsp.readdir(cacheDir)) {
if (!keep.has(entry)) await fsp.rm(path.join(cacheDir, entry), { force: true });
}
}
+90
View File
@@ -0,0 +1,90 @@
import fsp from 'node:fs/promises';
import path from 'node:path';
import { badRequest, conflict, notFound } from './http-error.js';
import { planRender, renderPlan } from './render.js';
// Exporte laufen nacheinander: ffmpeg lastet alle Kerne aus.
export function createRenderQueue({ store, render = renderPlan }) {
const jobs = new Map();
let chain = Promise.resolve();
const now = () => new Date().toISOString();
const setRender = (id, patch) =>
store.update(id, (project) => {
project.render = { ...project.render, ...patch };
});
async function run(id) {
const job = jobs.get(id);
try {
if (job.controller.signal.aborted) throw new Error('Abgebrochen.');
const project = await store.read(id);
const plan = planRender(project);
await setRender(id, { status: 'running', startedAt: now(), planKey: plan.key });
const dir = store.projectDir(id);
await render({
plan,
resolve: (target) => store.readyPath(project, target),
workDir: path.join(dir, 'render'),
output: path.join(dir, 'output.mp4'),
signal: job.controller.signal,
onProgress: (progress) => {
job.progress = progress;
},
});
await setRender(id, { status: 'done', finishedAt: now(), duration: plan.duration, error: null });
} catch (error) {
const cancelled = job.controller.signal.aborted;
await setRender(id, {
status: cancelled ? 'cancelled' : 'error',
finishedAt: now(),
error: cancelled ? null : error.message,
}).catch(() => {});
} finally {
jobs.delete(id);
}
}
return {
async start(id) {
if (jobs.has(id)) throw conflict('Das Video wird bereits erstellt.');
const plan = planRender(await store.read(id));
if (plan.clips.length === 0) throw badRequest('Keine fertig hochgeladenen Clips ausgewählt.');
jobs.set(id, { progress: 0, controller: new AbortController() });
await setRender(id, { status: 'queued', queuedAt: now(), error: null });
chain = chain.then(() => run(id));
},
cancel(id) {
const job = jobs.get(id);
if (!job) throw notFound('Es läuft kein Export.');
job.controller.abort();
},
isRunning: (id) => jobs.has(id),
// Gespeicherter Stand plus Fortschritt aus dem Speicher. Steht ein Export noch
// auf „läuft“, ohne dass er hier bekannt ist, wurde der Server neu gestartet.
describe(project) {
const saved = project.render ?? { status: 'idle' };
const job = jobs.get(project.id);
if (job) return { ...saved, progress: job.progress };
if (saved.status === 'queued' || saved.status === 'running') {
return { ...saved, status: 'error', error: 'Unterbrochen, weil der Server neu gestartet wurde.' };
}
if (saved.status === 'done') {
return { ...saved, outdated: planRender(project).key !== saved.planKey };
}
return saved;
},
async outputPath(project) {
if (project.render?.status !== 'done') throw notFound('Noch kein fertiges Video.');
const file = path.join(store.projectDir(project.id), 'output.mp4');
await fsp.access(file).catch(() => {
throw notFound('Noch kein fertiges Video.');
});
return file;
},
};
}
+76
View File
@@ -0,0 +1,76 @@
import { badRequest } from './http-error.js';
export const TARGET_SECONDS = 10;
export function defaultSettings() {
return {
intro: { enabled: false, volume: 100 },
outro: { enabled: false, volume: 100 },
speedUp: true,
muteClips: true,
clipVolume: 100,
music: { enabled: false, volume: 60 },
voiceVolume: 100,
// Pro Clip-Index: Lautstärke des Voice-overs, 0–100
voiceVolumes: {},
// Clip-Indizes, die nicht ins Video sollen
excluded: [],
};
}
function volume(value, name) {
if (!Number.isInteger(value) || value < 0 || value > 100) {
throw badRequest(`${name}: Lautstärke muss zwischen 0 und 100 liegen.`);
}
return value;
}
function flag(value, name) {
if (typeof value !== 'boolean') throw badRequest(`${name}: ungültiger Wert.`);
return value;
}
function clipIndex(value, clipCount) {
const index = Number(value);
if (!Number.isInteger(index) || index < 0 || index >= clipCount) {
throw badRequest('Unbekannter Clip.');
}
return index;
}
// Übernimmt nur bekannte Felder aus patch und prüft jedes; der Rest bleibt.
export function mergeSettings(current, patch, clipCount) {
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
throw badRequest('Ungültige Einstellungen.');
}
const next = structuredClone({ ...defaultSettings(), ...current });
for (const part of ['intro', 'outro', 'music']) {
if (patch[part] === undefined) continue;
const value = patch[part];
if (!value || typeof value !== 'object') throw badRequest(`${part}: ungültiger Wert.`);
if (value.enabled !== undefined) next[part].enabled = flag(value.enabled, part);
if (value.volume !== undefined) next[part].volume = volume(value.volume, part);
}
for (const key of ['speedUp', 'muteClips']) {
if (patch[key] !== undefined) next[key] = flag(patch[key], key);
}
for (const key of ['clipVolume', 'voiceVolume']) {
if (patch[key] !== undefined) next[key] = volume(patch[key], key);
}
if (patch.voiceVolumes !== undefined) {
if (!patch.voiceVolumes || typeof patch.voiceVolumes !== 'object') {
throw badRequest('voiceVolumes: ungültiger Wert.');
}
for (const [key, value] of Object.entries(patch.voiceVolumes)) {
next.voiceVolumes[clipIndex(key, clipCount)] = volume(value, 'Voice-over');
}
}
if (patch.excluded !== undefined) {
if (!Array.isArray(patch.excluded)) throw badRequest('excluded: ungültiger Wert.');
next.excluded = [...new Set(patch.excluded.map((value) => clipIndex(value, clipCount)))].sort(
(a, b) => a - b,
);
}
return next;
}
+32 -549
View File
@@ -1,569 +1,52 @@
import { useState, useEffect, useMemo, useRef } from 'react'
import './styles.css'
import {
useTweaks, TweaksPanel, TweakSection,
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
} from './TweaksPanel.jsx'
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'
// ===== DATA =====
const CLIPS_SEED = [
{ id: "c1", name: "IMG-20260418-WA0023.mp4", from: "Mama", duration: 18, size: "4.2 MB", hue: 18, status: "done", rec: "empfohlen", reason: "Klare Pointe am Ende, gut hörbar, reagiert direkt auf Papas Witz.", transcript: "… also ich sag' euch, der Hund hat sich den ganzen Kuchen geschnappt und ist damit unter den Tisch —", tags: ["Familie","Moment"] },
{ id: "c2", name: "IMG-20260418-WA0027.mp4", from: "Jonas", duration: 42, size: "9.8 MB", hue: 210, status: "done", rec: "empfohlen", reason: "Starker visueller Moment (Sonnenuntergang), ruhig eingesprochen.", transcript: "Guck mal wie das Licht gerade auf dem Wasser tanzt, das ist so verrückt, ich steh' hier schon seit zehn Minuten.", tags: ["Outdoor","Ruhig"] },
{ id: "c3", name: "IMG-20260418-WA0031.mp4", from: "Lena", duration: 8, size: "1.9 MB", hue: 340, status: "done", rec: "optional", reason: "Kurz und charmant, aber Ton etwas leise.", transcript: "Heey, ich wollt nur kurz sagen — ich hab's geschafft!", tags: ["Kurz"] },
{ id: "c4", name: "IMG-20260419-WA0002.mp4", from: "Papa", duration: 55, size: "12.1 MB", hue: 35, status: "done", rec: "überspringen", reason: "Lange Stille in der Mitte, Kameraruckler ab 0:32.", transcript: "So, jetzt müsste das eigentlich funktionieren … ähm … moment …", tags: ["Technik"] },
{ id: "c5", name: "IMG-20260419-WA0014.mp4", from: "Tante Rita", duration: 23, size: "5.4 MB", hue: 120, status: "done", rec: "empfohlen", reason: "Warme Begrüßung, gute Energie, passender Einstieg.", transcript: "Ihr Lieben, ich wünsch euch allen einen wunderbaren Sonntag und denkt dran —", tags: ["Gruß","Warm"] },
{ id: "c6", name: "IMG-20260419-WA0019.mp4", from: "Max", duration: 31, size: "7.2 MB", hue: 260, status: "done", rec: "optional", reason: "Gute Musik im Hintergrund, Sprache teils überlagert.", transcript: "Das Konzert gestern war einfach — ich hab keine Worte, Leute, das war —", tags: ["Musik"] },
{ id: "c7", name: "IMG-20260420-WA0005.mp4", from: "Mama", duration: 12, size: "2.8 MB", hue: 18, status: "done", rec: "empfohlen", reason: "Lacher, hohe emotionale Dichte in kurzer Zeit.", transcript: "Nein nein nein das kann nicht sein haha das ist nicht euer Ernst —", tags: ["Lacher"] },
{ id: "c8", name: "IMG-20260420-WA0011.mp4", from: "Jonas", duration: 47, size: "10.9 MB", hue: 210, status: "transcribing", progress: 64, rec: null, reason: null, transcript: null, tags: [] },
{ id: "c9", name: "IMG-20260420-WA0018.mp4", from: "Lena", duration: 15, size: "3.4 MB", hue: 340, status: "transcribing", progress: 22, rec: null, reason: null, transcript: null, tags: [] },
{ id: "c10", name: "IMG-20260421-WA0003.mp4", from: "Papa", duration: 29, size: "6.7 MB", hue: 35, status: "queued", rec: null, reason: null, transcript: null, tags: [] },
{ id: "c11", name: "IMG-20260421-WA0009.mp4", from: "Oma", duration: 38, size: "8.6 MB", hue: 80, status: "done", rec: "empfohlen", reason: "Seltener O-Ton von Oma, sehr bewegend.", transcript: "Weißt du, als ich in deinem Alter war, gab's das alles noch nicht und trotzdem —", tags: ["Herz","Familie"] },
{ id: "c12", name: "IMG-20260421-WA0021.mp4", from: "Max", duration: 19, size: "4.5 MB", hue: 260, status: "done", rec: "optional", reason: "Solider Moment, aber thematisch ähnlich zu Clip 06.", transcript: "Und dann hat er noch gesagt, dass das nächste Album schon —", tags: ["Musik"] },
{ id: "c13", name: "IMG-20260422-WA0001.mp4", from: "Tante Rita", duration: 52, size: "11.8 MB", hue: 120, status: "done", rec: "überspringen", reason: "Großer Teil ist Anleitung, wenig Reel-Material.", transcript: "Also ihr nehmt zuerst die Zwiebeln, schneidet die klein, dann kommt der Knoblauch dazu —", tags: ["Tutorial"] },
{ id: "c14", name: "IMG-20260422-WA0008.mp4", from: "Jonas", duration: 11, size: "2.6 MB", hue: 210, status: "done", rec: "empfohlen", reason: "Punchline mit klarer Pointe, starker Abschluss.", transcript: "… und das, meine Damen und Herren, ist der Grund warum ich keine Katzen mag.", tags: ["Punchline"] },
{ id: "c15", name: "IMG-20260422-WA0015.mp4", from: "Mama", duration: 26, size: "6.0 MB", hue: 18, status: "done", rec: "optional", reason: "Nett, aber wiederholt Thema aus Clip 01.", transcript: "Der Hund macht's schon wieder, der sitzt schon wieder unter'm Tisch —", tags: ["Familie"] },
{ id: "c16", name: "IMG-20260423-WA0004.mp4", from: "Lena", duration: 33, size: "7.5 MB", hue: 340, status: "done", rec: "empfohlen", reason: "Persönliche Reflexion, gut geschnitten, ruhige Kamera.", transcript: "Ich hab in der Woche echt viel über das nachgedacht was du letztens meintest und —", tags: ["Persönlich"] },
{ id: "c17", name: "IMG-20260423-WA0012.mp4", from: "Max", duration: 44, size: "10.2 MB", hue: 260, status: "done", rec: "überspringen", reason: "Vertikal/horizontal-Wechsel, Schnitt wird unruhig.", transcript: "Wartet, ich dreh mal um — nee andersrum — ok jetzt —", tags: ["Unruhig"] },
{ id: "c18", name: "IMG-20260423-WA0020.mp4", from: "Papa", duration: 21, size: "4.9 MB", hue: 35, status: "done", rec: "optional", reason: "Ordentlicher Ton, Inhalt wiederholt sich später.", transcript: "Hab gestern das neue Regal aufgebaut und es steht, gradmal vier Stunden gedauert.", tags: ["Alltag"] },
]
const SELECTED_SEED = ["c2","c5","c1","c7","c14","c16","c11"]
// ===== THUMB =====
function Thumb({ hue, duration, selected, index, w = 180, h = 108 }) {
const bg = `linear-gradient(135deg, oklch(0.22 0.04 ${hue}) 0%, oklch(0.14 0.03 ${hue}) 60%, oklch(0.09 0.02 ${hue}) 100%)`
const mm = String(Math.floor(duration / 60)).padStart(1, "0")
const ss = String(duration % 60).padStart(2, "0")
return (
<div className="thumb" style={{ background: bg, width: w, height: h }}>
<svg className="thumb-stripes" viewBox="0 0 160 100" preserveAspectRatio="none">
<defs>
<pattern id={`p-${hue}`} width="18" height="18" patternUnits="userSpaceOnUse" patternTransform="rotate(35)">
<line x1="0" y1="0" x2="0" y2="18" stroke={`oklch(0.30 0.03 ${hue})`} strokeWidth="1" />
</pattern>
</defs>
<rect width="160" height="100" fill={`url(#p-${hue})`} opacity="0.35" />
</svg>
<div className="thumb-play">
<svg width="22" height="22" viewBox="0 0 22 22"><path d="M8 6 L16 11 L8 16 Z" fill="rgba(255,255,255,0.92)" /></svg>
</div>
<div className="thumb-dur">{mm}:{ss}</div>
{selected && <div className="thumb-badge">#{index}</div>}
</div>
)
}
// ===== SIDEBAR =====
function Sidebar({ current, counts }) {
const steps = [
{ id: "import", label: "Import", sub: `${counts.total} Clips geladen` },
{ id: "transcribe",label: "Transkription", sub: `${counts.done}/${counts.total} fertig` },
{ id: "select", label: "Clip-Auswahl", sub: `${counts.selected} ausgewählt` },
{ id: "export", label: "Assembly & Export", sub: `~${counts.totalDur}s Reel` },
]
return (
<aside className="sidebar">
<div className="brand">
<div className="brand-mark"><div /><div /><div /></div>
<div className="brand-text">
<div className="brand-name">Reelkit</div>
<div className="brand-sub">Wochenrückblick</div>
</div>
</div>
<div className="project">
<div className="project-label">Aktuelles Projekt</div>
<div className="project-name">KW 17 · Familie & Freunde</div>
<div className="project-meta">18 Clips · 9,3 min Rohmaterial</div>
</div>
<nav className="steps">
{steps.map((s, i) => (
<a key={s.id} className={`step ${s.id === current ? "is-current" : ""} ${i < steps.findIndex(x => x.id === current) ? "is-done" : ""}`}>
<span className="step-num">{String(i + 1).padStart(2, "0")}</span>
<span className="step-body">
<span className="step-label">{s.label}</span>
<span className="step-sub">{s.sub}</span>
</span>
</a>
))}
</nav>
<div className="side-foot">
<div className="foot-row"><span>Speicher</span><span className="mono">2,1 / 10 GB</span></div>
<div className="foot-bar"><div style={{ width: "21%" }} /></div>
<div className="foot-hint">Whisper-Modell: <span className="mono">medium · de</span></div>
</div>
</aside>
)
}
// ===== CLIP ROW =====
function ClipRow({ clip, selected, orderIndex, onToggle, onDrag, onDragOver, onDrop, onHover, hovered, tweaks }) {
const thumbSizes = { small: { w: 130, h: 78 }, medium: { w: 180, h: 108 }, large: { w: 230, h: 138 } }
const ts = thumbSizes[tweaks.thumbSize] || thumbSizes.medium
const recMeta = {
"empfohlen": { dot: "rec-good", label: "empfohlen" },
"optional": { dot: "rec-mid", label: "optional" },
"überspringen": { dot: "rec-skip", label: "überspringen" },
}[clip.rec] || null
const statusLabel = {
"queued": "in Warteschlange",
"transcribing": `transkribiere … ${clip.progress ?? 0}%`,
"done": "Transkript fertig",
}[clip.status]
return (
<div
className={`row ${selected ? "is-selected" : ""} ${hovered ? "is-hovered" : ""} ${clip.status !== "done" ? "is-pending" : ""}`}
draggable={clip.status === "done"}
onDragStart={(e) => onDrag(e, clip.id)}
onDragOver={(e) => onDragOver(e, clip.id)}
onDrop={(e) => onDrop(e, clip.id)}
onMouseEnter={() => onHover(clip.id)}
onMouseLeave={() => onHover(null)}
>
<div className="row-check">
<button className={`check ${selected ? "is-on" : ""}`} onClick={() => onToggle(clip.id)} disabled={clip.status !== "done"}>
{selected && <svg viewBox="0 0 12 12" width="11" height="11"><path d="M2 6.2 L5 9 L10 3.2" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>}
</button>
{selected && <div className="order-pill mono">#{orderIndex}</div>}
</div>
<div className="row-thumb" style={{ width: ts.w }}>
<Thumb hue={clip.hue} duration={clip.duration} selected={selected} index={orderIndex} w={ts.w} h={ts.h} />
</div>
<div className="row-body">
<div className="row-head">
<div className="row-title">
<span className="from">{clip.from}</span>
{tweaks.showFilename && <>
<span className="sep">·</span>
<span className="filename mono">{clip.name}</span>
</>}
</div>
{tweaks.showMetaBar && <div className="row-meta mono">
<span>{clip.duration}s</span>
<span className="dotsep">·</span>
<span>{clip.size}</span>
<span className="dotsep">·</span>
<span className={`status s-${clip.status}`}>
<span className="status-dot" />
{statusLabel}
</span>
</div>}
</div>
{clip.status === "done" ? (
<div className="transcript" style={{ WebkitLineClamp: tweaks.transcriptLines, display: "-webkit-box", WebkitBoxOrient: "vertical", overflow: "hidden" }}>
<span className="quote">„</span>
{clip.transcript}
<span className="quote">"</span>
</div>
) : clip.status === "transcribing" ? (
<div className="transcribing">
<div className="transcribe-bar"><div style={{ width: `${clip.progress}%` }} /></div>
<div className="transcribe-hint mono">Whisper · medium · verarbeite Audio …</div>
</div>
) : (
<div className="transcribing">
<div className="transcribe-hint mono">wartet auf GPU-Slot</div>
</div>
)}
{tweaks.showTags && clip.tags.length > 0 && (
<div className="tags">
{clip.tags.map(t => <span key={t} className="tag">{t}</span>)}
</div>
)}
</div>
<div className="row-rec">
{recMeta && (
<div className={`rec ${recMeta.dot}`}>
<div className="rec-head">
<span className="rec-dot" />
<span className="rec-label">{recMeta.label}</span>
</div>
{tweaks.showReasons && clip.reason && <div className="rec-reason">{clip.reason}</div>}
</div>
)}
</div>
<div className="row-grip" title="Zum Sortieren ziehen">
<svg width="10" height="16" viewBox="0 0 10 16">
<circle cx="2" cy="3" r="1.2" /><circle cx="8" cy="3" r="1.2" />
<circle cx="2" cy="8" r="1.2" /><circle cx="8" cy="8" r="1.2" />
<circle cx="2" cy="13" r="1.2" /><circle cx="8" cy="13" r="1.2" />
</svg>
</div>
</div>
)
}
// ===== MAIN =====
export default function App() {
const [clips, setClips] = useState(CLIPS_SEED)
const [selected, setSelected] = useState(SELECTED_SEED)
const [filter, setFilter] = useState("alle")
const [sort, setSort] = useState("empfehlung")
const [query, setQuery] = useState("")
const [hovered, setHovered] = useState(null)
const dragId = useRef(null)
const [session, setSession] = useState(null)
const [loadError, setLoadError] = useState('')
const [view, setView] = useState({ name: 'home' })
const DEFAULTS = {
density: "comfortable",
showReasons: true,
accent: "moss",
targetLength: 90,
theme: "light",
thumbSize: "medium",
transcriptLines: 2,
showTags: true,
showFilename: true,
showMetaBar: true,
autoSelectRecommended: false,
groupBySender: false,
hideSkipped: false,
whisperModel: "medium",
whisperLang: "de",
aiStrictness: 2,
exportFormat: "mp4",
exportResolution: "1080p",
aspectRatio: "9:16",
addCrossfade: true,
crossfadeMs: 300,
addCaptions: false,
captionStyle: "minimal",
musicBed: "keine",
musicVolume: 20,
watermark: false,
minClipLength: 4,
maxClipLength: 60,
fontFamily: "inter-tight",
radius: 10,
cornerStyle: "soft",
async function loadSession() {
try {
setSession(await api('session'))
} catch (err) {
setLoadError(err.message)
}
}
const [tweaks, setTweaks] = useTweaks(DEFAULTS)
// Simulate transcription progress
useEffect(() => {
const t = setInterval(() => {
setClips(cs => cs.map(c => {
if (c.status === "transcribing") {
const p = Math.min(100, (c.progress ?? 0) + 3)
if (p >= 100) return { ...c, status: "done", progress: 100, rec: "optional", reason: "Frisch transkribiert, noch ungeprüft.", transcript: "… (gerade fertig transkribiert) …", tags: ["Neu"] }
return { ...c, progress: p }
}
return c
}))
}, 1200)
return () => clearInterval(t)
loadSession()
}, [])
// Apply tweaks to CSS vars
useEffect(() => {
const map = {
amber: { h: 60, c: 0.13 },
ocean: { h: 230, c: 0.10 },
moss: { h: 145, c: 0.09 },
plum: { h: 340, c: 0.10 },
crimson: { h: 25, c: 0.14 },
graphite:{ h: 260, c: 0.02 },
}
const a = map[tweaks.accent] || map.moss
const root = document.documentElement
root.style.setProperty("--accent", `oklch(0.68 ${a.c} ${a.h})`)
root.style.setProperty("--accent-2", `oklch(0.58 ${a.c} ${a.h})`)
root.style.setProperty("--accent-bg", `oklch(0.96 ${a.c * 0.3} ${a.h})`)
root.style.setProperty("--density", tweaks.density === "compact" ? "10px" : tweaks.density === "spacious" ? "22px" : "16px")
root.style.setProperty("--radius", tweaks.cornerStyle === "sharp" ? "2px" : tweaks.cornerStyle === "round" ? "16px" : "10px")
const fonts = {
"inter-tight": '"Inter Tight", "Inter", system-ui, sans-serif',
"geist": '"Geist", "Inter", system-ui, sans-serif',
"ibm-plex": '"IBM Plex Sans", system-ui, sans-serif',
"system": 'system-ui, -apple-system, sans-serif',
}
root.style.setProperty("--font", fonts[tweaks.fontFamily] || fonts["inter-tight"])
root.style.fontFamily = fonts[tweaks.fontFamily] || fonts["inter-tight"]
if (tweaks.theme === "dark") {
root.style.setProperty("--bg", "oklch(0.18 0.01 80)")
root.style.setProperty("--bg-2", "oklch(0.22 0.01 80)")
root.style.setProperty("--panel", "oklch(0.24 0.01 80)")
root.style.setProperty("--line", "oklch(0.32 0.01 80)")
root.style.setProperty("--line-2", "oklch(0.38 0.01 80)")
root.style.setProperty("--ink", "oklch(0.96 0.004 80)")
root.style.setProperty("--ink-2", "oklch(0.78 0.008 80)")
root.style.setProperty("--ink-3", "oklch(0.58 0.006 80)")
} else {
root.style.setProperty("--bg", "oklch(0.985 0.004 85)")
root.style.setProperty("--bg-2", "oklch(0.97 0.004 85)")
root.style.setProperty("--panel", "#ffffff")
root.style.setProperty("--line", "oklch(0.92 0.004 85)")
root.style.setProperty("--line-2", "oklch(0.88 0.004 85)")
root.style.setProperty("--ink", "oklch(0.18 0.01 80)")
root.style.setProperty("--ink-2", "oklch(0.38 0.008 80)")
root.style.setProperty("--ink-3", "oklch(0.58 0.006 80)")
}
const thumbCols = { small: "130px", medium: "180px", large: "230px" }
root.style.setProperty("--thumb-col", thumbCols[tweaks.thumbSize] || thumbCols.medium)
}, [tweaks.accent, tweaks.density, tweaks.cornerStyle, tweaks.fontFamily, tweaks.theme, tweaks.thumbSize])
const filtered = useMemo(() => {
let list = clips.slice()
if (filter === "empfohlen") list = list.filter(c => c.rec === "empfohlen")
if (filter === "pending") list = list.filter(c => c.status !== "done")
if (filter === "ausgewählt") list = list.filter(c => selected.includes(c.id))
if (tweaks.hideSkipped) list = list.filter(c => c.rec !== "überspringen")
if (query.trim()) {
const q = query.toLowerCase()
list = list.filter(c => (c.transcript || "").toLowerCase().includes(q) || c.from.toLowerCase().includes(q) || c.name.toLowerCase().includes(q))
}
if (sort === "empfehlung") {
const rank = { "empfohlen": 0, "optional": 1, "überspringen": 2 }
list.sort((a, b) => (rank[a.rec] ?? 3) - (rank[b.rec] ?? 3))
} else if (sort === "dauer") {
list.sort((a, b) => b.duration - a.duration)
} else if (sort === "absender") {
list.sort((a, b) => a.from.localeCompare(b.from))
}
return list
}, [clips, filter, sort, query, selected, tweaks.hideSkipped])
// Auto-select recommended
useEffect(() => {
if (!tweaks.autoSelectRecommended) return
setSelected(s => {
const add = clips.filter(c => c.rec === "empfohlen" && !s.includes(c.id)).map(c => c.id)
return add.length ? [...s, ...add] : s
})
}, [tweaks.autoSelectRecommended, clips])
const toggle = (id) => {
setSelected(s => s.includes(id) ? s.filter(x => x !== id) : [...s, id])
async function logout() {
await api('logout', { method: 'POST' })
loadSession()
}
const onDrag = (e, id) => { dragId.current = id; e.dataTransfer.effectAllowed = "move" }
const onDragOver = (e, id) => {
if (!dragId.current || dragId.current === id) return
e.preventDefault()
}
const onDrop = (e, id) => {
if (!dragId.current || dragId.current === id) return
e.preventDefault()
setSelected(s => {
if (!s.includes(dragId.current) || !s.includes(id)) return s
const next = s.filter(x => x !== dragId.current)
const idx = next.indexOf(id)
next.splice(idx, 0, dragId.current)
return next
})
dragId.current = null
if (loadError) return <main className="login"><p className="error">{loadError}</p></main>
if (!session) return null
if (!session.authenticated) {
return <Login pinConfigured={session.pinConfigured} onLogin={loadSession} />
}
const counts = {
total: clips.length,
done: clips.filter(c => c.status === "done").length,
selected: selected.length,
totalDur: selected.reduce((n, id) => n + (clips.find(c => c.id === id)?.duration || 0), 0),
}
const overTarget = counts.totalDur > tweaks.targetLength
const home = () => setView({ name: 'home' })
const openProject = (id) => setView({ name: 'project', id })
return (
<div className="app">
<Sidebar current="select" counts={counts} />
<main className="main">
<header className="top">
<div>
<div className="breadcrumb mono">Projekt · KW 17 · <span>Clip-Auswahl</span></div>
<h1>Welche Clips kommen ins Reel?</h1>
<p className="lede">18 Clips, 7 bereits empfohlen. Wähle aus, sortiere per Drag-and-drop und prüfe die KI-Begründung. Transkripte laufen im Hintergrund.</p>
</div>
<div className="top-actions">
<button className="btn-ghost">Auswahl zurücksetzen</button>
<button className="btn-primary">Weiter zu Export
<svg width="14" height="14" viewBox="0 0 14 14"><path d="M3 7 H11 M7.5 3.5 L11 7 L7.5 10.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button>
</div>
<header className="topbar">
<button type="button" className="brand" onClick={home}>FuerDieCuts</button>
<button type="button" className="secondary" onClick={logout}>Abmelden</button>
</header>
<div className="strip">
<div className="strip-group">
<div className="strip-label">Auswahl</div>
<div className="strip-value">
<span className="big">{counts.selected}</span>
<span className="unit">Clips</span>
</div>
</div>
<div className="strip-sep" />
<div className="strip-group">
<div className="strip-label">Länge gewählt</div>
<div className="strip-value">
<span className={`big ${overTarget ? "over" : ""}`}>{counts.totalDur}s</span>
<span className="unit">/ Ziel {tweaks.targetLength}s</span>
</div>
<div className="strip-bar">
<div className={overTarget ? "over" : ""} style={{ width: `${Math.min(100, counts.totalDur / tweaks.targetLength * 100)}%` }} />
</div>
</div>
<div className="strip-sep" />
<div className="strip-group">
<div className="strip-label">Transkription</div>
<div className="strip-value">
<span className="big">{counts.done}</span>
<span className="unit">/ {counts.total} fertig</span>
</div>
<div className="strip-bar">
<div style={{ width: `${counts.done / counts.total * 100}%` }} />
</div>
</div>
<div className="strip-sep" />
<div className="strip-group right">
<div className="strip-label">KI-Empfehlungen</div>
<div className="rec-legend">
<span><i className="rec-good" /> 7 empfohlen</span>
<span><i className="rec-mid" /> 5 optional</span>
<span><i className="rec-skip" /> 3 überspringen</span>
</div>
</div>
</div>
<div className="toolbar">
<div className="filters">
{[
["alle", `Alle · ${clips.length}`],
["empfohlen", `Nur empfohlen · ${clips.filter(c => c.rec === "empfohlen").length}`],
["ausgewählt", `Ausgewählt · ${selected.length}`],
["pending", `In Arbeit · ${clips.filter(c => c.status !== "done").length}`],
].map(([k, l]) => (
<button key={k} className={`chip ${filter === k ? "is-on" : ""}`} onClick={() => setFilter(k)}>{l}</button>
))}
</div>
<div className="tools">
<div className="search">
<svg width="14" height="14" viewBox="0 0 14 14"><circle cx="6" cy="6" r="4.2" fill="none" stroke="currentColor" strokeWidth="1.4" /><path d="M9.2 9.2 L12 12" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" /></svg>
<input placeholder="Im Transkript suchen …" value={query} onChange={e => setQuery(e.target.value)} />
</div>
<label className="sort">
Sortieren
<select value={sort} onChange={e => setSort(e.target.value)}>
<option value="empfehlung">KI-Empfehlung</option>
<option value="dauer">Länge</option>
<option value="absender">Absender</option>
</select>
</label>
</div>
</div>
<div className={`list density-${tweaks.density}`}>
<div className="list-head">
<div />
<div>Vorschau</div>
<div>Absender & Transkript</div>
<div>KI-Empfehlung</div>
<div />
</div>
{filtered.map(c => {
const orderIndex = selected.indexOf(c.id) + 1
return (
<ClipRow
key={c.id}
clip={c}
selected={selected.includes(c.id)}
orderIndex={orderIndex}
hovered={hovered === c.id}
onHover={setHovered}
onToggle={toggle}
onDrag={onDrag}
onDragOver={onDragOver}
onDrop={onDrop}
tweaks={tweaks}
/>
)
})}
</div>
<div className="sticky-foot">
<div className="foot-info">
<div className="foot-title">Reihenfolge deines Reels</div>
<div className="foot-chain">
{selected.length === 0 && <span className="foot-empty">Noch keine Clips ausgewählt.</span>}
{selected.map((id, i) => {
const c = clips.find(x => x.id === id)
if (!c) return null
return (
<span key={id} style={{ display: 'contents' }}>
<div className="chain-clip">
<div className="chain-thumb" style={{ background: `linear-gradient(135deg, oklch(0.22 0.04 ${c.hue}), oklch(0.12 0.02 ${c.hue}))` }} />
<div className="chain-meta">
<div className="chain-from">#{i + 1} {c.from}</div>
<div className="chain-dur mono">{c.duration}s</div>
</div>
</div>
{i < selected.length - 1 && <div className="chain-arrow">→</div>}
</span>
)
})}
</div>
</div>
<div className="foot-actions">
<button className="btn-ghost">Als Entwurf speichern</button>
<button className="btn-primary big">
Weiter → Assembly ({counts.totalDur}s)
</button>
</div>
</div>
<main className="content">
{view.name === 'home' && <Home onNew={() => setView({ name: 'import' })} onOpen={openProject} />}
{view.name === 'import' && <ImportView onCancel={home} onDone={openProject} />}
{view.name === 'project' && <ProjectView key={view.id} id={view.id} onBack={home} />}
</main>
<TweaksPanel title="Tweaks">
<TweakSection label="Erscheinungsbild">
<TweakRadio label="Theme" value={tweaks.theme} options={[{ value: "light", label: "Hell" }, { value: "dark", label: "Dunkel" }]} onChange={v => setTweaks('theme', v)} />
<TweakRadio label="Akzent" value={tweaks.accent} options={[{ value: "moss", label: "Moss" }, { value: "amber", label: "Amber" }, { value: "ocean", label: "Ocean" }, { value: "plum", label: "Plum" }, { value: "crimson", label: "Crimson" }, { value: "graphite", label: "Graphite" }]} onChange={v => setTweaks('accent', v)} />
<TweakSelect label="Schriftart" value={tweaks.fontFamily} options={[{ value: "inter-tight", label: "Inter Tight" }, { value: "geist", label: "Geist" }, { value: "ibm-plex", label: "IBM Plex Sans" }, { value: "system", label: "System" }]} onChange={v => setTweaks('fontFamily', v)} />
<TweakRadio label="Ecken" value={tweaks.cornerStyle} options={[{ value: "sharp", label: "Scharf" }, { value: "soft", label: "Weich" }, { value: "round", label: "Rund" }]} onChange={v => setTweaks('cornerStyle', v)} />
</TweakSection>
<TweakSection label="Liste & Layout">
<TweakRadio label="Dichte" value={tweaks.density} options={[{ value: "compact", label: "Kompakt" }, { value: "comfortable", label: "Komfortabel" }, { value: "spacious", label: "Luftig" }]} onChange={v => setTweaks('density', v)} />
<TweakRadio label="Thumbnail-Größe" value={tweaks.thumbSize} options={[{ value: "small", label: "S" }, { value: "medium", label: "M" }, { value: "large", label: "L" }]} onChange={v => setTweaks('thumbSize', v)} />
<TweakSlider label="Transkript-Zeilen" value={tweaks.transcriptLines} min={1} max={5} step={1} onChange={v => setTweaks('transcriptLines', v)} />
<TweakToggle label="KI-Begründungen zeigen" value={tweaks.showReasons} onChange={v => setTweaks('showReasons', v)} />
<TweakToggle label="Tags zeigen" value={tweaks.showTags} onChange={v => setTweaks('showTags', v)} />
<TweakToggle label="Dateinamen zeigen" value={tweaks.showFilename} onChange={v => setTweaks('showFilename', v)} />
<TweakToggle label="Meta-Zeile zeigen" value={tweaks.showMetaBar} onChange={v => setTweaks('showMetaBar', v)} />
</TweakSection>
<TweakSection label="Verhalten">
<TweakToggle label="Empfohlene automatisch auswählen" value={tweaks.autoSelectRecommended} onChange={v => setTweaks('autoSelectRecommended', v)} />
<TweakToggle label="Nach Absender gruppieren" value={tweaks.groupBySender} onChange={v => setTweaks('groupBySender', v)} />
<TweakToggle label={'„Überspringen" ausblenden'} value={tweaks.hideSkipped} onChange={v => setTweaks('hideSkipped', v)} />
</TweakSection>
<TweakSection label="KI & Transkription">
<TweakSelect label="Whisper-Modell" value={tweaks.whisperModel} options={[{ value: "tiny", label: "tiny (schnell)" }, { value: "base", label: "base" }, { value: "small", label: "small" }, { value: "medium", label: "medium" }, { value: "large-v3", label: "large-v3 (genau)" }]} onChange={v => setTweaks('whisperModel', v)} />
<TweakSelect label="Sprache" value={tweaks.whisperLang} options={[{ value: "de", label: "Deutsch" }, { value: "en", label: "Englisch" }, { value: "auto", label: "Automatisch" }]} onChange={v => setTweaks('whisperLang', v)} />
<TweakSlider label="KI-Strenge" value={tweaks.aiStrictness} min={1} max={5} step={1} onChange={v => setTweaks('aiStrictness', v)} />
<TweakSlider label="Min. Clip-Länge (s)" value={tweaks.minClipLength} min={1} max={30} step={1} onChange={v => setTweaks('minClipLength', v)} />
<TweakSlider label="Max. Clip-Länge (s)" value={tweaks.maxClipLength} min={10} max={180} step={5} onChange={v => setTweaks('maxClipLength', v)} />
</TweakSection>
<TweakSection label="Reel-Export">
<TweakSlider label="Ziel-Länge (s)" value={tweaks.targetLength} min={30} max={300} step={10} onChange={v => setTweaks('targetLength', v)} />
<TweakRadio label="Seitenverhältnis" value={tweaks.aspectRatio} options={[{ value: "9:16", label: "9:16" }, { value: "1:1", label: "1:1" }, { value: "16:9", label: "16:9" }, { value: "4:5", label: "4:5" }]} onChange={v => setTweaks('aspectRatio', v)} />
<TweakSelect label="Auflösung" value={tweaks.exportResolution} options={[{ value: "720p", label: "720p" }, { value: "1080p", label: "1080p" }, { value: "1440p", label: "1440p" }, { value: "2160p", label: "4K" }]} onChange={v => setTweaks('exportResolution', v)} />
<TweakSelect label="Format" value={tweaks.exportFormat} options={[{ value: "mp4", label: "MP4 (H.264)" }, { value: "mov", label: "MOV (ProRes)" }, { value: "webm", label: "WebM (VP9)" }]} onChange={v => setTweaks('exportFormat', v)} />
<TweakToggle label="Crossfade zwischen Clips" value={tweaks.addCrossfade} onChange={v => setTweaks('addCrossfade', v)} />
{tweaks.addCrossfade && <TweakSlider label="Crossfade (ms)" value={tweaks.crossfadeMs} min={50} max={1500} step={50} onChange={v => setTweaks('crossfadeMs', v)} />}
<TweakToggle label="Untertitel einbrennen" value={tweaks.addCaptions} onChange={v => setTweaks('addCaptions', v)} />
{tweaks.addCaptions && <TweakRadio label="Untertitel-Stil" value={tweaks.captionStyle} options={[{ value: "minimal", label: "Minimal" }, { value: "bold", label: "Bold" }, { value: "karaoke", label: "Karaoke" }]} onChange={v => setTweaks('captionStyle', v)} />}
<TweakSelect label="Musikbett" value={tweaks.musicBed} options={[{ value: "keine", label: "Keine" }, { value: "ambient-01", label: "Ambient · ruhig" }, { value: "upbeat-01", label: "Upbeat · warm" }, { value: "cinematic", label: "Cinematic" }, { value: "eigene", label: "Eigene Datei …" }]} onChange={v => setTweaks('musicBed', v)} />
{tweaks.musicBed !== "keine" && <TweakSlider label="Musik-Lautstärke" value={tweaks.musicVolume} min={0} max={100} step={5} onChange={v => setTweaks('musicVolume', v)} />}
<TweakToggle label="Wasserzeichen" value={tweaks.watermark} onChange={v => setTweaks('watermark', v)} />
</TweakSection>
</TweaksPanel>
</div>
)
}
+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>
)
}
+30
View File
@@ -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 (
<div className="actions">
<button type="button" onClick={() => folderInput.current.click()} disabled={disabled}>
{folderLabel}
</button>
<button
type="button"
className="secondary"
onClick={() => filesInput.current.click()}
disabled={disabled}
>
Einzelne Dateien wählen
</button>
<input ref={folderInput} type="file" webkitdirectory="" multiple hidden onChange={handleChange} />
<input ref={filesInput} type="file" multiple hidden onChange={handleChange} />
</div>
)
}
+49
View File
@@ -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 (
<div className="stack">
<div className="heading-row">
<h1>Deine Videos</h1>
<button type="button" onClick={onNew}>Neues Video</button>
</div>
{projects && projects.length === 0 && (
<section className="card">
<p className="muted">Noch keine Projekte. Starte mit „Neues Video“ und dem WhatsApp-Export.</p>
</section>
)}
{projects && projects.length > 0 && (
<ul className="project-list">
{projects.map((project) => (
<li key={project.id}>
<button type="button" className="project-card" onClick={() => onOpen(project.id)}>
<strong>
{project.range.from && project.range.to
? `${formatDate(project.range.from)} – ${formatDate(project.range.to)}`
: 'Projekt'}
</strong>
<span className="muted">
{project.readyCount} von {project.clipCount} Clips hochgeladen · angelegt am{' '}
{new Date(project.createdAt).toLocaleDateString('de-DE')}
</span>
</button>
</li>
))}
</ul>
)}
<p className="error" role="alert">{error}</p>
</div>
)
}
+233
View File
@@ -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 (
<div className="stack">
<div className="heading-row">
<h1>Neues Video</h1>
<button type="button" className="secondary" onClick={onCancel} disabled={busy}>
Abbrechen
</button>
</div>
<section className="card stack">
<h2>1. WhatsApp-Export wählen</h2>
<p className="muted">
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.
</p>
<ExportPicker onPick={pick} disabled={locked} />
{source && (
<p>
{source.clips.length} Videos im Export gefunden
{source.clips.length > 0 &&
`, vom ${formatDateTime(source.clips[0].timestamp)} bis ${formatDateTime(source.clips.at(-1).timestamp)}`}
.
</p>
)}
</section>
{source && (
<section className="card stack">
<h2>2. Zeitraum und Videos</h2>
<div className="actions">
<label className="field">
Von
<input
type="date"
value={range.from}
max={range.to}
disabled={locked}
onChange={(event) => setRange({ ...range, from: event.target.value })}
/>
</label>
<label className="field">
Bis
<input
type="date"
value={range.to}
min={range.from}
disabled={locked}
onChange={(event) => setRange({ ...range, to: event.target.value })}
/>
</label>
</div>
<ul className="days" aria-label="Videos pro Tag">
{days.map(({ day, count }) => (
<li key={day} className={count === 0 ? 'warn' : ''}>
{formatDay(day)}: {count}
</li>
))}
</ul>
{missing.length > 0 && (
<p className="warning" role="status">
{missing.length} Video(s) aus diesem Zeitraum stehen im Chat, fehlen aber im Export.
Wahrscheinlich sind sie nicht auf dem Handy gespeichert.
</p>
)}
{days.some(({ count }) => count === 0) && (
<p className="warning" role="status">
An manchen Tagen gibt es keine Videos. Fehlen Videos, waren sie beim Export
vermutlich nicht auf dem Handy heruntergeladen.
</p>
)}
{clips.length > 0 ? (
<>
<div className="actions">
<button type="button" className="secondary" onClick={() => selectAll(true)} disabled={locked}>
Alle
</button>
<button type="button" className="secondary" onClick={() => selectAll(false)} disabled={locked}>
Keine
</button>
</div>
<ul className="clip-list">
{clips.map((clip) => (
<li key={clip.file}>
<label>
<input
type="checkbox"
checked={!excluded.has(clip.file)}
disabled={locked}
onChange={() => toggle(clip.file)}
/>
<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>
{clip.duplicateOf && <span className="badge">doppelt</span>}
<span className="clip-size muted">{formatBytes(clip.size)}</span>
</label>
</li>
))}
</ul>
</>
) : (
<p className="muted">Keine Videos in diesem Zeitraum.</p>
)}
</section>
)}
{source && (
<section className="card stack">
<h2>3. Hochladen</h2>
<UploadProgress progress={progress} />
<div className="actions">
<button type="button" onClick={upload} disabled={busy || selected.length === 0}>
{project
? 'Upload fortsetzen'
: `${selected.length} Videos hochladen (${formatBytes(selectedBytes)})`}
</button>
</div>
</section>
)}
<p className="error" role="alert">{error}</p>
</div>
)
}
+52
View File
@@ -0,0 +1,52 @@
import { useState } from 'react'
import { api } from './api.js'
export default function Login({ pinConfigured, onLogin }) {
const [pin, setPin] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
async function submit(event) {
event.preventDefault()
setBusy(true)
setError('')
try {
await api('login', { method: 'POST', body: { pin } })
onLogin()
} catch (err) {
setError(err.message)
setPin('')
} finally {
setBusy(false)
}
}
return (
<main className="login">
<form className="card login-card" onSubmit={submit}>
<h1>FuerDieCuts</h1>
{pinConfigured ? (
<>
<label htmlFor="pin">PIN</label>
<input
id="pin"
type="password"
inputMode="numeric"
autoComplete="current-password"
autoFocus
value={pin}
onChange={(event) => setPin(event.target.value)}
required
/>
<button type="submit" disabled={busy || !pin}>
{busy ? 'Prüfe …' : 'Anmelden'}
</button>
</>
) : (
<p>Es ist keine PIN eingerichtet. Setze <code>APP_PIN</code> in der Umgebung.</p>
)}
<p className="error" role="alert" aria-live="assertive">{error}</p>
</form>
</main>
)
}
+265
View File
@@ -0,0 +1,265 @@
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'
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)
// 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 {
const loaded = await api(`projects/${id}`)
setProject(loaded)
setSettings((current) => current ?? loaded.settings)
} catch (err) {
setError(err.message)
}
}, [id])
useEffect(() => {
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('')
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 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 {
await api(`projects/${id}`, { method: 'DELETE' })
onBack()
} catch (err) {
setError(err.message)
}
}
if (!project || !settings) {
return (
<p className="error" role="alert">
{error}
</p>
)
}
const pending = project.clips.filter((clip) => clip.status === 'pending')
const toggleOpen = (index, mode) =>
setOpen(open?.index === index && open.mode === mode ? null : { index, mode })
return (
<div className="stack">
<div className="heading-row">
<h1>
{project.range.from && project.range.to
? `${formatDate(project.range.from)} – ${formatDate(project.range.to)}`
: 'Projekt'}
</h1>
<button type="button" className="secondary" onClick={onBack} disabled={busy}>
Zurück
</button>
</div>
{pending.length > 0 && (
<section className="card stack">
<h2>Upload unvollständig</h2>
<p className="muted">
{pending.length} Video(s) fehlen noch. Wähle den Exportordner erneut, der Upload macht
dort weiter, wo er aufgehört hat.
</p>
<UploadProgress progress={progress} />
<ExportPicker onPick={resume} disabled={busy} folderLabel="Exportordner wählen und fortsetzen" />
</section>
)}
<CutSettings project={project} settings={settings} onSettings={changeSettings} onProject={setProject} />
<section className="card stack">
<h2>Clips</h2>
<ol className="clip-list">
{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 bearbeiten' : 'Voice-over aufnehmen'}
</button>
</span>
)}
</div>
{isOpen('preview') && (
<video
className="preview"
src={`api/projects/${project.id}/clips/${clip.index}/file`}
controls
autoPlay
/>
)}
{isOpen('voice') && (
<VoiceOver
project={project}
clip={clip}
settings={settings}
onProject={setProject}
onVolume={(volume) => changeSettings({ voiceVolumes: { [clip.index]: volume } })}
/>
)}
</li>
)
})}
</ol>
</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}>
Projekt löschen
</button>
</div>
<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>
)
}
-358
View File
@@ -1,358 +0,0 @@
import { useState, useEffect, useRef, useCallback } from 'react'
const __TWEAKS_STYLE = `
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
background:rgba(250,249,247,.78);color:#29261b;
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
.twk-hd{display:flex;align-items:center;justify-content:space-between;
padding:10px 8px 10px 14px;cursor:move;user-select:none}
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
overflow-y:auto;overflow-x:hidden;min-height:0;
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
.twk-body::-webkit-scrollbar{width:8px}
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
border:2px solid transparent;background-clip:content-box}
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
border:2px solid transparent;background-clip:content-box}
.twk-row{display:flex;flex-direction:column;gap:5px}
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
color:rgba(41,38,27,.72)}
.twk-lbl>span:first-child{font-weight:500}
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
color:rgba(41,38,27,.45);padding:10px 0 0}
.twk-sect:first-child{padding-top:0}
.twk-field{appearance:none;width:100%;height:26px;padding:0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
select.twk-field{padding-right:22px;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
background-repeat:no-repeat;background-position:right 8px center}
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:14px;height:14px;border-radius:50%;background:#fff;
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
background:rgba(0,0,0,.06);user-select:none}
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
.twk-seg.dragging .twk-seg-thumb{transition:none}
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
background:transparent;color:inherit;font:inherit;font-weight:500;height:22px;
border-radius:6px;cursor:default;padding:0}
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
.twk-toggle[data-on="1"]{background:#34c759}
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
.twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
user-select:none;padding-right:8px}
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
outline:none;color:inherit;-moz-appearance:textfield}
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
-webkit-appearance:none;margin:0}
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
.twk-btn:hover{background:rgba(0,0,0,.88)}
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
background:transparent;flex-shrink:0}
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
`
export function useTweaks(defaults) {
const init = { ...defaults, ...(window.__INITIAL_TWEAKS__ || {}) }
const [values, setValues] = useState(init)
const setTweak = useCallback((key, val) => {
setValues((prev) => ({ ...prev, [key]: val }))
window.parent.postMessage({ type: '__edit_mode_set_keys', edits: { [key]: val } }, '*')
}, [])
return [values, setTweak]
}
export function TweaksPanel({ title = 'Tweaks', children }) {
const [open, setOpen] = useState(false)
const dragRef = useRef(null)
const offsetRef = useRef({ x: 16, y: 16 })
const PAD = 16
const clampToViewport = useCallback(() => {
const panel = dragRef.current
if (!panel) return
const w = panel.offsetWidth, h = panel.offsetHeight
const maxRight = Math.max(PAD, window.innerWidth - w - PAD)
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD)
offsetRef.current = {
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
}
panel.style.right = offsetRef.current.x + 'px'
panel.style.bottom = offsetRef.current.y + 'px'
}, [])
useEffect(() => {
if (!open) return
clampToViewport()
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', clampToViewport)
return () => window.removeEventListener('resize', clampToViewport)
}
const ro = new ResizeObserver(clampToViewport)
ro.observe(document.documentElement)
return () => ro.disconnect()
}, [open, clampToViewport])
useEffect(() => {
const onMsg = (e) => {
const t = e?.data?.type
if (t === '__activate_edit_mode') setOpen(true)
else if (t === '__deactivate_edit_mode') setOpen(false)
}
window.addEventListener('message', onMsg)
window.parent.postMessage({ type: '__edit_mode_available' }, '*')
return () => window.removeEventListener('message', onMsg)
}, [])
const dismiss = () => {
setOpen(false)
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*')
}
const onDragStart = (e) => {
const panel = dragRef.current
if (!panel) return
const r = panel.getBoundingClientRect()
const sx = e.clientX, sy = e.clientY
const startRight = window.innerWidth - r.right
const startBottom = window.innerHeight - r.bottom
const move = (ev) => {
offsetRef.current = {
x: startRight - (ev.clientX - sx),
y: startBottom - (ev.clientY - sy),
}
clampToViewport()
}
const up = () => {
window.removeEventListener('mousemove', move)
window.removeEventListener('mouseup', up)
}
window.addEventListener('mousemove', move)
window.addEventListener('mouseup', up)
}
if (!open) return null
return (
<>
<style>{__TWEAKS_STYLE}</style>
<div ref={dragRef} className="twk-panel"
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
<div className="twk-hd" onMouseDown={onDragStart}>
<b>{title}</b>
<button className="twk-x" aria-label="Close tweaks"
onMouseDown={(e) => e.stopPropagation()}
onClick={dismiss}>✕</button>
</div>
<div className="twk-body">{children}</div>
</div>
</>
)
}
export function TweakSection({ label, children }) {
return (
<>
<div className="twk-sect">{label}</div>
{children}
</>
)
}
export function TweakRow({ label, value, children, inline = false }) {
return (
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
<div className="twk-lbl">
<span>{label}</span>
{value != null && <span className="twk-val">{value}</span>}
</div>
{children}
</div>
)
}
export function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
<TweakRow label={label} value={`${value}${unit}`}>
<input type="range" className="twk-slider" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(Number(e.target.value))} />
</TweakRow>
)
}
export function TweakToggle({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
role="switch" aria-checked={!!value}
onClick={() => onChange(!value)}><i /></button>
</div>
)
}
export function TweakRadio({ label, value, options, onChange }) {
const trackRef = useRef(null)
const [dragging, setDragging] = useState(false)
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }))
const idx = Math.max(0, opts.findIndex((o) => o.value === value))
const n = opts.length
const valueRef = useRef(value)
valueRef.current = value
const segAt = (clientX) => {
const r = trackRef.current.getBoundingClientRect()
const inner = r.width - 4
const i = Math.floor(((clientX - r.left - 2) / inner) * n)
return opts[Math.max(0, Math.min(n - 1, i))].value
}
const onPointerDown = (e) => {
setDragging(true)
const v0 = segAt(e.clientX)
if (v0 !== valueRef.current) onChange(v0)
const move = (ev) => {
if (!trackRef.current) return
const v = segAt(ev.clientX)
if (v !== valueRef.current) onChange(v)
}
const up = () => {
setDragging(false)
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
return (
<TweakRow label={label}>
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
<div className="twk-seg-thumb"
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
width: `calc((100% - 4px) / ${n})` }} />
{opts.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
{o.label}
</button>
))}
</div>
</TweakRow>
)
}
export function TweakSelect({ label, value, options, onChange }) {
return (
<TweakRow label={label}>
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => {
const v = typeof o === 'object' ? o.value : o
const l = typeof o === 'object' ? o.label : o
return <option key={v} value={v}>{l}</option>
})}
</select>
</TweakRow>
)
}
export function TweakText({ label, value, placeholder, onChange }) {
return (
<TweakRow label={label}>
<input className="twk-field" type="text" value={value} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
</TweakRow>
)
}
export function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
const clamp = (n) => {
if (min != null && n < min) return min
if (max != null && n > max) return max
return n
}
const startRef = useRef({ x: 0, val: 0 })
const onScrubStart = (e) => {
e.preventDefault()
startRef.current = { x: e.clientX, val: value }
const decimals = (String(step).split('.')[1] || '').length
const move = (ev) => {
const dx = ev.clientX - startRef.current.x
const raw = startRef.current.val + dx * step
const snapped = Math.round(raw / step) * step
onChange(clamp(Number(snapped.toFixed(decimals))))
}
const up = () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
return (
<div className="twk-num">
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
<input type="number" value={value} min={min} max={max} step={step}
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
{unit && <span className="twk-num-unit">{unit}</span>}
</div>
)
}
export function TweakColor({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<input type="color" className="twk-swatch" value={value}
onChange={(e) => onChange(e.target.value)} />
</div>
)
}
export function TweakButton({ label, onClick, secondary = false }) {
return (
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
onClick={onClick}>{label}</button>
)
}
+15
View File
@@ -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 (
<div className="progress-block">
<progress max="100" value={percent} aria-label="Upload-Fortschritt">{percent} %</progress>
<p className="muted">
Video {progress.current} von {progress.count} · {formatBytes(progress.done)} von{' '}
{formatBytes(progress.total)} · {percent} %
</p>
</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>
)
}
+16
View File
@@ -0,0 +1,16 @@
// Relative Pfade, damit die App auch hinter dem VCH-Pfad-Proxy funktioniert.
export async function api(path, { method = 'GET', body } = {}) {
const res = await fetch(`api/${path}`, {
method,
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
credentials: 'same-origin',
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
const error = new Error(data.error || `Anfrage fehlgeschlagen (${res.status})`)
error.status = res.status
throw error
}
return data
}
+31
View File
@@ -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]))
}
+48
View File
@@ -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
}
+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
}
+95
View File
@@ -0,0 +1,95 @@
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 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 uploadFile({ url, 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(`${url}?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 uploadFile({
url: `api/projects/${project.id}/clips/${clip.index}`,
blob: fileByName.get(clip.file),
startAt: clip.received,
signal,
onProgress: report,
})
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
}
+213
View File
@@ -0,0 +1,213 @@
// Liest WhatsApp-Chatexporte (iPhone und Android, deutsch und englisch) und
// ordnet die Videos des Exports chronologisch. Läuft im Browser und in Node.
// Unsichtbare Richtungszeichen, die WhatsApp vor Zeilen und Namen setzt.
const INVISIBLE = /[\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
const ODD_SPACES = /[\u00a0\u202f]/g;
// iPhone: "[24.09.26, 16:36:19] Name: Text"
const IOS_HEADER =
/^\[(\d{1,2})([./])(\d{1,2})\2(\d{2,4}),? (\d{1,2}):(\d{2})(?::(\d{2}))?(?: ?([AP]M))?\] (.*)$/i;
// Android: "24.09.26, 16:36 - Name: Text"
const ANDROID_HEADER =
/^(\d{1,2})([./])(\d{1,2})\2(\d{2,4}),? (\d{1,2}):(\d{2})(?::(\d{2}))?(?: ?([AP]M))? - (.*)$/i;
// "<Anhang: 00001338-VIDEO-2026-09-24-16-36-19.mp4>" bzw. "<attached: …>"
const TAGGED_ATTACHMENT = /<[^<>:]{1,40}:\s*([^<>]+?\.[A-Za-z0-9]{2,5})>/g;
// "VID-20260924-WA0012.mp4 (Datei angehängt)" bzw. "(file attached)"
const ANDROID_ATTACHMENT = /([^\s<>]+\.[A-Za-z0-9]{2,5}) \((?:Datei angehängt|file attached)\)/g;
const VIDEO_EXTENSION = /\.(mp4|mov|m4v|3gp|avi|mkv|webm)$/i;
const pad = (value) => String(value).padStart(2, '0');
function cleanLine(line) {
return line.replace(INVISIBLE, '').replace(ODD_SPACES, ' ');
}
export function isVideoFile(name) {
return VIDEO_EXTENSION.test(name);
}
export function baseName(path) {
return path.split(/[\\/]/).pop();
}
// Bei Datumsangaben mit Schrägstrich ist die Reihenfolge nicht eindeutig
// (US: M/D, sonst D/M). Ein Tag über 12 irgendwo im Chat entscheidet.
function detectDayFirst(lines) {
for (const line of lines) {
const match = line.match(IOS_HEADER) || line.match(ANDROID_HEADER);
if (match && match[2] === '/' && Number(match[1]) > 12) return true;
}
return false;
}
function toTimestamp(match, dayFirst) {
const [, first, separator, second, rawYear, rawHour, minute, second_ = '0', meridiem] = match;
let day = Number(first);
let month = Number(second);
if (separator === '/' && !dayFirst) [day, month] = [month, day];
let year = Number(rawYear);
if (year < 100) year += 2000;
let hour = Number(rawHour);
if (meridiem) {
const pm = meridiem.toUpperCase() === 'PM';
if (hour === 12) hour = pm ? 12 : 0;
else if (pm) hour += 12;
}
if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23) return null;
return `${year}-${pad(month)}-${pad(day)}T${pad(hour)}:${minute}:${pad(second_)}`;
}
function splitSender(rest) {
const index = rest.indexOf(': ');
if (index > 0) return { sender: rest.slice(0, index).trim(), text: rest.slice(index + 2) };
if (rest.endsWith(':')) return { sender: rest.slice(0, -1).trim(), text: '' };
// Systemmeldung ohne Absender, z. B. "Max hat die Gruppe verlassen"
return { sender: null, text: rest };
}
export function findAttachments(text) {
const names = [];
for (const pattern of [TAGGED_ATTACHMENT, ANDROID_ATTACHMENT]) {
for (const match of text.matchAll(pattern)) names.push(match[1].trim());
}
return names;
}
function captionOf(text) {
return text.replace(TAGGED_ATTACHMENT, '').replace(ANDROID_ATTACHMENT, '').trim();
}
export function parseChat(rawText) {
const lines = rawText.replace(/\r\n?/g, '\n').split('\n').map(cleanLine);
const dayFirst = detectDayFirst(lines);
const messages = [];
for (const line of lines) {
const match = line.match(IOS_HEADER) || line.match(ANDROID_HEADER);
const timestamp = match && toTimestamp(match, dayFirst);
if (timestamp) {
messages.push({ index: messages.length, timestamp, ...splitSender(match[9]) });
} else if (messages.length > 0) {
messages[messages.length - 1].text += `\n${line}`;
}
}
return messages.map((message) => ({
...message,
text: message.text.trim(),
attachments: findAttachments(message.text),
}));
}
// Fallback für Videos ohne Chatzeile: der Dateiname trägt den Zeitpunkt.
export function timestampFromFileName(name) {
const ios = name.match(/(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})\.\w+$/);
if (ios) return `${ios[1]}-${ios[2]}-${ios[3]}T${ios[4]}:${ios[5]}:${ios[6]}`;
const android = name.match(/-(\d{4})(\d{2})(\d{2})-WA\d+/);
if (android) return `${android[1]}-${android[2]}-${android[3]}T00:00:00`;
return null;
}
// Die Chatreihenfolge ist verlässlicher als die Zeitstempel: WhatsApp stempelt
// beim Synchronisieren gelegentlich alte Nachrichten mit einer neuen Zeit.
function compareClips(a, b) {
return a.order - b.order || a.file.localeCompare(b.file);
}
// Videos ohne Chatzeile werden per Zeitstempel zwischen die Nachrichten einsortiert.
function orderForTimestamp(messages, timestamp) {
const next = messages.find((message) => message.timestamp > timestamp);
return next ? next.index - 0.5 : messages.length;
}
// Doppelt gepostete Videos sind bytegleich; die Dateigröße genügt als Merkmal.
function markDuplicates(clips) {
const firstBySize = new Map();
for (const clip of clips) {
if (!clip.size) continue;
const first = firstBySize.get(clip.size);
if (first) clip.duplicateOf = first.file;
else firstBySize.set(clip.size, clip);
}
}
// files: [{ name, size }] aus dem Export (Pfade erlaubt).
// Liefert die Videos in Chatreihenfolge, dazu im Chat genannte, aber fehlende Dateien.
export function collectClips(messages, files) {
const videos = new Map();
for (const file of files) {
if (isVideoFile(file.name)) videos.set(baseName(file.name), file);
}
const clips = [];
const missing = [];
const used = new Set();
for (const message of messages) {
for (const attachment of message.attachments) {
if (!isVideoFile(attachment)) continue;
const name = baseName(attachment);
const file = videos.get(name);
if (!file) {
missing.push({ file: name, timestamp: message.timestamp, sender: message.sender });
continue;
}
if (used.has(name)) continue;
used.add(name);
clips.push({
file: name,
path: file.name,
size: file.size ?? 0,
timestamp: message.timestamp,
sender: message.sender,
caption: captionOf(message.text),
order: message.index,
});
}
}
for (const [name, file] of videos) {
if (used.has(name)) continue;
const timestamp = timestampFromFileName(name);
if (!timestamp) continue;
clips.push({
file: name,
path: file.name,
size: file.size ?? 0,
timestamp,
sender: null,
caption: '',
order: orderForTimestamp(messages, timestamp),
});
}
clips.sort(compareClips);
markDuplicates(clips);
return { clips, missing };
}
// from/to als "JJJJ-MM-TT", beide Tage eingeschlossen.
export function clipsInRange(clips, from, to) {
return clips.filter((clip) => {
const day = clip.timestamp.slice(0, 10);
return (!from || day >= from) && (!to || day <= to);
});
}
export function toDateString(date) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
// Montag bis Freitag der Kalenderwoche vor der von `today`.
export function previousWorkWeek(today = new Date()) {
const date = new Date(today.getFullYear(), today.getMonth(), today.getDate());
const daysSinceMonday = (date.getDay() + 6) % 7;
const monday = new Date(date);
monday.setDate(date.getDate() - daysSinceMonday - 7);
const friday = new Date(monday);
friday.setDate(monday.getDate() + 4);
return { from: toDateString(monday), to: toDateString(friday) };
}
+1
View File
@@ -1,6 +1,7 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './styles.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
+523 -294
View File
@@ -1,325 +1,554 @@
/*
* Gestaltung nach dem tradeo Corporate Design System 0.1 (docs/tradeo-design-system.md).
*
* Verbindlich definiert und hier umgesetzt: tradeo Blue, Weiß, Poppins für Titel,
* Inter für UI und Text, 8-px-Raster, linksbündig, primäre Aktion in tradeo Blue.
*/
:root {
--bg: oklch(0.985 0.004 85);
--bg-2: oklch(0.97 0.004 85);
--panel: #ffffff;
--line: oklch(0.92 0.004 85);
--line-2: oklch(0.88 0.004 85);
--ink: oklch(0.18 0.01 80);
--ink-2: oklch(0.38 0.008 80);
--ink-3: oklch(0.58 0.006 80);
--accent: oklch(0.68 0.09 145);
--accent-2: oklch(0.58 0.09 145);
--accent-bg: oklch(0.96 0.027 145);
--rec-good: oklch(0.62 0.11 150);
--rec-mid: oklch(0.70 0.09 85);
--rec-skip: oklch(0.60 0.04 30);
--density: 16px;
--radius: 10px;
--radius-sm: 6px;
--shadow: 0 1px 2px rgba(20,15,10,0.04), 0 4px 20px -8px rgba(20,15,10,0.06);
--font: "Inter Tight", "Inter", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
font-family: var(--font);
/* --- Bestätigte tradeo-Tokens --- */
--tradeo-blue: #122e45;
--white: #ffffff;
--font-heading: 'Poppins', system-ui, sans-serif;
/* Inter ist laut Design-System 0.1 vorgeschlagen, aber noch nicht freigegeben. */
--font-ui: 'Inter', system-ui, sans-serif;
--space-1: 4px;
--space-2: 8px;
--space-3: 16px;
--space-4: 24px;
--space-5: 32px;
--space-6: 48px;
--space-7: 64px;
/*
* --- OFFENE DESIGNENTSCHEIDUNGEN (Design-System Abschnitt 23) ---
* Vorläufige Werte, bis tradeo sie festlegt. Neutraltöne sind Abstufungen
* von tradeo Blue auf Weiß; Kontraste gegen Weiß in Klammern.
*/
--neutral-text: #546979; /* Blue 72 % (5,7:1) – Hilfstexte */
--neutral-control: #71828f; /* Blue 60 % (4,0:1) – Ränder von Eingabefeldern */
--neutral-line: #d9dee1; /* Blue 16 % – Trennlinien */
--neutral-surface: #f6f7f8; /* Blue 4 % – ruhige Hintergrundfläche */
--primary-hover: #364d61; /* Blue 85 % – Hover der primären Aktion */
--status-error: #b3261e; /* Statusfarbe Fehler (6,5:1) */
--status-warning-bg: #fff4d6; /* Statusfarbe Warnung */
--status-warning-text: #6b4e00; /* (7,1:1 auf Warnfläche) */
--radius: 8px; /* Corner Radius */
--text-h1: 28px; /* Typografiegrößen und Zeilenhöhen */
--text-h2: 20px;
--text-body: 16px;
--text-small: 14px;
color-scheme: light;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--ink); font-size: 14px; line-height: 1.45; }
.mono { font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; font-feature-settings: "tnum"; }
button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; }
input, select { font-family: inherit; font-size: inherit; color: inherit; }
.app { display: grid; grid-template-columns: 280px 1fr; min-height: 100vh; }
/* ===== SIDEBAR ===== */
.sidebar {
background: var(--bg-2);
border-right: 1px solid var(--line);
padding: 28px 22px;
display: flex;
flex-direction: column;
gap: 28px;
position: sticky;
top: 0;
height: 100vh;
*,
*::before,
*::after {
box-sizing: border-box;
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark { display: grid; grid-template-columns: repeat(3, 4px); gap: 3px; width: 28px; height: 28px; align-content: center; justify-content: center; background: var(--ink); border-radius: 6px; padding: 6px; }
.brand-mark > div { background: var(--bg-2); height: 4px; border-radius: 1px; }
.brand-mark > div:nth-child(2) { background: var(--accent); }
.brand-name { font-weight: 600; font-size: 15px; letter-spacing: -0.01em; }
.brand-sub { font-size: 11px; color: var(--ink-3); letter-spacing: 0.04em; text-transform: uppercase; }
.project {
border: 1px solid var(--line);
body {
margin: 0;
background: var(--white);
color: var(--tradeo-blue);
font-family: var(--font-ui);
font-size: var(--text-body);
line-height: 1.5;
}
h1,
h2 {
font-family: var(--font-heading);
margin: 0;
}
h1 {
font-size: var(--text-h1);
font-weight: 600;
line-height: 1.25;
}
h2 {
font-size: var(--text-h2);
font-weight: 500;
line-height: 1.3;
}
p {
margin: 0;
}
/* --- Aktionen --- */
button,
a.button {
display: inline-block;
font: inherit;
font-weight: 500;
line-height: 1.25;
padding: 12px var(--space-3);
border: 1px solid var(--tradeo-blue);
border-radius: var(--radius);
padding: 14px;
background: var(--panel);
}
.project-label { font-size: 10px; color: var(--ink-3); letter-spacing: 0.08em; text-transform: uppercase; }
.project-name { font-weight: 600; font-size: 14px; margin-top: 4px; letter-spacing: -0.01em; }
.project-meta { font-size: 12px; color: var(--ink-3); margin-top: 4px; }
.steps { display: flex; flex-direction: column; gap: 2px; }
.step {
display: flex;
gap: 12px;
padding: 10px 12px;
border-radius: 8px;
background: var(--tradeo-blue);
color: var(--white);
cursor: pointer;
text-decoration: none;
color: var(--ink-2);
position: relative;
transition: background 0.12s;
}
button:hover:not(:disabled),
a.button:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
/* Sekundär: zurückhaltender als die primäre Aktion */
button.secondary {
background: var(--white);
color: var(--tradeo-blue);
border-color: var(--neutral-control);
}
button.secondary:hover:not(:disabled) {
background: var(--neutral-surface);
border-color: var(--tradeo-blue);
}
/* Destruktiv: nur funktional markiert */
button.danger {
background: var(--white);
color: var(--status-error);
border-color: var(--neutral-control);
}
button.danger:hover:not(:disabled) {
background: var(--neutral-surface);
border-color: var(--status-error);
}
button.small {
padding: var(--space-2) 12px;
font-size: var(--text-small);
}
button:disabled {
opacity: 0.45;
cursor: default;
}
.step:hover { background: rgba(0,0,0,0.025); }
.step.is-current { background: var(--panel); box-shadow: 0 0 0 1px var(--line); }
.step.is-current .step-label { color: var(--ink); font-weight: 600; }
.step.is-current .step-num { color: var(--accent-2); background: var(--accent-bg); }
.step.is-done .step-num { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.step-num {
width: 26px; height: 26px; border-radius: 6px; border: 1px solid var(--line);
display: grid; place-items: center; font-family: "JetBrains Mono", monospace; font-size: 11px; color: var(--ink-3);
:focus-visible {
outline: 2px solid var(--tradeo-blue);
outline-offset: 2px;
}
/* --- Formularelemente --- */
input:not([type]),
input[type='password'],
input[type='date'],
input[type='text'] {
font: inherit;
padding: 10px 12px;
border: 1px solid var(--neutral-control);
border-radius: var(--radius);
background: var(--white);
color: var(--tradeo-blue);
}
input[type='checkbox'],
input[type='range'],
progress {
accent-color: var(--tradeo-blue);
}
input[type='checkbox'] {
width: 20px;
height: 20px;
margin: 2px 0 0;
flex-shrink: 0;
}
.step-body { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.step-label { font-size: 13px; }
.step-sub { font-size: 11px; color: var(--ink-3); }
.side-foot { margin-top: auto; font-size: 12px; color: var(--ink-3); display: flex; flex-direction: column; gap: 8px; }
.foot-row { display: flex; justify-content: space-between; }
.foot-bar { height: 3px; background: var(--line); border-radius: 2px; overflow: hidden; }
.foot-bar > div { height: 100%; background: var(--ink-2); }
.foot-hint { font-size: 11px; }
/* ===== MAIN ===== */
.main { padding: 36px 44px 140px; max-width: 1400px; }
.top { display: flex; justify-content: space-between; align-items: flex-end; gap: 32px; margin-bottom: 28px; }
.breadcrumb { font-size: 11px; color: var(--ink-3); letter-spacing: 0.04em; margin-bottom: 8px; }
.breadcrumb span { color: var(--ink); }
.top h1 { margin: 0; font-size: 30px; font-weight: 600; letter-spacing: -0.015em; line-height: 1.2; }
.lede { margin: 12px 0 0; color: var(--ink-2); font-size: 15px; line-height: 1.6; max-width: 60ch; letter-spacing: 0; text-wrap: pretty; }
.top-actions { display: flex; gap: 10px; align-items: center; flex-shrink: 0; }
.btn-ghost {
padding: 9px 14px; border-radius: 8px; font-size: 13px; color: var(--ink-2);
border: 1px solid var(--line); background: var(--panel); transition: all 0.12s;
.field {
display: flex;
flex-direction: column;
gap: var(--space-1);
font-size: var(--text-small);
font-weight: 500;
}
.btn-ghost:hover { border-color: var(--line-2); color: var(--ink); }
.btn-primary {
padding: 9px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
background: var(--ink); color: var(--bg); display: inline-flex; align-items: center; gap: 8px;
transition: background 0.12s;
}
.btn-primary:hover { background: oklch(0.10 0.01 80); }
.btn-primary.big { padding: 12px 20px; font-size: 14px; }
/* ===== STRIP ===== */
.strip {
display: grid; grid-template-columns: 1fr 1fr 1fr 1.2fr;
gap: 0;
background: var(--panel); border: 1px solid var(--line);
border-radius: var(--radius); padding: 18px 22px;
margin-bottom: 24px;
/* --- Grundlayout --- */
.topbar {
display: flex;
align-items: center;
}
.strip-sep { width: 1px; background: var(--line); height: 42px; justify-self: center; display: none; }
.strip-group { display: flex; flex-direction: column; gap: 6px; padding: 0 22px; border-left: 1px solid var(--line); }
.strip-group:first-child { border-left: none; padding-left: 0; }
.strip-group.right { align-items: flex-start; }
.strip-label { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); }
.strip-value { display: flex; align-items: baseline; gap: 6px; }
.strip-value .big { font-size: 26px; font-weight: 600; letter-spacing: -0.02em; font-family: "JetBrains Mono", monospace; font-feature-settings: "tnum"; }
.strip-value .big.over { color: oklch(0.55 0.14 30); }
.strip-value .unit { font-size: 12px; color: var(--ink-3); }
.strip-bar { height: 3px; background: var(--line); border-radius: 2px; overflow: hidden; margin-top: 2px; max-width: 160px; }
.strip-bar > div { height: 100%; background: var(--accent); transition: width 0.3s; }
.strip-bar > div.over { background: oklch(0.60 0.14 30); }
.rec-legend { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--ink-2); }
.rec-legend span { display: inline-flex; align-items: center; gap: 7px; }
.rec-legend i { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
.rec-legend i.rec-good { background: var(--rec-good); }
.rec-legend i.rec-mid { background: var(--rec-mid); }
.rec-legend i.rec-skip { background: var(--rec-skip); }
/* ===== TOOLBAR ===== */
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; gap: 16px; flex-wrap: wrap; }
.filters { display: flex; gap: 6px; }
.chip {
padding: 7px 12px; border-radius: 999px; font-size: 12.5px;
color: var(--ink-2); border: 1px solid var(--line); background: var(--panel);
transition: all 0.12s;
}
.chip:hover { border-color: var(--line-2); }
.chip.is-on { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.tools { display: flex; gap: 10px; align-items: center; }
.search {
display: flex; align-items: center; gap: 8px;
padding: 7px 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel);
color: var(--ink-3);
}
.search:focus-within { border-color: var(--ink-2); color: var(--ink-2); }
.search input { border: none; outline: none; background: none; width: 260px; font-size: 13px; color: var(--ink); }
.search input::placeholder { color: var(--ink-3); }
.sort { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--ink-3); }
.sort select {
padding: 7px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel);
font-size: 13px; color: var(--ink);
justify-content: space-between;
gap: var(--space-3);
padding: 12px var(--space-3);
background: var(--tradeo-blue);
color: var(--white);
}
/* ===== LIST ===== */
.list {
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);
overflow: hidden;
.brand {
font-family: var(--font-heading);
font-size: 18px;
font-weight: 600;
background: none;
border: none;
padding: var(--space-1) 0;
color: var(--white);
}
.list-head {
.brand:hover:not(:disabled) {
background: none;
}
/* Sekundäre Aktion auf tradeo Blue */
.topbar button.secondary {
background: transparent;
color: var(--white);
border-color: var(--white);
}
.topbar button.secondary:hover:not(:disabled) {
background: var(--primary-hover);
}
.topbar :focus-visible {
outline-color: var(--white);
}
.content {
max-width: 960px;
margin: 0 auto;
padding: var(--space-5) var(--space-3) var(--space-7);
}
.stack {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.stack-tight {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
align-items: flex-end;
}
.heading-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
align-items: baseline;
justify-content: space-between;
}
/* Abschnitte: durch Weißraum und eine Trennlinie gegliedert, ohne Boxen */
.card {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding-top: var(--space-4);
border-top: 1px solid var(--neutral-line);
}
.muted {
color: var(--neutral-text);
}
.error {
color: var(--status-error);
margin: 0;
}
.error:empty {
display: none;
}
.warning {
background: var(--status-warning-bg);
color: var(--status-warning-text);
padding: 12px var(--space-3);
border-radius: var(--radius);
}
/* --- Anmeldung --- */
.login {
min-height: 100vh;
display: grid;
grid-template-columns: 56px var(--thumb-col, 180px) 1fr 210px 24px;
gap: 20px;
padding: 12px 18px;
border-bottom: 1px solid var(--line);
background: var(--bg-2);
font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3);
}
.row {
display: grid;
grid-template-columns: 56px var(--thumb-col, 180px) 1fr 210px 24px;
gap: 20px;
padding: var(--density) 18px;
border-bottom: 1px solid var(--line);
align-items: center;
transition: background 0.1s;
cursor: grab;
}
.row:last-child { border-bottom: none; }
.row:hover, .row.is-hovered { background: var(--bg-2); }
.row.is-selected { background: color-mix(in oklch, var(--accent-bg) 40%, var(--panel)); }
.row.is-selected:hover { background: color-mix(in oklch, var(--accent-bg) 60%, var(--panel)); }
.row.is-pending { opacity: 0.75; cursor: default; }
.row-check { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.check {
width: 20px; height: 20px; border-radius: 5px;
border: 1.5px solid var(--line-2); background: var(--panel);
display: grid; place-items: center;
transition: all 0.12s;
color: var(--bg);
}
.check:hover:not(:disabled) { border-color: var(--ink-2); }
.check:disabled { opacity: 0.35; cursor: not-allowed; }
.check.is-on { background: var(--accent-2); border-color: var(--accent-2); }
.order-pill {
font-size: 10px; padding: 2px 6px; border-radius: 4px;
background: var(--ink); color: var(--bg);
letter-spacing: 0.02em;
place-items: center;
padding: var(--space-3);
background: var(--tradeo-blue);
}
/* THUMB */
.thumb {
width: 180px; height: 108px; border-radius: var(--radius-sm);
position: relative; overflow: hidden;
box-shadow: 0 1px 2px rgba(0,0,0,0.15), inset 0 0 0 1px rgba(255,255,255,0.04);
.login-card {
width: 100%;
max-width: 360px;
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-5);
border-radius: var(--radius);
background: var(--white);
}
.thumb-stripes { position: absolute; inset: 0; width: 100%; height: 100%; }
.thumb-play {
position: absolute; inset: 0; display: grid; place-items: center;
background: radial-gradient(circle at center, rgba(0,0,0,0.25), transparent 60%);
.login-card label {
font-weight: 500;
}
.thumb-dur {
position: absolute; bottom: 6px; right: 6px;
font-family: "JetBrains Mono", monospace; font-size: 10.5px;
color: #fff; background: rgba(0,0,0,0.6); padding: 2px 6px; border-radius: 3px;
letter-spacing: 0.02em;
.login-card input {
font-size: 22px;
letter-spacing: 0.3em;
}
.thumb-badge {
position: absolute; top: 6px; left: 6px;
font-family: "JetBrains Mono", monospace; font-size: 10.5px;
background: var(--accent); color: var(--ink); padding: 2px 6px; border-radius: 3px;
/* --- Startseite --- */
.project-list {
list-style: none;
margin: 0;
padding: 0;
border-top: 1px solid var(--neutral-line);
}
.project-list li {
border-bottom: 1px solid var(--neutral-line);
}
.project-card {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
padding: var(--space-3) 0;
text-align: left;
background: none;
border: none;
border-radius: 0;
color: var(--tradeo-blue);
}
.project-card:hover:not(:disabled) {
background: var(--neutral-surface);
}
.project-card strong {
font-family: var(--font-heading);
font-weight: 500;
}
/* --- Import --- */
.days {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
list-style: none;
margin: 0;
padding: 0;
}
.days li {
padding: var(--space-1) 12px;
border: 1px solid var(--neutral-line);
border-radius: var(--radius);
font-size: var(--text-small);
font-variant-numeric: tabular-nums;
}
.days li.warn {
background: var(--status-warning-bg);
color: var(--status-warning-text);
border-color: var(--status-warning-bg);
}
.badge {
font-size: 13px;
font-weight: 500;
padding: 2px var(--space-2);
border-radius: var(--radius);
background: var(--status-warning-bg);
color: var(--status-warning-text);
}
.progress-block {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
progress {
width: 100%;
height: 8px;
}
/* --- Clip-Liste --- */
.clip-list {
list-style: none;
margin: 0;
padding: 0;
border-top: 1px solid var(--neutral-line);
}
.clip-list li {
border-bottom: 1px solid var(--neutral-line);
padding: 12px 0;
}
.clip-list label,
.clip-row {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--space-1) 12px;
}
.clip-list label {
cursor: pointer;
}
.clip-time {
font-variant-numeric: tabular-nums;
color: var(--neutral-text);
white-space: nowrap;
}
.clip-text {
flex: 1 1 14rem;
min-width: 0;
overflow-wrap: anywhere;
}
.clip-text strong {
font-weight: 600;
}
/* BODY */
.row-body { min-width: 0; display: flex; flex-direction: column; gap: 6px; }
.row-head { display: flex; flex-direction: column; gap: 2px; }
.row-title { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.row-title .from { font-weight: 600; font-size: 14px; letter-spacing: -0.005em; }
.row-title .sep { color: var(--ink-3); }
.row-title .filename { font-size: 11.5px; color: var(--ink-3); }
.row-meta { font-size: 11.5px; color: var(--ink-3); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.dotsep { opacity: 0.5; }
.status { display: inline-flex; align-items: center; gap: 5px; }
.status-dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; display: inline-block; }
.status.s-done { color: var(--rec-good); }
.status.s-transcribing { color: var(--accent-2); }
.status.s-queued { color: var(--ink-3); }
.transcript {
font-size: 13px; color: var(--ink-2);
line-height: 1.5;
max-width: 62ch;
font-style: italic;
position: relative;
padding-left: 10px;
border-left: 2px solid var(--line-2);
.clip-size {
white-space: nowrap;
font-size: var(--text-small);
font-variant-numeric: tabular-nums;
}
.transcript .quote { opacity: 0.4; font-style: normal; }
.transcribing { display: flex; flex-direction: column; gap: 6px; max-width: 360px; }
.transcribe-bar { height: 3px; background: var(--line); border-radius: 2px; overflow: hidden; }
.transcribe-bar > div { height: 100%; background: var(--accent); animation: pulse 2s ease-in-out infinite alternate; }
@keyframes pulse { from { opacity: 0.7; } to { opacity: 1; } }
.transcribe-hint { font-size: 11px; color: var(--ink-3); }
.tags { display: flex; gap: 5px; margin-top: 2px; }
.tag { font-size: 10.5px; padding: 2px 7px; border-radius: 999px; background: var(--bg-2); color: var(--ink-2); border: 1px solid var(--line); }
/* REC */
.row-rec { display: flex; flex-direction: column; gap: 6px; }
.rec { padding: 10px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--bg-2); }
.rec-head { display: flex; align-items: center; gap: 8px; }
.rec-dot { width: 7px; height: 7px; border-radius: 50%; }
.rec-label { font-size: 12px; font-weight: 600; letter-spacing: -0.005em; }
.rec-reason { font-size: 11.5px; color: var(--ink-2); margin-top: 6px; line-height: 1.4; }
.rec.rec-good .rec-dot { background: var(--rec-good); }
.rec.rec-good { background: color-mix(in oklch, var(--rec-good) 8%, var(--panel)); border-color: color-mix(in oklch, var(--rec-good) 25%, var(--line)); }
.rec.rec-good .rec-label { color: oklch(0.45 0.10 150); }
.rec.rec-mid .rec-dot { background: var(--rec-mid); }
.rec.rec-mid .rec-label { color: oklch(0.48 0.09 85); }
.rec.rec-skip .rec-dot { background: var(--rec-skip); }
.rec.rec-skip .rec-label { color: var(--ink-3); }
.rec.rec-skip { opacity: 0.75; }
.row-grip { color: var(--ink-3); opacity: 0; transition: opacity 0.12s; cursor: grab; }
.row-grip svg { fill: currentColor; }
.row:hover .row-grip { opacity: 0.5; }
/* ===== STICKY FOOT ===== */
.sticky-foot {
position: fixed; bottom: 0; left: 280px; right: 0;
background: color-mix(in oklch, var(--panel) 90%, transparent);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-top: 1px solid var(--line);
padding: 16px 44px;
display: flex; justify-content: space-between; align-items: center; gap: 24px;
z-index: 10;
.clip-list li.excluded .clip-text,
.clip-list li.excluded .clip-time {
opacity: 0.5;
text-decoration: line-through;
}
.has-voice {
border-color: var(--tradeo-blue);
font-weight: 600;
}
.preview {
width: 100%;
max-height: 60vh;
background: #000;
border-radius: var(--radius);
}
/* --- Schnitt-Einstellungen --- */
.option {
display: flex;
gap: 12px;
align-items: flex-start;
cursor: pointer;
}
.option.disabled {
cursor: default;
}
.option-group {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.asset-field {
margin-left: 32px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2) var(--space-3);
}
.asset-field progress {
flex: 1 1 10rem;
}
.mixer {
display: grid;
gap: 12px;
}
.slider {
display: grid;
grid-template-columns: minmax(7rem, 11rem) 1fr 2.5rem;
align-items: center;
gap: 12px;
}
.slider label {
font-weight: 500;
}
.slider input {
width: 100%;
}
.slider output {
text-align: right;
font-variant-numeric: tabular-nums;
}
.slider.disabled {
opacity: 0.45;
}
.voice-over {
padding: var(--space-3);
border-radius: var(--radius);
background: var(--neutral-surface);
}
.countdown {
font-family: var(--font-heading);
font-size: 40px;
font-weight: 600;
text-align: center;
}
.recording {
color: var(--status-error);
font-weight: 600;
}
.blockers {
margin: 0;
padding-left: var(--space-5);
}
@media (max-width: 480px) {
.slider {
grid-template-columns: 1fr 2.5rem;
}
.slider label {
grid-column: 1 / -1;
}
.asset-field {
margin-left: 0;
}
.foot-info { display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 0; }
.foot-title { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); }
.foot-chain { display: flex; gap: 10px; align-items: center; overflow-x: auto; padding-bottom: 2px; }
.foot-chain::-webkit-scrollbar { height: 4px; }
.foot-chain::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: 2px; }
.foot-empty { font-size: 12.5px; color: var(--ink-3); }
.chain-clip {
display: flex; align-items: center; gap: 8px; padding: 4px 10px 4px 4px;
background: var(--bg-2); border: 1px solid var(--line); border-radius: 6px;
flex-shrink: 0;
}
.chain-thumb { width: 28px; height: 20px; border-radius: 3px; flex-shrink: 0; }
.chain-meta { display: flex; flex-direction: column; gap: 0; }
.chain-from { font-size: 11.5px; font-weight: 500; letter-spacing: -0.005em; }
.chain-dur { font-size: 10px; color: var(--ink-3); }
.chain-arrow { color: var(--ink-3); font-size: 12px; flex-shrink: 0; }
.foot-actions { display: flex; gap: 10px; flex-shrink: 0; }
+52
View File
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import { after, before, test } from 'node:test';
import { createApp } from '../server/app.js';
let server;
let baseUrl;
before(async () => {
const app = createApp({ config: { pin: '4711' }, secret: 'testgeheimnis' });
server = app.listen(0);
await new Promise((resolve) => server.once('listening', resolve));
baseUrl = `http://127.0.0.1:${server.address().port}`;
});
after(() => server.close());
const login = (pin) =>
fetch(`${baseUrl}/api/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pin }),
});
test('Healthcheck antwortet ohne Anmeldung', async () => {
const res = await fetch(`${baseUrl}/healthz`);
assert.equal(res.status, 200);
});
test('Geschützte Routen verlangen eine Anmeldung', async () => {
const res = await fetch(`${baseUrl}/api/irgendwas`);
assert.equal(res.status, 401);
});
test('Falsche PIN wird abgelehnt', async () => {
const res = await login('0000');
assert.equal(res.status, 401);
assert.equal(res.headers.get('set-cookie'), null);
});
test('Richtige PIN liefert ein Sitzungs-Cookie, das gilt', async () => {
const res = await login('4711');
assert.equal(res.status, 200);
const cookie = res.headers.get('set-cookie').split(';')[0];
assert.match(cookie, /^fdc_session=/);
assert.match(res.headers.get('set-cookie'), /HttpOnly/);
const session = await fetch(`${baseUrl}/api/session`, { headers: { cookie } });
assert.deepEqual(await session.json(), { authenticated: true, pinConfigured: true });
const protectedRes = await fetch(`${baseUrl}/api/irgendwas`, { headers: { cookie } });
assert.equal(protectedRes.status, 404);
});
+44
View File
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { createLoginLimiter, createToken, pinMatches, verifyToken } from '../server/auth.js';
test('Token ist mit Geheimnis und PIN gültig', () => {
const token = createToken('geheim', '1234');
assert.equal(verifyToken('geheim', '1234', token), true);
});
test('Token wird nach PIN-Wechsel ungültig', () => {
const token = createToken('geheim', '1234');
assert.equal(verifyToken('geheim', '9999', token), false);
});
test('Token mit fremdem Geheimnis oder manipuliertem Ablauf ist ungültig', () => {
const token = createToken('geheim', '1234');
assert.equal(verifyToken('anderes', '1234', token), false);
const [, signature] = token.split('.');
assert.equal(verifyToken('geheim', '1234', `${Date.now() + 1e12}.${signature}`), false);
});
test('Abgelaufenes Token ist ungültig', () => {
const token = createToken('geheim', '1234', 0);
assert.equal(verifyToken('geheim', '1234', token), false);
});
test('Ohne gesetzte PIN gilt kein Token und keine Eingabe', () => {
assert.equal(verifyToken('geheim', '', createToken('geheim', '')), false);
assert.equal(pinMatches('', ''), false);
});
test('PIN-Vergleich', () => {
assert.equal(pinMatches('1234', '1234'), true);
assert.equal(pinMatches('1234', '1235'), false);
assert.equal(pinMatches('1234', undefined), false);
});
test('Sperre nach zu vielen Fehlversuchen läuft ab', () => {
const limiter = createLoginLimiter({ maxFailures: 2, windowMs: 1000 });
limiter.recordFailure(0);
limiter.recordFailure(10);
assert.equal(limiter.isBlocked(500), true);
assert.equal(limiter.isBlocked(1500), false);
});
+66
View File
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { after, before, test } from 'node:test';
import { probe } from '../server/media.js';
const hasFfmpeg = (() => {
try {
execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
})();
let dir;
before(() => {
if (!hasFfmpeg) return;
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fdc-media-'));
const ffmpeg = (...args) => execFileSync('ffmpeg', ['-v', 'error', '-y', ...args]);
ffmpeg('-f', 'lavfi', '-i', 'testsrc=size=320x180:rate=25', '-f', 'lavfi', '-i', 'sine', '-t', '2',
'-c:v', 'libx264', '-c:a', 'aac', '-shortest', path.join(dir, 'quer.mp4'));
// Wie ein Hochkant-Handyvideo: quer gespeichert, mit Drehung im Dateikopf
ffmpeg('-i', path.join(dir, 'quer.mp4'), '-an', '-c', 'copy', '-metadata:s:v:0', 'rotate=90',
path.join(dir, 'hochkant.mp4'));
// Wie eine Browser-Aufnahme: WebM ohne Gesamtlänge im Kopf
ffmpeg('-f', 'lavfi', '-i', 'sine', '-t', '2', '-c:a', 'libopus', '-f', 'webm', '-live', '1',
path.join(dir, 'aufnahme.webm'));
});
after(() => {
if (dir) fs.rmSync(dir, { recursive: true, force: true });
});
test('ffprobe liefert Länge, Größe, Bildrate und Tonspur', { skip: !hasFfmpeg && 'ffmpeg fehlt' }, async () => {
const media = await probe(path.join(dir, 'quer.mp4'));
assert.ok(Math.abs(media.duration - 2) < 0.1);
assert.equal(media.width, 320);
assert.equal(media.height, 180);
assert.equal(media.fps, 25);
assert.equal(media.hasAudio, true);
});
test('Gedrehte Videos werden hochkant erkannt', { skip: !hasFfmpeg && 'ffmpeg fehlt' }, async () => {
const media = await probe(path.join(dir, 'hochkant.mp4'));
assert.equal(Math.abs(media.rotation), 90);
assert.equal(media.width, 180);
assert.equal(media.height, 320);
assert.equal(media.hasAudio, false);
});
test('Datei ohne Video wird abgelehnt', { skip: !hasFfmpeg && 'ffmpeg fehlt' }, async () => {
const file = path.join(dir, 'text.mp4');
fs.writeFileSync(file, 'kein Video');
await assert.rejects(probe(file));
});
test('Browser-Aufnahme ohne Längenangabe wird ausgemessen', { skip: !hasFfmpeg && 'ffmpeg fehlt' }, async () => {
const media = await probe(path.join(dir, 'aufnahme.webm'));
assert.equal(media.hasVideo, false);
assert.equal(media.hasAudio, true);
assert.ok(Math.abs(media.duration - 2) < 0.1, `Länge ${media.duration}`);
});
+251
View File
@@ -0,0 +1,251 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { after, before, test } from 'node:test';
import { createApp } from '../server/app.js';
import { createProjectStore } from '../server/projects.js';
import { createRenderQueue } from '../server/renderQueue.js';
let server;
let baseUrl;
let cookie;
let dataDir;
let releaseRender;
// Steuerbare Attrappe: meldet halben Fortschritt und wartet auf releaseRender().
async function fakeRender({ output, onProgress, signal }) {
onProgress(0.5);
await new Promise((resolve, reject) => {
releaseRender = resolve;
signal.addEventListener('abort', () => reject(new Error('Abgebrochen.')));
});
await fs.writeFile(output, 'VIDEO');
onProgress(1);
}
const fakeMedia = { duration: 42, hasVideo: true, width: 1080, height: 1920, rotation: 0, fps: 30, hasAudio: true };
before(async () => {
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fdc-test-'));
const probe = async (file) => {
const content = await fs.readFile(file, 'utf8');
if (content.startsWith('kaputt')) throw new Error('Keine Videospur gefunden.');
return fakeMedia;
};
const store = createProjectStore({ dataDir, probe });
const queue = createRenderQueue({ store, render: fakeRender });
const app = createApp({ config: { pin: '4711' }, secret: 'geheim', store, queue });
server = app.listen(0);
await new Promise((resolve) => server.once('listening', resolve));
baseUrl = `http://127.0.0.1:${server.address().port}`;
const res = await fetch(`${baseUrl}/api/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pin: '4711' }),
});
cookie = res.headers.get('set-cookie').split(';')[0];
});
after(async () => {
server.close();
await fs.rm(dataDir, { recursive: true, force: true });
});
function call(method, url, body, headers = {}) {
const isJson = body !== undefined && typeof body === 'object' && !(body instanceof Uint8Array);
return fetch(`${baseUrl}/api/${url}`, {
method,
headers: { cookie, ...(isJson ? { 'Content-Type': 'application/json' } : {}), ...headers },
body: isJson ? JSON.stringify(body) : body,
});
}
const upload = (id, index, offset, text) =>
call('PUT', `projects/${id}/clips/${index}?offset=${offset}`, new TextEncoder().encode(text), {
'Content-Type': 'application/octet-stream',
});
const clip = (file, size) => ({
file,
size,
timestamp: '2026-09-21T10:15:30',
sender: 'Anna',
caption: 'Ordnung',
});
async function createProject(clips) {
const res = await call('POST', 'projects', { range: { from: '2026-09-21', to: '2026-09-25' }, clips });
assert.equal(res.status, 201);
return res.json();
}
test('Projekt anlegen und abrufen', async () => {
const project = await createProject([clip('a.mp4', 10), clip('b.mov', 20)]);
assert.equal(project.clips.length, 2);
assert.equal(project.clips[0].status, 'pending');
assert.equal(project.clips[0].received, 0);
const res = await call('GET', `projects/${project.id}`);
assert.equal((await res.json()).clips[1].file, 'b.mov');
const list = await (await call('GET', 'projects')).json();
assert.ok(list.some((entry) => entry.id === project.id && entry.clipCount === 2));
});
test('Ungültige Clips werden abgelehnt', async () => {
for (const clips of [[], [clip('notiz.txt', 10)], [clip('a.mp4', 0)], [{ ...clip('a.mp4', 5), timestamp: 'gestern' }]]) {
const res = await call('POST', 'projects', { clips });
assert.equal(res.status, 400);
}
});
test('Upload in Stücken, Wiederaufnahme und Abschluss mit ffprobe', async () => {
const project = await createProject([clip('a.mp4', 10)]);
let res = await upload(project.id, 0, 0, 'abcd');
assert.deepEqual(await res.json(), { received: 4, status: 'pending' });
// Falscher Offset: der Server nennt den richtigen Stand
res = await upload(project.id, 0, 0, 'abcd');
assert.equal(res.status, 409);
assert.equal((await res.json()).received, 4);
res = await upload(project.id, 0, 4, 'efghij');
assert.deepEqual(await res.json(), { received: 10, status: 'ready' });
const stored = await (await call('GET', `projects/${project.id}`)).json();
assert.equal(stored.clips[0].status, 'ready');
assert.deepEqual(stored.clips[0].media, fakeMedia);
const file = await call('GET', `projects/${project.id}/clips/0/file`);
assert.equal(await file.text(), 'abcdefghij');
});
test('Mehr Daten als angekündigt werden abgewiesen, der gültige Anfang bleibt', async () => {
const project = await createProject([clip('a.mp4', 5)]);
await upload(project.id, 0, 0, 'ab');
const res = await upload(project.id, 0, 2, 'cdefgh');
assert.equal(res.status, 413);
const stored = await (await call('GET', `projects/${project.id}`)).json();
assert.equal(stored.clips[0].received, 2);
});
test('Datei ohne Videospur wird als Fehler markiert', async () => {
const project = await createProject([clip('a.mp4', 6)]);
const res = await upload(project.id, 0, 0, 'kaputt');
assert.equal((await res.json()).status, 'error');
const stored = await (await call('GET', `projects/${project.id}`)).json();
assert.equal(stored.clips[0].error, 'Keine Videospur gefunden.');
});
test('Unbekannte Projekte und Clips ergeben 404, auch bei manipulierten IDs', async () => {
assert.equal((await call('GET', 'projects/00000000-0000-0000-0000-000000000000')).status, 404);
assert.equal((await call('GET', 'projects/..%2F..%2Fetc')).status, 404);
const project = await createProject([clip('a.mp4', 5)]);
assert.equal((await upload(project.id, 7, 0, 'x')).status, 404);
assert.equal((await call('GET', `projects/${project.id}/clips/0/file`)).status, 404);
});
test('Projekt löschen entfernt die Dateien', async () => {
const project = await createProject([clip('a.mp4', 5)]);
await upload(project.id, 0, 0, 'ab');
assert.equal((await call('DELETE', `projects/${project.id}`)).status, 204);
assert.equal((await call('GET', `projects/${project.id}`)).status, 404);
await assert.rejects(fs.stat(path.join(dataDir, 'projects', project.id)));
});
test('Einstellungen speichern und prüfen', async () => {
const project = await createProject([clip('a.mp4', 5), clip('b.mp4', 5)]);
assert.equal(project.settings.muteClips, true);
let res = await call('PATCH', `projects/${project.id}/settings`, { music: { enabled: true, volume: 40 } });
const updated = await res.json();
assert.deepEqual(updated.settings.music, { enabled: true, volume: 40 });
res = await call('PATCH', `projects/${project.id}/settings`, { excluded: [5] });
assert.equal(res.status, 400);
});
test('Zusatzdateien anmelden, hochladen, ersetzen und löschen', async () => {
const project = await createProject([clip('a.mp4', 5)]);
const base = `projects/${project.id}/assets`;
// Falscher Dateityp für den Platz
assert.equal((await call('POST', `${base}/music`, { file: 'intro.txt', size: 4 })).status, 400);
// Unbekannter Platz und Voice-over zu einem Clip, den es nicht gibt
assert.equal((await call('POST', `${base}/abspann`, { file: 'a.mp3', size: 4 })).status, 404);
assert.equal((await call('POST', `${base}/voice-3`, { file: 'a.webm', size: 4 })).status, 404);
let res = await call('POST', `${base}/voice-0`, { file: 'aufnahme.webm', size: 4 });
assert.equal(res.status, 201);
assert.equal((await res.json()).assets['voice-0'].status, 'pending');
const put = (slot, offset, text) =>
call('PUT', `${base}/${slot}?offset=${offset}`, new TextEncoder().encode(text), {
'Content-Type': 'application/octet-stream',
});
res = await put('voice-0', 0, 'stim');
assert.equal((await res.json()).status, 'ready');
assert.equal(await (await call('GET', `${base}/voice-0/file`)).text(), 'stim');
// Ersetzen verwirft die alte Datei
await call('POST', `${base}/voice-0`, { file: 'neu.m4a', size: 3 });
const replaced = await (await call('GET', `projects/${project.id}`)).json();
assert.equal(replaced.assets['voice-0'].file, 'neu.m4a');
assert.equal(replaced.assets['voice-0'].received, 0);
const files = await fs.readdir(path.join(dataDir, 'projects', project.id, 'assets'));
assert.deepEqual(files, []);
res = await call('DELETE', `${base}/voice-0`);
assert.deepEqual((await res.json()).assets, {});
});
async function waitForRender(id, status) {
for (let i = 0; i < 100; i += 1) {
const project = await (await call('GET', `projects/${id}`)).json();
if (project.render.status === status) return project;
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error(`Status ${status} nicht erreicht`);
}
test('Export ohne fertige Clips wird abgelehnt', async () => {
const project = await createProject([clip('a.mp4', 5)]);
assert.equal(project.render.status, 'idle');
assert.equal((await call('POST', `projects/${project.id}/render`)).status, 400);
assert.equal((await call('GET', `projects/${project.id}/output`)).status, 404);
});
test('Export läuft, meldet Fortschritt, liefert die Datei und erkennt Änderungen', async () => {
const project = await createProject([clip('a.mp4', 5)]);
await upload(project.id, 0, 0, 'abcde');
assert.equal((await call('POST', `projects/${project.id}/render`)).status, 202);
const running = await waitForRender(project.id, 'running');
assert.equal(running.render.progress, 0.5);
assert.equal((await call('POST', `projects/${project.id}/render`)).status, 409);
releaseRender();
const done = await waitForRender(project.id, 'done');
assert.equal(done.render.outdated, false);
const download = await call('GET', `projects/${project.id}/output?download=1`);
assert.equal(await download.text(), 'VIDEO');
assert.match(download.headers.get('content-disposition'), /FuerDieCuts_2026-09-21_bis_2026-09-25\.mp4/);
const changed = await (await call('PATCH', `projects/${project.id}/settings`, { speedUp: false })).json();
assert.equal(changed.render.outdated, true);
});
test('Laufender Export lässt sich abbrechen', async () => {
const project = await createProject([clip('a.mp4', 5)]);
await upload(project.id, 0, 0, 'abcde');
await call('POST', `projects/${project.id}/render`);
await waitForRender(project.id, 'running');
assert.equal((await call('DELETE', `projects/${project.id}/render`)).status, 202);
const cancelled = await waitForRender(project.id, 'cancelled');
assert.equal(cancelled.render.error, null);
assert.equal((await call('DELETE', `projects/${project.id}/render`)).status, 404);
});
+200
View File
@@ -0,0 +1,200 @@
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { after, before, test } from 'node:test';
import { probe } from '../server/media.js';
import { planRender, renderPlan, segmentArgs } from '../server/render.js';
import { defaultSettings } from '../server/settings.js';
const media = (duration, hasAudio = true) => ({ duration, hasVideo: true, hasAudio, width: 320, height: 180 });
function fakeProject(settings = {}) {
return {
clips: [
{ index: 0, file: 'a.mp4', size: 10, status: 'ready', media: media(95) },
{ index: 1, file: 'b.mp4', size: 20, status: 'ready', media: media(4, false) },
{ index: 2, file: 'c.mp4', size: 30, status: 'ready', media: media(30) },
{ index: 3, file: 'd.mp4', size: 40, status: 'pending' },
],
assets: {
intro: { status: 'ready', revision: 'i1', media: media(3) },
music: { status: 'ready', revision: 'm1', media: { duration: 60, hasAudio: true } },
'voice-2': { status: 'ready', revision: 'v1', media: { duration: 8, hasAudio: true } },
},
settings: { ...defaultSettings(), ...settings },
};
}
test('Lange Clips werden auf 10 Sekunden beschleunigt, kurze bleiben', () => {
const plan = planRender(fakeProject());
assert.deepEqual(plan.clips.map((clip) => clip.index), [0, 1, 2]);
assert.equal(plan.clips[0].speed, 9.5);
assert.equal(plan.clips[0].outDuration, 10);
assert.equal(plan.clips[1].speed, 1);
assert.equal(plan.clips[1].outDuration, 4);
assert.equal(plan.clipsDuration, 24);
});
test('Ohne Beschleunigen behalten Clips ihre Länge', () => {
const plan = planRender(fakeProject({ speedUp: false }));
assert.equal(plan.clips[0].outDuration, 95);
});
test('Stummgeschaltete Clips haben keinen eigenen Ton, Intro und Outro schon', () => {
const plan = planRender(fakeProject({ intro: { enabled: true, volume: 80 } }));
assert.ok(plan.clips.every((clip) => clip.audio === null));
assert.deepEqual(plan.intro.audio, { volume: 0.8, tempo: 1 });
});
test('Mit Ton wird der Clip-Ton im selben Tempo beschleunigt', () => {
const plan = planRender(fakeProject({ muteClips: false, clipVolume: 50 }));
assert.deepEqual(plan.clips[0].audio, { volume: 0.5, tempo: 9.5 });
assert.equal(plan.clips[1].audio, null, 'Clip ohne Tonspur');
const args = segmentArgs(plan.clips[0], { video: 'in.mp4' }, 'out.mov').join(' ');
assert.match(args, /atempo=2,atempo=2,atempo=2,atempo=1\.187500/);
});
test('Voice-over-Lautstärke ist Gesamtregler mal Einzelregler', () => {
const plan = planRender(fakeProject({ voiceVolume: 50, voiceVolumes: { 2: 80 } }));
assert.equal(plan.clips[2].voice.volume, 0.4);
assert.equal(plan.clips[0].voice, null);
});
test('Ausgeschlossene Clips und fehlende Dateien fallen raus', () => {
const plan = planRender(fakeProject({ excluded: [0], intro: { enabled: true, volume: 100 } }));
assert.deepEqual(plan.clips.map((clip) => clip.index), [1, 2]);
assert.equal(plan.duration, 3 + 4 + 10);
const withoutAsset = fakeProject({ outro: { enabled: true, volume: 100 } });
assert.equal(planRender(withoutAsset).outro, null);
});
test('Musik läuft nur unter den Clips', () => {
const plan = planRender(fakeProject({ music: { enabled: true, volume: 30 }, intro: { enabled: true, volume: 100 } }));
assert.equal(plan.music.duration, 24);
assert.equal(plan.music.volume, 0.3);
});
test('Musiklautstärke ändert keine Clip-Segmente, Clip-Lautstärke im Stumm-Modus auch nicht', () => {
const base = planRender(fakeProject({ music: { enabled: true, volume: 30 } }));
const louder = planRender(fakeProject({ music: { enabled: true, volume: 90 }, clipVolume: 20 }));
assert.deepEqual(louder.clips.map((c) => c.key), base.clips.map((c) => c.key));
assert.notEqual(louder.key, base.key);
const unmuted = planRender(fakeProject({ muteClips: false }));
assert.notEqual(unmuted.clips[0].key, base.clips[0].key);
});
// --- Echter Schnitt mit kleinen Testvideos ---
const hasFfmpeg = (() => {
try {
execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
})();
let dir;
const files = {};
const ffmpeg = (...args) => execFileSync('ffmpeg', ['-v', 'error', '-y', ...args]);
// Mittlere Lautstärke eines Ausschnitts in dB (volumedetect schreibt auf stderr)
function meanVolumeOf(file, start, duration) {
const { stderr } = spawnSync(
'ffmpeg',
['-v', 'info', '-ss', String(start), '-t', String(duration), '-i', file, '-af', 'volumedetect', '-f', 'null', '-'],
{ encoding: 'utf8' },
);
const match = /mean_volume: (-?[\d.]+|-inf) dB/.exec(stderr);
if (!match) throw new Error(`Keine Lautstärke gemessen: ${stderr.slice(-300)}`);
return match[1] === '-inf' ? -Infinity : Number(match[1]);
}
before(() => {
if (!hasFfmpeg) return;
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fdc-render-'));
const file = (name) => (files[name] = path.join(dir, name));
// 12 s Hochkant wie vom Handy (quer gespeichert, gedreht), mit Ton
ffmpeg('-f', 'lavfi', '-i', 'testsrc=size=320x180:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=500',
'-t', '12', '-c:v', 'libx264', '-preset', 'ultrafast', '-c:a', 'aac', '-shortest', file('raw.mp4'));
ffmpeg('-i', files['raw.mp4'], '-c', 'copy', '-metadata:s:v:0', 'rotate=90', file('clip0.mp4'));
// 4 s quer, ohne Ton
ffmpeg('-f', 'lavfi', '-i', 'testsrc2=size=320x180:rate=25', '-t', '4', '-c:v', 'libx264', '-preset', 'ultrafast', file('clip1.mp4'));
// 2 s Intro mit Ton
ffmpeg('-f', 'lavfi', '-i', 'color=c=blue:size=320x180:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=800',
'-t', '2', '-c:v', 'libx264', '-preset', 'ultrafast', '-c:a', 'aac', '-shortest', file('intro.mp4'));
// 3 s Musik (muss wiederholt werden) und 3 s Voice-over
ffmpeg('-f', 'lavfi', '-i', 'sine=frequency=300', '-t', '3', file('music.mp3'));
ffmpeg('-f', 'lavfi', '-i', 'sine=frequency=1000', '-t', '3', '-c:a', 'libopus', file('voice.webm'));
});
after(() => {
if (dir) fs.rmSync(dir, { recursive: true, force: true });
});
async function realProject(settings) {
return {
clips: [
{ index: 0, file: 'clip0.mp4', size: 1, status: 'ready', media: await probe(files['clip0.mp4']) },
{ index: 1, file: 'clip1.mp4', size: 1, status: 'ready', media: await probe(files['clip1.mp4']) },
],
assets: {
intro: { status: 'ready', revision: 'i', media: await probe(files['intro.mp4']) },
music: { status: 'ready', revision: 'm', media: await probe(files['music.mp3']) },
'voice-1': { status: 'ready', revision: 'v', media: await probe(files['voice.webm']) },
},
settings: { ...defaultSettings(), ...settings },
};
}
const resolve = (target) =>
target.type === 'clip'
? files[`clip${target.index}.mp4`]
: { intro: files['intro.mp4'], music: files['music.mp3'], 'voice-1': files['voice.webm'] }[target.slot];
test('Schnitt: Intro, beschleunigter Hochkant-Clip, Voice-over und Musik', { skip: !hasFfmpeg && 'ffmpeg fehlt', timeout: 180000 }, async () => {
const workDir = path.join(dir, 'work');
const output = path.join(dir, 'out.mp4');
const project = await realProject({
intro: { enabled: true, volume: 100 },
music: { enabled: true, volume: 60 },
});
const progress = [];
await renderPlan({ plan: planRender(project), resolve, workDir, output, onProgress: (p) => progress.push(p) });
const result = await probe(output);
// 2 s Intro + 12 s auf 10 s beschleunigt + 4 s
assert.ok(Math.abs(result.duration - 16) < 0.2, `Länge ${result.duration}`);
assert.equal(result.width, 1920);
assert.equal(result.height, 1080);
assert.equal(result.hasAudio, true);
assert.equal(progress.at(-1), 1);
assert.ok(progress.every((value, i) => i === 0 || value >= progress[i - 1]), 'Fortschritt steigt');
// Intro hat Ton, stummgeschalteter Clip trägt nur Musik bzw. Voice-over
assert.ok(meanVolumeOf(output, 0.5, 1) > -40, 'Intro hörbar');
assert.ok(meanVolumeOf(output, 3, 2) > -40, 'Musik unter dem stummen Clip hörbar');
assert.ok(meanVolumeOf(output, 12.5, 1.5) > -40, 'Voice-over hörbar');
// Zweiter Lauf mit anderer Musiklautstärke rechnet die Segmente nicht neu
const cacheDir = path.join(workDir, 'cache');
const before = new Map(fs.readdirSync(cacheDir).map((f) => [f, fs.statSync(path.join(cacheDir, f)).mtimeMs]));
assert.equal(before.size, 3);
project.settings.music.volume = 20;
await renderPlan({ plan: planRender(project), resolve, workDir, output });
for (const [name, mtime] of before) {
assert.equal(fs.statSync(path.join(cacheDir, name)).mtimeMs, mtime, `${name} neu gerechnet`);
}
});
test('Schnitt: ohne Musik und Voice-over sind stummgeschaltete Clips still', { skip: !hasFfmpeg && 'ffmpeg fehlt', timeout: 180000 }, async () => {
const output = path.join(dir, 'still.mp4');
const project = await realProject({});
delete project.assets['voice-1'];
await renderPlan({ plan: planRender(project), resolve, workDir: path.join(dir, 'work2'), output });
const result = await probe(output);
assert.ok(Math.abs(result.duration - 14) < 0.2, `Länge ${result.duration}`);
assert.ok(meanVolumeOf(output, 1, 10) < -80, 'still');
});
+46
View File
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { defaultSettings, mergeSettings } from '../server/settings.js';
test('Vorgaben: beschleunigen und stummschalten an, Intro, Outro und Musik aus', () => {
const settings = defaultSettings();
assert.equal(settings.speedUp, true);
assert.equal(settings.muteClips, true);
assert.equal(settings.intro.enabled, false);
assert.equal(settings.music.volume, 60);
});
test('Teiländerungen lassen den Rest stehen', () => {
const merged = mergeSettings(defaultSettings(), { music: { volume: 35 } }, 5);
assert.equal(merged.music.volume, 35);
assert.equal(merged.music.enabled, false);
assert.equal(merged.intro.volume, 100);
});
test('Voice-over-Lautstärke pro Clip und ausgeschlossene Clips', () => {
const merged = mergeSettings(defaultSettings(), { voiceVolumes: { 2: 80 }, excluded: [3, 1, 3] }, 5);
assert.deepEqual(merged.voiceVolumes, { 2: 80 });
assert.deepEqual(merged.excluded, [1, 3]);
});
test('Ungültige Werte werden abgelehnt', () => {
const invalid = [
{ music: { volume: 101 } },
{ clipVolume: -1 },
{ clipVolume: 50.5 },
{ speedUp: 'ja' },
{ voiceVolumes: { 9: 50 } },
{ excluded: [7] },
[],
];
for (const patch of invalid) {
assert.throws(() => mergeSettings(defaultSettings(), patch, 5), { status: 400 });
}
});
test('Unbekannte Felder werden ignoriert', () => {
const merged = mergeSettings(defaultSettings(), { hacker: true, intro: { enabled: true, file: '/etc/passwd' } }, 1);
assert.equal(merged.hacker, undefined);
assert.equal(merged.intro.file, undefined);
assert.equal(merged.intro.enabled, true);
});
+169
View File
@@ -0,0 +1,169 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
clipsInRange,
collectClips,
parseChat,
previousWorkWeek,
timestampFromFileName,
} from '../src/lib/whatsapp.js';
// Ausgedachte Zeilen im Format eines deutschen iPhone-Exports mit Medien,
// samt der unsichtbaren Richtungszeichen (\u200e), die WhatsApp einfügt.
const IOS_CHAT = [
'[21.09.26, 09:00:00] Anna Beispiel: Guten Morgen',
'\u200e[21.09.26, 10:15:30] Anna Beispiel: Ordnung am Platz \u200e<Anhang: 00000101-VIDEO-2026-09-21-10-15-30.mp4>',
'[21.09.26, 10:16:00] Ben Muster: Stark!',
'Zweite Zeile der Nachricht',
'\u200e[22.09.26, 08:00:00] ~\u202fCarla: \u200e<Anhang: 00000102-VIDEO-2026-09-22-08-00-00.mp4>',
// Außer der Reihe: im Chat später, laut Zeitstempel aber früher
'\u200e[21.09.26, 07:30:00] Ben Muster: Frühaufsteher \u200e<Anhang: 00000103-VIDEO-2026-09-21-07-30-00.mp4>',
'\u200e[22.09.26, 08:00:05] ~\u202fCarla: \u200e<Anhang: 00000104-PHOTO-2026-09-22-08-00-05.jpg>',
'[22.09.26, 09:00:00] Ben Muster: \u200eDiese Nachricht wurde gelöscht.',
'\u200e[23.09.26, 11:00:00] Anna Beispiel: Fehlt im Export \u200e<Anhang: 00000105-VIDEO-2026-09-23-11-00-00.mp4>',
'\u200e[28.09.26, 12:00:00] Anna Beispiel: Nächste Woche \u200e<Anhang: 00000106-VIDEO-2026-09-28-12-00-00.mp4>',
].join('\r\n');
const IOS_FILES = [
{ name: '_chat.txt', size: 1000 },
{ name: 'WhatsApp Chat/00000101-VIDEO-2026-09-21-10-15-30.mp4', size: 5000 },
{ name: 'WhatsApp Chat/00000102-VIDEO-2026-09-22-08-00-00.mp4', size: 7000 },
{ name: 'WhatsApp Chat/00000103-VIDEO-2026-09-21-07-30-00.mp4', size: 6000 },
{ name: 'WhatsApp Chat/00000104-PHOTO-2026-09-22-08-00-05.jpg', size: 100 },
{ name: 'WhatsApp Chat/00000106-VIDEO-2026-09-28-12-00-00.mp4', size: 8000 },
// Im Chat nicht erwähnt, Zeitpunkt nur aus dem Dateinamen
{ name: 'WhatsApp Chat/00000107-VIDEO-2026-09-22-15-00-00.mp4', size: 9000 },
];
test('iPhone-Export: Nachrichten, Absender, Zeitstempel und Anhänge', () => {
const messages = parseChat(IOS_CHAT);
assert.equal(messages.length, 9);
assert.deepEqual(messages[1], {
index: 1,
timestamp: '2026-09-21T10:15:30',
sender: 'Anna Beispiel',
text: 'Ordnung am Platz <Anhang: 00000101-VIDEO-2026-09-21-10-15-30.mp4>',
attachments: ['00000101-VIDEO-2026-09-21-10-15-30.mp4'],
});
assert.equal(messages[2].text, 'Stark!\nZweite Zeile der Nachricht');
assert.equal(messages[3].sender, '~ Carla');
assert.deepEqual(messages[6].attachments, []);
});
test('Videos folgen der Chatreihenfolge, Videos ohne Chatzeile dem Zeitstempel', () => {
const { clips } = collectClips(parseChat(IOS_CHAT), IOS_FILES);
assert.deepEqual(
clips.map((clip) => clip.file),
[
'00000101-VIDEO-2026-09-21-10-15-30.mp4',
'00000102-VIDEO-2026-09-22-08-00-00.mp4',
'00000103-VIDEO-2026-09-21-07-30-00.mp4',
'00000107-VIDEO-2026-09-22-15-00-00.mp4',
'00000106-VIDEO-2026-09-28-12-00-00.mp4',
],
);
});
test('Ein falsch nachgestempeltes Video bleibt an seinem Platz im Chat', () => {
// Wie im echten Export: WhatsApp hat das mittlere Video auf den 19. umgestempelt.
const chat = [
'\u200e[17.08.26, 09:48:18] Anna: Eins \u200e<Anhang: 00001338-VIDEO-2026-08-17-09-48-18.mp4>',
'\u200e[19.08.26, 13:17:37] Ben: Zwei \u200e<Anhang: 00001339-VIDEO-2026-08-19-13-17-37.mp4>',
'\u200e[17.08.26, 09:52:15] Carla: Drei \u200e<Anhang: 00001340-VIDEO-2026-08-17-09-52-15.mp4>',
].join('\n');
const files = [
{ name: '00001338-VIDEO-2026-08-17-09-48-18.mp4', size: 1 },
{ name: '00001339-VIDEO-2026-08-19-13-17-37.mp4', size: 2 },
{ name: '00001340-VIDEO-2026-08-17-09-52-15.mp4', size: 3 },
];
const { clips } = collectClips(parseChat(chat), files);
assert.deepEqual(clips.map((clip) => clip.caption), ['Eins', 'Zwei', 'Drei']);
});
test('Clips tragen Absender, Beschriftung und Pfad', () => {
const { clips } = collectClips(parseChat(IOS_CHAT), IOS_FILES);
const clip = clips.find((c) => c.file.startsWith('00000101'));
assert.equal(clip.sender, 'Anna Beispiel');
assert.equal(clip.caption, 'Ordnung am Platz');
assert.equal(clip.path, 'WhatsApp Chat/00000101-VIDEO-2026-09-21-10-15-30.mp4');
const orphan = clips.find((c) => c.file.startsWith('00000107'));
assert.equal(orphan.sender, null);
});
test('Im Chat genannte, aber fehlende Videos werden gemeldet', () => {
const { missing } = collectClips(parseChat(IOS_CHAT), IOS_FILES);
assert.deepEqual(missing.map((m) => m.file), ['00000105-VIDEO-2026-09-23-11-00-00.mp4']);
});
test('Bytegleiche Videos werden als Duplikat markiert', () => {
const chat = [
'\u200e[23.09.26, 10:03:49] Tom: Autostart \u200e<Anhang: 00000201-VIDEO-2026-09-23-10-03-49.mp4>',
'\u200e[23.09.26, 10:03:53] Tom: Autostart \u200e<Anhang: 00000202-VIDEO-2026-09-23-10-03-53.mp4>',
].join('\n');
const files = [
{ name: '00000201-VIDEO-2026-09-23-10-03-49.mp4', size: 4242 },
{ name: '00000202-VIDEO-2026-09-23-10-03-53.mp4', size: 4242 },
];
const { clips } = collectClips(parseChat(chat), files);
assert.equal(clips[0].duplicateOf, undefined);
assert.equal(clips[1].duplicateOf, '00000201-VIDEO-2026-09-23-10-03-49.mp4');
});
test('Android-Export (deutsch) mit Systemmeldung', () => {
const chat = [
'21.09.26, 10:15 - Anna: VID-20260921-WA0003.mp4 (Datei angehängt)',
'Ordnung am Platz',
'21.09.26, 10:20 - Ben hat die Gruppe verlassen',
'21.09.26, 11:00 - Ben: Hallo',
].join('\n');
const messages = parseChat(chat);
assert.equal(messages.length, 3);
assert.equal(messages[0].timestamp, '2026-09-21T10:15:00');
assert.deepEqual(messages[0].attachments, ['VID-20260921-WA0003.mp4']);
assert.equal(messages[1].sender, null);
const { clips } = collectClips(messages, [{ name: 'VID-20260921-WA0003.mp4', size: 1 }]);
assert.equal(clips[0].caption, 'Ordnung am Platz');
});
test('Englischer Export mit Monat zuerst und AM/PM', () => {
const messages = parseChat(
[
'9/21/26, 4:36\u202fPM - Anna: VID-20260921-WA0001.mp4 (file attached)',
'[9/22/26, 12:05:00 AM] Ben: <attached: 00000001-VIDEO-2026-09-22-00-05-00.mp4>',
].join('\n'),
);
assert.equal(messages[0].timestamp, '2026-09-21T16:36:00');
assert.equal(messages[1].timestamp, '2026-09-22T00:05:00');
assert.deepEqual(messages[1].attachments, ['00000001-VIDEO-2026-09-22-00-05-00.mp4']);
});
test('Schrägstrich-Datum mit Tag über 12 gilt als Tag zuerst', () => {
const messages = parseChat('24/09/2026, 16:36 - Anna: Hallo\n05/09/2026, 10:00 - Ben: Hi');
assert.equal(messages[0].timestamp, '2026-09-24T16:36:00');
assert.equal(messages[1].timestamp, '2026-09-05T10:00:00');
});
test('Zeitstempel aus Dateinamen', () => {
assert.equal(timestampFromFileName('00001338-VIDEO-2026-09-24-16-36-18.mp4'), '2026-09-24T16:36:18');
assert.equal(timestampFromFileName('VID-20260924-WA0012.mp4'), '2026-09-24T00:00:00');
assert.equal(timestampFromFileName('film.mp4'), null);
});
test('Zeitraumfilter schließt beide Tage ein', () => {
const { clips } = collectClips(parseChat(IOS_CHAT), IOS_FILES);
const week = clipsInRange(clips, '2026-09-21', '2026-09-25');
assert.equal(week.length, 4);
assert.ok(week.every((clip) => clip.timestamp < '2026-09-26'));
});
test('Vergangene Woche ist Montag bis Freitag der Vorwoche', () => {
// Dienstag, 29.09.2026
assert.deepEqual(previousWorkWeek(new Date(2026, 8, 29)), { from: '2026-09-21', to: '2026-09-25' });
// Montag, 28.09.2026
assert.deepEqual(previousWorkWeek(new Date(2026, 8, 28)), { from: '2026-09-21', to: '2026-09-25' });
// Sonntag, 27.09.2026 gehört noch zur Woche ab 21.09.
assert.deepEqual(previousWorkWeek(new Date(2026, 8, 27)), { from: '2026-09-14', to: '2026-09-18' });
// Über den Jahreswechsel
assert.deepEqual(previousWorkWeek(new Date(2027, 0, 5)), { from: '2026-12-28', to: '2027-01-01' });
});