Compare commits
13
Commits
278d47c793
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cb7ce9555 | ||
|
|
ccd70bd585 | ||
|
|
18d5b80959 | ||
|
|
6e497ffc45 | ||
|
|
03bfda09b2 | ||
|
|
72e4b8ab7a | ||
|
|
d7102d04a4 | ||
|
|
782d306cb1 | ||
|
|
6f583c8f5b | ||
|
|
ce84e1103d | ||
|
|
899b212cba | ||
|
|
f7d249f28b | ||
|
|
8ed1396b1c |
No files matched your search
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
data
|
||||
.git
|
||||
.env
|
||||
.vch
|
||||
samples
|
||||
@@ -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
|
||||
@@ -3,3 +3,5 @@ dist/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
data/
|
||||
samples/
|
||||
+1
-1
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
runtime: docker
|
||||
port: 3000
|
||||
health: /healthz
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Generated
+3823
-119
File diff suppressed because it is too large.
Load diff
+11
-4
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+33
-550
@@ -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>
|
||||
|
||||
<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>
|
||||
<header className="topbar">
|
||||
<button type="button" className="brand" onClick={home}>FuerDieCuts</button>
|
||||
<button type="button" className="secondary" onClick={logout}>Abmelden</button>
|
||||
</header>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { api } from './api.js'
|
||||
import { formatDuration } from './lib/format.js'
|
||||
import { uploadAsset } from './lib/upload.js'
|
||||
|
||||
// Datei für Intro, Outro oder Backing Track wählen, ersetzen oder entfernen.
|
||||
export default function AssetField({ project, slot, label, accept, onProject }) {
|
||||
const input = useRef(null)
|
||||
const [progress, setProgress] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const asset = project.assets[slot]
|
||||
const busy = progress !== null
|
||||
|
||||
async function pick(event) {
|
||||
const file = event.target.files[0]
|
||||
event.target.value = ''
|
||||
if (!file) return
|
||||
setError('')
|
||||
setProgress(0)
|
||||
try {
|
||||
onProject(await uploadAsset({ projectId: project.id, slot, file, onProgress: setProgress }))
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setProgress(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
setError('')
|
||||
try {
|
||||
onProject(await api(`projects/${project.id}/assets/${slot}`, { method: 'DELETE' }))
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="asset-field">
|
||||
{busy && (
|
||||
<progress max="1" value={progress} aria-label={`${label} wird hochgeladen`}>
|
||||
{Math.round(progress * 100)} %
|
||||
</progress>
|
||||
)}
|
||||
{!busy && asset?.status === 'ready' && (
|
||||
<span>
|
||||
{asset.file} <span className="muted">· {formatDuration(asset.media.duration)}</span>
|
||||
</span>
|
||||
)}
|
||||
{!busy && asset?.status === 'error' && <span className="error">{asset.error}</span>}
|
||||
{!busy && asset?.status === 'pending' && <span className="muted">Upload unvollständig</span>}
|
||||
{!busy && !asset && <span className="muted">Keine Datei</span>}
|
||||
<div className="actions">
|
||||
<button type="button" className="secondary small" onClick={() => input.current.click()} disabled={busy}>
|
||||
{asset ? 'Ersetzen' : 'Datei wählen'}
|
||||
</button>
|
||||
{asset && (
|
||||
<button type="button" className="secondary small" onClick={remove} disabled={busy}>
|
||||
Entfernen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input ref={input} type="file" accept={accept} hidden onChange={pick} aria-label={`${label}-Datei`} />
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import AssetField from './AssetField.jsx'
|
||||
import Slider from './Slider.jsx'
|
||||
import { formatDuration } from './lib/format.js'
|
||||
import { TARGET_SECONDS, estimateDuration, includedClips } from './lib/plan.js'
|
||||
|
||||
function Option({ checked, onChange, disabled = false, children, hint }) {
|
||||
return (
|
||||
<label className={`option${disabled ? ' disabled' : ''}`}>
|
||||
<input type="checkbox" checked={checked} disabled={disabled} onChange={(event) => onChange?.(event.target.checked)} />
|
||||
<span>
|
||||
{children}
|
||||
{hint && <span className="muted"> · {hint}</span>}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CutSettings({ project, settings, onSettings, onProject }) {
|
||||
const clipCount = includedClips(project, settings).length
|
||||
|
||||
return (
|
||||
<section className="card stack">
|
||||
<div className="heading-row">
|
||||
<h2>Automatisch bearbeiten</h2>
|
||||
<span className="muted">
|
||||
{clipCount} Clips · Länge etwa {formatDuration(estimateDuration(project, settings))} min
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="option-group">
|
||||
<Option checked={settings.intro.enabled} onChange={(enabled) => onSettings({ intro: { enabled } })}>
|
||||
Intro einfügen
|
||||
</Option>
|
||||
{settings.intro.enabled && (
|
||||
<AssetField project={project} slot="intro" label="Intro" accept="video/*" onProject={onProject} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="option-group">
|
||||
<Option checked={settings.outro.enabled} onChange={(enabled) => onSettings({ outro: { enabled } })}>
|
||||
Outro einfügen
|
||||
</Option>
|
||||
{settings.outro.enabled && (
|
||||
<AssetField project={project} slot="outro" label="Outro" accept="video/*" onProject={onProject} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Option checked disabled hint="immer aktiv">
|
||||
Videos in Chat-Reihenfolge aneinanderreihen
|
||||
</Option>
|
||||
|
||||
<Option checked={settings.speedUp} onChange={(speedUp) => onSettings({ speedUp })}>
|
||||
Videos kürzen: jedes auf {TARGET_SECONDS} Sekunden beschleunigen
|
||||
</Option>
|
||||
|
||||
<Option
|
||||
checked={settings.muteClips}
|
||||
onChange={(muteClips) => onSettings({ muteClips })}
|
||||
hint="Intro und Outro behalten ihren Ton"
|
||||
>
|
||||
Ton der Videos stummschalten
|
||||
</Option>
|
||||
|
||||
<div className="option-group">
|
||||
<Option
|
||||
checked={settings.music.enabled}
|
||||
onChange={(enabled) => onSettings({ music: { enabled } })}
|
||||
hint="läuft nur unter den Clips, wird wiederholt und am Ende ausgeblendet"
|
||||
>
|
||||
Backing Track unterlegen
|
||||
</Option>
|
||||
{settings.music.enabled && (
|
||||
<AssetField project={project} slot="music" label="Backing Track" accept="audio/*" onProject={onProject} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2>Lautstärke</h2>
|
||||
<div className="mixer">
|
||||
<Slider
|
||||
label="Intro"
|
||||
value={settings.intro.volume}
|
||||
disabled={!settings.intro.enabled}
|
||||
onChange={(volume) => onSettings({ intro: { volume } })}
|
||||
/>
|
||||
<Slider
|
||||
label="Outro"
|
||||
value={settings.outro.volume}
|
||||
disabled={!settings.outro.enabled}
|
||||
onChange={(volume) => onSettings({ outro: { volume } })}
|
||||
/>
|
||||
<Slider
|
||||
label="Ton der Videos"
|
||||
value={settings.clipVolume}
|
||||
disabled={settings.muteClips}
|
||||
onChange={(clipVolume) => onSettings({ clipVolume })}
|
||||
/>
|
||||
<Slider
|
||||
label="Backing Track"
|
||||
value={settings.music.volume}
|
||||
disabled={!settings.music.enabled}
|
||||
onChange={(volume) => onSettings({ music: { volume } })}
|
||||
/>
|
||||
<Slider
|
||||
label="Voice-over (alle)"
|
||||
value={settings.voiceVolume}
|
||||
onChange={(voiceVolume) => onSettings({ voiceVolume })}
|
||||
/>
|
||||
</div>
|
||||
<p className="muted">Voice-overs nimmst du unten in der Clip-Liste pro Video auf.</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { formatDuration } from './lib/format.js'
|
||||
|
||||
// Export starten, Fortschritt zeigen, fertiges Video ansehen und herunterladen.
|
||||
export default function RenderPanel({ project, blockers, busy, onStart, onCancel }) {
|
||||
const render = project.render
|
||||
const active = render.status === 'queued' || render.status === 'running'
|
||||
const percent = Math.round((render.progress ?? 0) * 100)
|
||||
|
||||
return (
|
||||
<section className="card stack">
|
||||
<h2>Video erstellen</h2>
|
||||
|
||||
{blockers.length > 0 && !active && (
|
||||
<ul className="warning blockers">
|
||||
{blockers.map((blocker) => (
|
||||
<li key={blocker}>{blocker}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{render.status === 'queued' && <p aria-live="polite">Wartet, bis ein anderer Export fertig ist …</p>}
|
||||
{render.status === 'running' && (
|
||||
<div className="progress-block">
|
||||
<progress max="100" value={percent} aria-label="Export-Fortschritt">
|
||||
{percent} %
|
||||
</progress>
|
||||
<p className="muted" aria-live="polite">
|
||||
{percent} % · Ein Best-of mit 40 Clips braucht einige Minuten. Du kannst die Seite
|
||||
offen lassen oder später wiederkommen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{render.status === 'error' && (
|
||||
<p className="error" role="alert">
|
||||
Export fehlgeschlagen: {render.error}
|
||||
</p>
|
||||
)}
|
||||
{render.status === 'cancelled' && <p className="muted">Export abgebrochen.</p>}
|
||||
|
||||
{render.status === 'done' && (
|
||||
<div className="stack-tight">
|
||||
{render.outdated && (
|
||||
<p className="warning" role="status">
|
||||
Seit diesem Export wurde etwas geändert. Erstelle das Video neu, damit die Änderungen
|
||||
drin sind.
|
||||
</p>
|
||||
)}
|
||||
<video
|
||||
className="preview"
|
||||
src={`api/projects/${project.id}/output?v=${encodeURIComponent(render.finishedAt)}`}
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
<div className="actions">
|
||||
<a className="button" href={`api/projects/${project.id}/output?download=1`} download>
|
||||
Herunterladen
|
||||
</a>
|
||||
<span className="muted">Länge {formatDuration(render.duration)} min · MP4, 1920×1080</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="actions">
|
||||
{active ? (
|
||||
<button type="button" className="secondary" onClick={onCancel} disabled={busy}>
|
||||
Abbrechen
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={render.status === 'done' && !render.outdated ? 'secondary' : ''}
|
||||
onClick={onStart}
|
||||
disabled={busy || blockers.length > 0}
|
||||
>
|
||||
{render.status === 'done' ? 'Neu erstellen' : 'Video erstellen'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useId } from 'react'
|
||||
|
||||
export default function Slider({ label, value, onChange, disabled = false }) {
|
||||
const id = useId()
|
||||
return (
|
||||
<div className={`slider${disabled ? ' disabled' : ''}`}>
|
||||
<label htmlFor={id}>{label}</label>
|
||||
<input
|
||||
id={id}
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
/>
|
||||
<output htmlFor={id}>{value}</output>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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]))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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
@@ -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;
|
||||
}
|
||||
.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;
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
.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; }
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
Reference in new issue
Block a user