const { useState, useRef, useEffect } = React;
/* ===== Konstanten ===== */
const PLATFORMS = [
{ value: "short", label: "Short Video" },
{ value: "long", label: "Long Video" },
];
const LS_LAST_PROJECT = "kveditor.lastProjectId";
// Untertitel-Schriftarten (Auswahl im Settings-Panel). Für den Burn-in muss die
// Schrift auf dem Mac installiert sein — sonst greift eine Ersatzschrift.
// Nur gebündelte Fonts: jede Auswahl ist als @font-face geladen UND liegt als
// Datei im fontsdir des Burn-ins → Vorschau und Export nutzen exakt dieselbe Schrift.
// Reihenfolge = fett→schlank. Familienname muss 1:1 zu @font-face + ASS passen.
const SUB_FONT_CHOICES = [
"Montserrat Black",
"Montserrat ExtraBold",
"Montserrat SemiBold",
"Montserrat",
"Plus Jakarta Sans ExtraBold",
"DM Sans",
"DM Serif Display",
"Fraunces Black",
];
const SUB_FONT_DEFAULT = "Montserrat Black";
const SUB_FONT_SIZE_DEFAULT = 24; // = Backend DEFAULT_FONT_SIZE; vollbild-tauglich
const SUB_FONT_SIZES = [14, 16, 20, 24, 28, 32, 40, 48];
const LS_MULTICUT = "kveditor.multicutMode";
const CONTENT_TYPES = [
{ value: "tofu", label: "TOFU — kontroverse Statements (max. 15s)", short: "TOFU" },
{ value: "mofu", label: "MOFU — Insights & Takeaways (30–60s)", short: "MOFU" },
];
const POST_PROCESSING_OPTIONS = [
{ value: "segment", label: "Nur Segment-Schnitt (schnell)" },
{ value: "v52", label: "Segment + V5.2 Feinschnitt (Ähs & Pausen raus)" },
];
const SEGMENT_TYPE_LABELS = {
content: "Content",
silence: "Stille",
breath: "Atmer",
filler_word: "Füllwort",
false_start: "Fehlstart",
repetition: "Wiederholung",
};
/* ===== API helper ===== */
async function api(method, path, body) {
const opts = { method, headers: {} };
if (body && !(body instanceof FormData)) {
opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(body);
} else if (body instanceof FormData) {
opts.body = body;
}
const res = await fetch(path, opts);
if (!res.ok) {
const e = await res.json().catch(() => ({}));
throw new Error(e.detail || `HTTP ${res.status}`);
}
return res.json();
}
/* ===== Admin-/Feedback-Ansicht =====
Zwei Ansichten aus EINER Codebasis: Kunden-Ansicht (Standard, ohne Feedback-Felder) und
Admin-Ansicht mit Feedback pro Output-Feld. Das Passwort wird SERVER-seitig geprüft
(/api/analyst/admin/verify) — im Frontend steht es nie, sonst wäre es bei öffentlich
erreichbarem Server wirkungslos. Gesammelt wird nur; kein Auto-Fix, kein Auto-Commit. */
const AdminCtx = React.createContext({ admin: false, password: "", runId: "" });
function AdminToggle({ admin, password, onLogin, onLogout }) {
const [offen, setOffen] = useState(false);
const [pw, setPw] = useState("");
const [fehler, setFehler] = useState("");
const [pruefe, setPruefe] = useState(false);
async function submit(e) {
e?.preventDefault();
setPruefe(true); setFehler("");
try {
await api("POST", "/api/analyst/admin/verify", { password: pw });
onLogin(pw); setOffen(false); setPw("");
} catch (err) {
setFehler(err.message || "Falsches Passwort");
} finally { setPruefe(false); }
}
if (admin) {
return (
ADMIN
Verlassen
);
}
return (
setOffen((o) => !o)}>Admin
{offen && (
)}
);
}
/* Ein Feedback-Block pro Output-Feld. In der Kunden-Ansicht rendert er nichts. */
function Feedback({ field }) {
const { admin, password, runId } = React.useContext(AdminCtx);
const [verdict, setVerdict] = useState("");
const [text, setText] = useState("");
const [status, setStatus] = useState(""); // "", "gespeichert", Fehlertext
if (!admin || !runId) return null;
async function speichern(v, t) {
try {
await api("POST", `/api/analyst/${runId}/feedback`,
{ password, field_id: field, verdict: v, text: t });
setStatus("gespeichert ✓");
setTimeout(() => setStatus(""), 1800);
} catch (e) { setStatus(e.message || "Fehler"); }
}
const daumen = (wert, symbol) => (
{ setVerdict(wert); speichern(wert, text); }}
title={wert === "up" ? "gut" : "schlecht"}
style={{ padding: "2px 8px", fontSize: 14, cursor: "pointer", borderRadius: 6,
border: "1px solid " + (verdict === wert ? "transparent" : "var(--line-strong)"),
background: verdict === wert ? (wert === "up" ? "#1e8e5a" : "#c0392b") : "transparent",
filter: verdict === wert ? "grayscale(1) brightness(3)" : "none" }}>
{symbol}
);
return (
{daumen("up", "👍")}{daumen("down", "👎")}
{field}
{status}
);
}
/* ===== Icons ===== */
const Ico = {
refresh: (p) => ,
plus: (p) => ,
folder: (p) => ,
upload: (p) => ,
film: (p) => ,
search: (p) => ,
brain: (p) => ,
scissors: (p) => ,
check: (p) => ,
download: (p) => ,
caption: (p) => ,
x: (p) => ,
link: (p) => ,
play: (p) => ,
chart: (p) => ,
sparkles: (p) => ,
grid: (p) => ,
};
/* ===== MultiCut: Sound-Belohnung (WebAudio, erst nach User-Geste erlaubt) ===== */
let _audioCtx = null;
function playDoneChime() {
try {
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const ctx = _audioCtx;
if (ctx.state === "suspended") ctx.resume();
const t0 = ctx.currentTime;
[[880, 0, 0.18], [1318.5, 0.12, 0.3]].forEach(([freq, delay, dur]) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "sine";
osc.frequency.value = freq;
gain.gain.setValueAtTime(0.0001, t0 + delay);
gain.gain.exponentialRampToValueAtTime(0.22, t0 + delay + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + delay + dur);
osc.connect(gain).connect(ctx.destination);
osc.start(t0 + delay);
osc.stop(t0 + delay + dur + 0.05);
});
} catch (_) { /* Sound ist Bonus — nie die Pipeline stören */ }
}
function Logo() {
// Marken-Logo: drei goldene Schräg-Balken wie im MEINFLUSS-Screenshot.
// Die Wortmarke „MEINFLUSS" steht daneben als HTML-Textmarke (dunkel = auf der
// hellen Kopfleiste sichtbar; die helle Screenshot-Variante wäre creme-auf-creme unsichtbar).
return (
);
}
function Card({ num, icon, title, sub, action, children }) {
return (
{num != null &&
{num}
}
{icon &&
{icon}
}
{action}
{children}
);
}
function Pill({ kind, children }) {
return {children} ;
}
/* ===== Pipeline-Schritte ===== */
const STEPS = [
{ key: "analyze", icon: Ico.search, title: "Originalvideo wird analysiert", desc: "Segmente, Sprache & Highlights erkennen" },
{ key: "plan", icon: Ico.brain, title: "Rohschnitt wird geplant", desc: "Füllwörter, Pausen & Wiederholungen aussortieren" },
{ key: "cut", icon: Ico.scissors, title: "Rohschnitt wird erstellt", desc: "Schnitte rendern & finales Video exportieren" },
];
function fmtMs(ms) {
if (ms == null) return "—";
const s = ms / 1000;
const mm = Math.floor(s / 60);
const ss = (s - mm * 60).toFixed(1);
return mm > 0 ? `${mm}:${ss.padStart(4, "0")}` : `${s.toFixed(2)}s`;
}
/* ===== Video Analyst Seite ===== */
// Muss mit models.analyst.FORMATE übereinstimmen — die API validiert die Auswahl dagegen (422).
const FORMATE = ["Talking Head", "Reaction", "Sketch", "Tutorial", "Vlog", "Andere"];
const ANALYST_FEATURES = [
{ ico: "🎯", title: "Inhaltsanalyse", desc: "Themen, Kernaussagen & Story-Struktur erkennen" },
{ ico: "🎙️", title: "Sprach-Qualität", desc: "Füllwörter, Pausen, Sprechtempo & Verständlichkeit" },
{ ico: "✂️", title: "Schnitt-Bewertung", desc: "Übergänge, Pacing & Engagement-Kurve" },
{ ico: "📊", title: "Performance-Score", desc: "Plattform-Potenzial & konkrete Optimierungstipps" },
];
// Bewertungs-Engines zum Vergleich. Gleiches Ergebnis-Layout, gleicher Bewertungsprompt —
// nur WER/WIE bewertet ändert sich.
const ANALYST_ENGINES = [
{ id: "v1", label: "V1", hint: "Mehrstufige Analyse: das Video wird erst beschrieben, dann bewertet. Gründlich, aber etwas langsamer." },
{ id: "v2_hybrid", label: "V2 Hybrid", hint: "Analyse und Bewertung in einem Schritt, ergänzt um objektive Messwerte. Schneller." },
];
const ENGINE_LABEL = Object.fromEntries(ANALYST_ENGINES.map((e) => [e.id, e.label]));
// Stufen pro Engine für die Fortschrittsbalken. est = geschätzte Dauer in Sekunden (aus echten Läufen +
// Architektur; die Gemini-Phase enthält den Video-Upload und ist mit Abstand am längsten).
const STAGE_SETS = {
v2_hybrid: [
{ key: "transcribe", label: "Transkription", est: 28 },
{ key: "quality", label: "Audio-Messwerte", est: 6 },
{ key: "evaluate", label: "Analyse & Bewertung", est: 85 },
],
v2_pure: [
{ key: "evaluate", label: "Analyse & Bewertung", est: 50 },
],
v1: [
{ key: "transcribe", label: "Transkription", est: 40 },
{ key: "describe", label: "Bild & Ton", est: 300 },
{ key: "quality", label: "Audio-Messwerte", est: 6 },
{ key: "evaluate", label: "Bewertung", est: 40 },
],
};
// Ein Stufen-Balken pro Bearbeitungsschritt: abgeschlossene Stufen 100 %, die aktive füllt sich zeitbasiert
// (gedeckelt bei 96 %, bis die Stufe wirklich fertig ist). So gibt es pro Stufe ein „voll"-Erlebnis.
function StageBars({ stages, activePhase, phaseStartMs, tick }) {
const activeIdx = stages.findIndex((s) => s.key === activePhase);
return (
{stages.map((s, i) => {
// Abgeschlossene Stufe → nur grüner Haken. Aktive Stufe → EIN Balken. Zukünftige → nicht anzeigen.
if (activeIdx >= 0 && i < activeIdx) {
return (
✓ {s.label}
);
}
if (i === activeIdx) {
const elapsed = phaseStartMs ? (Date.now() - phaseStartMs) / 1000 : 0;
const pct = Math.min(96, (elapsed / s.est) * 100);
return (
{s.label}
{Math.round(pct)} %
);
}
return null;
})}
);
}
function fmtSec(s) {
if (s == null) return "—";
const mm = Math.floor(s / 60);
const ss = Math.floor(s - mm * 60);
return `${mm}:${String(ss).padStart(2, "0")}`;
}
function RatingDots({ value }) {
return (
{[1, 2, 3, 4, 5].map((i) => (
))}
);
}
// Score-Chip mit Hover-Info (ⓘ): spart Platz, Detail nur bei Bedarf.
function ScoreChip({ label, score, detail }) {
const has = detail && String(detail).trim().length > 0;
return (
{label}
{has && (
ⓘ{detail}
)}
{score != null ? `${score}/5` : "–"}
);
}
// Handlungsempfehlungen: max 3, nach Priorität; die wichtigste hervorgehoben. Ruhige Liste, kein Häkchen-Zwang.
function Handlungsempfehlungen({ steps }) {
const top = (steps || []).slice(0, 3);
return (
💡 Handlungsempfehlungen
Du musst nicht alles umsetzen — schon eine Änderung hilft.
{top.map((s, i) => (
{i === 0 ? "★ Wichtigste" : `#${i + 1}`}
{s.zeitpunkt ? {s.zeitpunkt} — : null}{s.anweisung}
))}
);
}
// Ordnet den Performance-Score ermutigend ein (Label + Farbe) statt nackter Zahl.
function scoreEinordnung(score) {
if (score == null) return { label: "", color: "var(--taupe)" };
if (score >= 75) return { label: "Stark — weiter so", color: "var(--ok-ink)" };
if (score >= 55) return { label: "Gute Basis — mit 1–2 Änderungen richtig stark", color: "var(--gold-deep)" };
if (score >= 35) return { label: "Solide Basis — ein paar Hebel bringen viel", color: "var(--gold-deep)" };
return { label: "Guter Anfang — die Empfehlungen unten helfen am meisten", color: "var(--err-ink)" };
}
// Detail-Text für die Struktur: Kommentar + erkannte Bausteine.
function strukturDetail(struktur) {
if (!struktur) return "";
const e = struktur.elemente || {};
const order = [["hook", "Hook"], ["bridge", "Bridge"], ["mid", "Mid"], ["peak", "Peak"], ["cta", "CTA"]];
const bausteine = order.map(([k, lbl]) => `${lbl}${e[k] ? "✓" : "✗"}`).join(" · ");
return [struktur.kommentar, "Bausteine: " + bausteine].filter(Boolean).join(" — ");
}
function problemeDetail(block) {
const p = block?.probleme || [];
return p.length ? p.join(" · ") : "Keine Auffälligkeiten";
}
function VideoAnalystPage({ adminPw = "" }) {
const [runId, setRunId] = useState(""); // für die Feedback-Zuordnung in der Admin-Ansicht
const [analysisFile, setAnalysisFile] = useState(null);
const engine = "v2_hybrid"; // nur noch V2 Hybrid im Frontend (V1 entfernt; Backend kann v1/v2_pure weiter via API)
const [plannedTextHook, setPlannedTextHook] = useState(""); // Freifeld: geplante Texthook (falls noch nicht im Video)
// Format-Auswahl (Pflicht, genau eines). Muss zu models.analyst.FORMATE passen — die API validiert dagegen.
const [format, setFormat] = useState("");
const [phase, setPhase] = useState("idle"); // idle | running | done
const [progress, setProgress] = useState("");
const [queueInfo, setQueueInfo] = useState(null); // {ahead,total} während "queued"
const [result, setResult] = useState(null);
const [error, setError] = useState("");
const [activePhase, setActivePhase] = useState(""); // aktuelle Bearbeitungsstufe (transcribe/quality/evaluate)
const [phaseStartMs, setPhaseStartMs] = useState(0); // Startzeitpunkt der aktuellen Stufe
const [tick, setTick] = useState(0); // erzwingt zeitbasierte Neuberechnung der Balken
const fileRef = useRef(null);
const cancelledRef = useRef(false);
const activePhaseRef = useRef(""); // stale-freier Vergleich im Poll-Loop
useEffect(() => () => { cancelledRef.current = true; }, []);
// Solange die Analyse läuft: regelmäßig neu rendern, damit die aktive Stufe weiterfüllt.
useEffect(() => {
if (phase !== "running") return;
const id = setInterval(() => setTick((t) => t + 1), 300);
return () => clearInterval(id);
}, [phase]);
function onPickFile(e) {
const f = e.target.files?.[0];
if (f) setAnalysisFile({ file: f, name: f.name, size: (f.size / 1024 / 1024).toFixed(1) + " MB" });
}
const canStart = !!analysisFile && !!format;
async function pollUntilDone(runId) {
while (!cancelledRef.current) {
await new Promise((r) => setTimeout(r, 2000));
const data = await api("GET", `/api/analyst/${runId}`);
if (cancelledRef.current) return null;
if (data.phase === "error") throw new Error(data.error || "Analyse fehlgeschlagen");
setQueueInfo(data.phase === "queued" ? (data.queue || null) : null);
setProgress(data.detail || "Analyse läuft …");
if (data.phase && data.phase !== activePhaseRef.current) {
activePhaseRef.current = data.phase;
setActivePhase(data.phase);
setPhaseStartMs(Date.now());
}
if (data.done && data.result) return data.result;
}
return null;
}
async function startAnalysis() {
if (!canStart || phase === "running") return;
setError("");
setPhase("running");
setResult(null);
setActivePhase("");
activePhaseRef.current = "";
setPhaseStartMs(0);
try {
setProgress("Video wird hochgeladen …");
const fd = new FormData();
fd.append("file", analysisFile.file);
const up = await api("POST", "/api/analyst/upload", fd);
setRunId(up.id);
setProgress("Analyse startet …");
await api("POST", `/api/analyst/${up.id}/start?engine=${engine}&planned_text_hook=${encodeURIComponent(plannedTextHook)}&format=${encodeURIComponent(format)}`);
const res = await pollUntilDone(up.id);
if (res) {
setResult(res);
setPhase("done");
}
} catch (e) {
setError(e.message);
setPhase("idle");
}
}
function reset() {
setPhase("idle");
setAnalysisFile(null);
setResult(null);
setProgress("");
setQueueInfo(null);
setError("");
setPlannedTextHook("");
setFormat("");
setRunId("");
setActivePhase("");
activePhaseRef.current = "";
setPhaseStartMs(0);
}
return (
{error && (
)}
{/* Eingabe */}
} title="Video-Quelle" sub="Lade dein Video hoch">
fileRef.current?.click()}
>
{analysisFile ? analysisFile.name : "Video auswählen oder hierher ziehen"}
{analysisFile ? analysisFile.size : "MP4, MOV, AVI, MKV · max. 4 GB"}
{analysisFile && (
setAnalysisFile(null)}>
Datei entfernen
)}
{/* Analyse-Umfang + Start-Button */}
} title="Analyse-Umfang" sub="Was der AI Video Analyst prüft">
{ANALYST_FEATURES.map((f) => (
))}
{/* Format-Auswahl (Pflicht, genau eines). Der Nutzer kennt sein Video — seine Angabe ist für die
Bewertung bindend und verhindert, dass die KI z.B. ein Reaction-Video als Talking Head liest. */}
Format *
{FORMATE.map((f) => {
const on = format === f;
return (
setFormat(f)}
aria-checked={on}
style={{
padding: "6px 12px", fontSize: 13, borderRadius: 999, cursor: "pointer",
border: `1px solid ${on ? "var(--accent, #2d6cdf)" : "var(--line-strong)"}`,
background: on ? "var(--accent, #2d6cdf)" : "transparent",
color: on ? "#fff" : "inherit",
fontWeight: on ? 600 : 400,
}}
>
{f}
);
})}
Wähl das Format, das am besten passt — es steuert, wie streng Schnitt und Sprechpausen
bewertet werden. Passt nichts genau? Dann „Andere“.
{/* Freifeld: geplante Texthook (falls sie erst nach dem Upload ins Video kommt) */}
{phase === "running" ? (
<> {progress || "Analyse läuft …"}>
) : (
<> Videoanalyse starten>
)}
{phase === "running" && (
)}
{phase === "running" && queueInfo && queueInfo.total > 1 && (
⏳ In Warteschlange — Platz {queueInfo.ahead + 1} von {queueInfo.total}. Deine Analyse startet automatisch, sobald sie an der Reihe ist.
)}
{!canStart && (
Bitte wähle zuerst eine Videodatei aus.
)}
{/* Ergebnis */}
{phase === "done" && result && (
}
title="Analyse abgeschlossen"
sub={[
result.filename,
fmtSec(result.duration_sec),
ENGINE_LABEL[result.engine] || result.engine,
result.elapsed_sec ? `⏱ ${result.elapsed_sec}s` : null,
result.scene_count ? `${result.scene_count} Szenen` : null,
].filter(Boolean).join(" · ")}
action={
Neue Analyse
}
>
{result.evaluation && (() => {
const ev = result.evaluation;
const ez = scoreEinordnung(ev.performance_score);
return (
{/* 1. Score + Einordnung */}
{ev.performance_score}
Performance-Score von 100
{ez.label && (
{ez.label}
)}
{ev.funnel &&
{ev.funnel} }
{ev.zielgruppe && (
<>
🎯 {ev.zielgruppe}
>
)}
{/* 2. Positiv zuerst */}
{ev.staerken?.length > 0 && (
✅ Das läuft schon gut
{ev.staerken.map((s, i) => {s} )}
)}
{/* 3. Handlungsempfehlungen (max 3, wichtigste groß) */}
{ev.action_steps?.length > 0 && (
<>
>
)}
{/* 3b. Erweiterte Handlungsempfehlungen (aufklappbar) */}
{ev.weitere_empfehlungen?.length > 0 && (
Erweiterte Handlungsempfehlungen ({ev.weitere_empfehlungen.length})
{ev.weitere_empfehlungen.map((s, i) => (
{s.zeitpunkt ? {s.zeitpunkt} — : null}{s.anweisung}
))}
)}
{/* 4. Details-Aufklapper: ausführliches Feedback + alle Score-Dimensionen */}
Detaillierte Analyse und Feedback
{ev.top_tipps?.length > 0 && (
💬 Feedback (ausführlich)
{ev.top_tipps.map((t, i) => {t} )}
)}
);
})()}
{!result.evaluation && (
Die Bewertung konnte nicht erstellt werden. Unten findest du die Detailanalyse des Videos.
)}
{result.audio_overview && (
🔊 Audio (ganzes Video)
{result.audio_overview}
)}
{result.gaze_overview && (
👀 Blickkontakt (ganzes Video)
{result.gaze_overview}
)}
{result.scenes.length > 0 && (
🔬 Detailanalyse ({result.scenes.length} Segmente)
{result.scenes.map((s) => (
Segment {s.index + 1} {fmtSec(s.start)}–{fmtSec(s.end)}
{s.error ? (
{s.error}
) : (
<>
Handlung: {s.handlung || s.beschreibung}
{s.personen &&
👤 {s.personen}
}
{(s.texte && s.texte.length > 0) ? (
s.texte.map((t, i) => (
📝 Text: „{t.wortlaut}" {t.darstellung ? — {t.darstellung} : null}
))
) : (
📝 Text im Bild: {s.text_overlays ? „{s.text_overlays}" : "(keiner erkannt)"}
)}
{s.gesprochener_text &&
🗣️ Gesprochen: {s.gesprochener_text}
}
{s.kamera &&
🎥 {s.kamera}
}
{s.bild_fakten && (
🖼️ Komposition: {s.bild_fakten.komposition} · Licht: {s.bild_fakten.licht} · Hintergrund: {s.bild_fakten.hintergrund}
)}
{s.effekte &&
✨ Effekte: {s.effekte}
}
{s.raw && (
Rohtext (1:1)
{s.raw}
)}
>
)}
))}
)}
)}
);
}
/* ===== Video Editor Seite ===== */
/* ===== Untertitel-Overlay (Bold Pop, Wort-für-Wort synchron, per Drag verschiebbar) ===== */
function SubtitleOverlay({ videoRef, doc, onPositionChange }) {
const [time, setTime] = useState(0);
const [pos, setPos] = useState(doc.position || { x_pct: 50, y_pct: 65 });
const [boxH, setBoxH] = useState(0);
const [boxW, setBoxW] = useState(0);
const overlayRef = useRef(null);
const dragRef = useRef(null);
// Refs für die Event-Handler: immer aktuelle Position + Callback, ohne dass die
// window-Listener bei jedem Render neu registriert werden müssen.
const posRef = useRef(pos);
const onPositionChangeRef = useRef(onPositionChange);
useEffect(() => { onPositionChangeRef.current = onPositionChange; }, [onPositionChange]);
useEffect(() => {
const p = doc.position || { x_pct: 50, y_pct: 65 };
posRef.current = p;
setPos(p);
}, [doc]);
// Player-Sync über requestAnimationFrame (flüssiger als timeupdate-Events)
useEffect(() => {
let raf;
const tick = () => {
const v = videoRef.current;
if (v) setTime(v.currentTime);
const o = overlayRef.current;
if (o) { setBoxH(o.clientHeight); setBoxW(o.clientWidth); }
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [videoRef]);
// Drag: Box greifen → Position in % des Video-Bereichs, beim Loslassen speichern.
// WICHTIG: Listener werden genau EINMAL registriert ([]-Deps) und das Speichern
// passiert direkt im Event-Handler — NIE als Seiteneffekt in einem setState-Updater.
// (Updater müssen pur sein; React darf sie bei interleaved Updates mehrfach ausführen —
// das hat mit laufendem Video die Seite eingefroren.)
useEffect(() => {
function onMove(e) {
if (!dragRef.current || !overlayRef.current) return;
const r = overlayRef.current.getBoundingClientRect();
const x = Math.max(5, Math.min(95, ((e.clientX - r.left) / r.width) * 100));
const y = Math.max(5, Math.min(95, ((e.clientY - r.top) / r.height) * 100));
const next = { x_pct: x, y_pct: y };
posRef.current = next;
setPos(next);
}
function onUp() {
if (dragRef.current) {
dragRef.current = null;
onPositionChangeRef.current(posRef.current);
}
}
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); };
}, []);
const style = doc.style_def || {};
const phrase = (doc.phrases || []).find((p) => time >= p.start && time < p.display_end);
const visible = phrase ? phrase.words.filter((w) => time >= w.start) : [];
// Zeilenumbruch: line_break_after = Index des letzten Worts von Zeile 1 (vom Backend,
// Fallback: einzeilig). Sichtbare Wörter werden auf die Zeilen verteilt.
const lineBreakAfter = phrase && phrase.line_break_after != null ? phrase.line_break_after : null;
const line1 = [];
const line2 = [];
visible.forEach((w, i) => {
if (lineBreakAfter !== null && i > lineBreakAfter) line2.push({ w, i });
else line1.push({ w, i });
});
// Größe: font_size (Default 16) = px bei 480p-Referenz, skaliert mit Player-Höhe.
// Rand-Garantie: identische Formel wie build_ass — Maß ist die LÄNGSTE ZEILE
// der vollen Phrase (kein Größen-Sprung während Wörter erscheinen).
const basePx = Math.max(8, ((doc.font_size || 24) * boxH) / 480);
let longestLine = 0;
if (phrase) {
const texts = phrase.words.map((w) => w.text);
if (lineBreakAfter === null) {
longestLine = texts.join(" ").length;
} else {
longestLine = Math.max(
texts.slice(0, lineBreakAfter + 1).join(" ").length,
texts.slice(lineBreakAfter + 1).join(" ").length
);
}
}
const maxPx = longestLine > 0 ? (boxW * 0.80) / (0.68 * longestLine) : basePx;
const fontSize = Math.max(8, Math.min(basePx, maxPx));
const renderWord = ({ w, i }, isLast) => (
{w.text}
);
return (
{visible.length > 0 && (
{ e.preventDefault(); dragRef.current = true; }}
style={{
position: "absolute",
left: pos.x_pct + "%",
top: pos.y_pct + "%",
transform: "translate(-50%, -50%)",
pointerEvents: "auto",
cursor: "grab",
whiteSpace: "nowrap",
textAlign: "center",
userSelect: "none",
// Genau die gewählte (gebündelte) Familie — KEIN synthetisches Bold,
// das Gewicht steckt in der Datei. So rendert der Browser identisch zu libass.
fontFamily: `"${doc.font_family || "Montserrat Black"}", "Montserrat Black", sans-serif`,
fontWeight: "normal",
textTransform: "uppercase",
fontSize: fontSize + "px",
letterSpacing: "0.5px",
lineHeight: 1.15,
color: doc.text_color || style.text_color || "#FFFFFF",
textShadow: "0 2px 14px rgba(0,0,0,.85), 0 1px 4px rgba(0,0,0,.7)",
}}
>
{line1.map((x, k) => renderWord(x, k === line1.length - 1))}
{line2.length > 0 && (
{line2.map((x, k) => renderWord(x, k === line2.length - 1))}
)}
)}
);
}
/* ===== MultiCut: KPI-Badge (1-5) ===== */
function KpiBadge({ score }) {
return (
{[1, 2, 3, 4, 5].map((i) => (
))}
{score}/5
);
}
/* ===== MultiCut: Konfetti-Burst bei fertigem Short ===== */
function ConfettiBurst() {
const pieces = Array.from({ length: 14 }, (_, i) => i);
const colors = ["#BD9F66", "#A6863F", "#5E7A47", "#00D2FF", "#E8B84B"];
return (
{pieces.map((i) => (
))}
);
}
function fmtDur(sec) {
if (sec == null) return "—";
if (sec < 60) return sec.toFixed(1) + "s";
const m = Math.floor(sec / 60);
return `${m}:${String(Math.round(sec - m * 60)).padStart(2, "0")} min`;
}
/* ===== MultiCut: Kachel ===== */
function ShortTile({ projectId, short, locked, celebrating, onToggle }) {
const [expanded, setExpanded] = useState(false);
const isDone = short.status === "done";
const isCutting = short.status === "cutting";
const isError = short.status === "error";
const previewUrl = `/api/shorts/${projectId}/${short.id}/preview`;
return (
{celebrating &&
}
setExpanded(!expanded)}>
„{short.transcript}"
{short.rationale &&
{short.rationale}
}
{isCutting && Wird geschnitten … }
{isError && ✗ Fehler — beim nächsten Lauf erneut }
{isDone && Fertig }
{!isCutting && !isError && !isDone && (
{short.selected ? "Bereit" : "Abgewählt"}
)}
{isDone && (
)}
);
}
/* ===== MultiCut: kompletter Flow (Analyse → Vorschläge → Auswahl → Batch) ===== */
function MultiCutFlow({ project, onProjectChanged }) {
const [plan, setPlan] = useState(null);
const [busy, setBusy] = useState(false); // Analyse + Planung läuft
const [busyMsg, setBusyMsg] = useState("");
const [polling, setPolling] = useState(false); // Batch läuft → 2s-Polling
const [postProcessing, setPostProcessing] = useState("segment");
const [error, setError] = useState("");
const [celebrating, setCelebrating] = useState({});
const prevStatusRef = useRef({});
const hasFiles = project.files && project.files.length > 0;
// Plan-Update zentral: erkennt frisch fertige Shorts → Chime + Konfetti
function applyPlan(p) {
const prev = prevStatusRef.current;
const fresh = (p.shorts || []).filter((s) => s.status === "done" && prev[s.id] && prev[s.id] !== "done");
if (fresh.length > 0) {
playDoneChime();
setCelebrating((c) => {
const next = { ...c };
fresh.forEach((s) => { next[s.id] = true; });
return next;
});
fresh.forEach((s) => {
setTimeout(() => setCelebrating((c) => { const n = { ...c }; delete n[s.id]; return n; }), 1800);
});
}
prevStatusRef.current = Object.fromEntries((p.shorts || []).map((s) => [s.id, s.status]));
setPlan(p);
if (p.post_processing) setPostProcessing(p.post_processing);
}
// Beim Projektwechsel: bestehenden Plan laden, ggf. Polling fortsetzen
useEffect(() => {
let cancelled = false;
prevStatusRef.current = {};
setPlan(null); setError(""); setCelebrating({});
api("GET", `/api/shorts/${project.id}`)
.then((p) => {
if (cancelled) return;
prevStatusRef.current = Object.fromEntries((p.shorts || []).map((s) => [s.id, s.status]));
setPlan(p);
if (p.post_processing) setPostProcessing(p.post_processing);
if (p.batch_status === "running" && p.live !== false) setPolling(true);
})
.catch(() => {});
return () => { cancelled = true; };
}, [project.id]);
// Batch-Polling alle 2s
useEffect(() => {
if (!polling) return;
const t = setInterval(async () => {
try {
const p = await api("GET", `/api/shorts/${project.id}`);
applyPlan(p);
if (p.batch_status === "done" || p.live === false) {
setPolling(false);
onProjectChanged && onProjectChanged();
}
} catch (_) {}
}, 2000);
return () => clearInterval(t);
}, [polling, project.id]);
async function findShorts() {
setBusy(true); setError("");
try {
if (project.status === "created") {
setBusyMsg("Langvideo wird analysiert … (Whisper — bei Podcast-Länge mehrere Minuten)");
await api("POST", `/api/pipeline/${project.id}/analyse`);
}
setBusyMsg("Claude sucht die besten Stellen …");
const p = await api("POST", `/api/shorts/${project.id}/plan`);
prevStatusRef.current = Object.fromEntries((p.shorts || []).map((s) => [s.id, s.status]));
setPlan(p);
onProjectChanged && onProjectChanged();
} catch (e) {
setError("Shorts-Planung fehlgeschlagen: " + e.message);
} finally {
setBusy(false); setBusyMsg("");
}
}
async function toggleShort(shortId, selected) {
try {
await api("PATCH", `/api/shorts/${project.id}/${shortId}`, { selected });
setPlan((p) => ({
...p,
shorts: p.shorts.map((s) => (s.id === shortId ? { ...s, selected } : s)),
}));
} catch (e) {
setError("Auswahl speichern fehlgeschlagen: " + e.message);
}
}
async function startBatch() {
setError("");
try {
// AudioContext braucht eine User-Geste — hier ist sie (Klick auf Pipeline starten)
try { if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch (_) {}
await api("POST", `/api/shorts/${project.id}/execute`, { post_processing: postProcessing });
setPolling(true);
} catch (e) {
setError("Batch-Start fehlgeschlagen: " + e.message);
}
}
const shorts = plan ? plan.shorts || [] : [];
const nSelected = shorts.filter((s) => s.selected).length;
const nDone = shorts.filter((s) => s.status === "done" && s.selected).length;
const batchRunning = polling;
const allDone = nSelected > 0 && nDone >= nSelected;
const progressPct = nSelected ? Math.round((nDone / nSelected) * 100) : 0;
const ctMeta = CONTENT_TYPES.find((c) => c.value === project.content_type);
return (
{error && (
)}
{/* Schritt 1: Stellen finden */}
{!plan && (
Die KI analysiert das Langvideo und schlägt Stellen vor, die als eigenständige
Shorts funktionieren — mit Transkript-Vorschau und KPI-Erwartung (1–5).
{busy ? <> {busyMsg}> : <> Shorts-Vorschläge finden>}
{!hasFiles && (
Lade zuerst dein Langvideo (z.B. Podcast) hoch.
)}
)}
{/* Schritt 2+3: Kacheln + Batch */}
{plan && (
<>
{batchRunning && <> {nDone}/{nSelected} Shorts fertig>}
{!batchRunning && allDone && (
Alle {nDone} Shorts fertig
)}
{!batchRunning && !allDone && (
{shorts.length} Vorschläge · {nSelected} ausgewählt
)}
{busy ? <> {busyMsg || "Plant …"}> : <> Neu planen>}
{(batchRunning || nDone > 0) && (
)}
{plan.claude_reasoning && (
{plan.claude_reasoning}
)}
{shorts.map((s) => (
))}
Nachschnitt
setPostProcessing(e.target.value)}>
{POST_PROCESSING_OPTIONS.map((o) => (
{o.label}
))}
{batchRunning
? <> Schneidet … ({nDone}/{nSelected})>
: allDone ? "Erneut schneiden" : <> Pipeline starten ({nSelected} Shorts)>}
>
)}
);
}
function VideoEditorPage() {
const [projects, setProjects] = useState([]);
const [selectedId, setSelectedId] = useState("");
const [refreshing, setRefreshing] = useState(false);
const [newName, setNewName] = useState("");
const [newPlatform, setNewPlatform] = useState("short");
const [newEngine, setNewEngine] = useState("v3");
const [multicutOn, setMulticutOn] = useState(() => localStorage.getItem(LS_MULTICUT) === "1");
const [newContentType, setNewContentType] = useState("tofu");
const [newSubsEnabled, setNewSubsEnabled] = useState(false);
const [newSubStyle, setNewSubStyle] = useState("bold_pop_cyan");
const [subStyles, setSubStyles] = useState([{ id: "bold_pop_cyan", label: "Bold Pop (Cyan)" }]);
const [files, setFiles] = useState([]);
const fileRef = useRef(null);
const [uploading, setUploading] = useState(false);
const [phase, setPhase] = useState(-1);
const [error, setError] = useState("");
const [pipelineMsg, setPipelineMsg] = useState("");
const [clips, setClips] = useState([]);
const [claudeReasoning, setClaudeReasoning] = useState("");
const [videoCacheBust, setVideoCacheBust] = useState(0);
const [subtitling, setSubtitling] = useState(false);
// Untertitel V1 (Bold Pop): JSON-Overlay + Drag + Burn-in
const [subsDoc, setSubsDoc] = useState(null);
const [subsBusy, setSubsBusy] = useState(false);
const [burning, setBurning] = useState(false);
const videoRef = useRef(null);
const selected = projects.find((p) => p.id === selectedId);
useEffect(() => { loadProjectList(); }, []);
useEffect(() => {
api("GET", "/api/subtitles/styles")
.then((d) => d.styles && d.styles.length && setSubStyles(d.styles))
.catch(() => {});
}, []);
// Preview zeigt IMMER den Rohschnitt + Live-Overlay — der Burn ist nur fürs
// Downloaden. (Vorher wurde nach dem Download auf die eingebrannte Datei
// umgeschaltet → Preview brach und Live-Einstellungen wären unsichtbar.)
async function refreshSubsAndOutput(id, _status) {
try {
const doc = await api("GET", `/api/subtitles/${id}`);
setSubsDoc(doc);
} catch (_) { setSubsDoc(null); }
}
// Live-Einstellungen: lokal sofort anwenden (Overlay rendert direkt), dann speichern
async function patchSubs(patch) {
setSubsDoc((d) => (d ? { ...d, ...patch } : d));
try { await api("PATCH", `/api/subtitles/${selectedId}/settings`, patch); } catch (_) {}
}
async function generateSubs() {
if (!selectedId) return;
setSubsBusy(true); setError("");
try {
const doc = await api("POST", `/api/subtitles/${selectedId}/generate`);
setSubsDoc(doc);
} catch (e) {
setError("Untertitel-Generierung fehlgeschlagen: " + e.message);
} finally { setSubsBusy(false); }
}
async function toggleSubs(enabled) { await patchSubs({ enabled }); }
async function saveSubsPosition(pos) {
// Funktionales Update — kein stale-subsDoc-Closure aus dem Drag-Listener
setSubsDoc((d) => (d ? { ...d, position: pos } : d));
try { await api("PATCH", `/api/subtitles/${selectedId}/settings`, { position: pos }); } catch (_) {}
}
function triggerDownload(url, filename) {
const a = document.createElement("a");
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); a.remove();
}
// Download mit integriertem Burn-in: sind Untertitel per Checkbox aktiv,
// wird vor dem Download automatisch eingebrannt (subtitled_cut.mp4).
async function downloadVideo() {
if (!selected) return;
if (subsDoc && subsDoc.enabled) {
setBurning(true); setError("");
try {
await api("POST", `/api/subtitles/${selectedId}/burn`);
// Preview bleibt auf dem Rohschnitt (Live-Overlay) — nur der Download
// bekommt die eingebrannte Datei.
triggerDownload(
`/api/pipeline/${selectedId}/preview/subtitled_cut.mp4?t=${Date.now()}`,
`${selected.name}_subtitled_cut.mp4`
);
} catch (e) {
// Fallback: Download darf nie komplett blockieren — Rohschnitt liefern
setError("Einbrennen fehlgeschlagen (" + e.message + ") — Rohschnitt ohne Untertitel wurde stattdessen heruntergeladen.");
triggerDownload(
`/api/pipeline/${selectedId}/preview/rough_cut.mp4?t=${Date.now()}`,
`${selected.name}_rough_cut.mp4`
);
} finally { setBurning(false); }
} else {
triggerDownload(videoUrl, `${selected.name}_rough_cut.mp4`);
}
}
async function loadProjectList(autoRestore = true) {
setRefreshing(true);
try {
const data = await api("GET", "/api/projects/");
const sorted = [...(data.projects || [])].sort(
(a, b) => (b.created_at || "").localeCompare(a.created_at || "")
);
setProjects(sorted);
if (autoRestore) {
const lastId = localStorage.getItem(LS_LAST_PROJECT);
const restoreId = lastId && sorted.find((p) => p.id === lastId) ? lastId : (sorted[0]?.id || "");
setSelectedId(restoreId);
if (restoreId) await loadProjectDetails(restoreId);
}
} catch (e) {
setError("Projekt-Liste laden fehlgeschlagen: " + e.message);
} finally {
setTimeout(() => setRefreshing(false), 400);
}
}
async function loadProjectDetails(id) {
try {
const proj = await api("GET", `/api/projects/${id}`);
setProjects((ps) => ps.map((p) => (p.id === id ? { ...p, files: proj.files, status: proj.status, name: proj.name, platform: proj.platform, mode: proj.mode, content_type: proj.content_type } : p)));
if (proj.mode === "multicut") {
// MultiCut hat seinen eigenen Flow — Single-Pipeline-Phasen hier nicht anfassen
setPhase(-1); setClips([]); setClaudeReasoning("");
return;
}
if (proj.status === "cut" || proj.status === "subtitled") {
setPhase(3);
setVideoCacheBust(Date.now());
await refreshSubsAndOutput(id, proj.status);
} else if (proj.status === "planned") {
try {
const head = await fetch(`/api/pipeline/${id}/preview/rough_cut.mp4`, { method: "HEAD" });
if (head.ok) {
setPhase(3);
setVideoCacheBust(Date.now());
await refreshSubsAndOutput(id, proj.status);
} else {
setPhase(-1); setClips([]); setClaudeReasoning("");
}
} catch (_) {
setPhase(-1); setClips([]); setClaudeReasoning("");
}
} else {
setPhase(-1); setClips([]); setClaudeReasoning("");
}
} catch (e) {
console.error("Projekt-Details laden fehlgeschlagen", e);
}
}
async function onSelectProject(id) {
setSelectedId(id);
localStorage.setItem(LS_LAST_PROJECT, id);
setFiles([]); setError(""); setPipelineMsg("");
setSubsDoc(null); setOutputFile("rough_cut.mp4");
await loadProjectDetails(id);
}
async function createProject() {
if (!newName.trim()) { setError("Bitte gib einen Projektnamen ein."); return; }
setError("");
try {
const p = await api("POST", "/api/projects/", {
name: newName.trim().replace(/\s+/g, "_"),
platform: multicutOn ? "short" : newPlatform,
engine_version: newEngine,
subtitles_enabled: multicutOn ? false : newSubsEnabled,
subtitle_style: newSubStyle,
mode: multicutOn ? "multicut" : "single",
content_type: multicutOn ? newContentType : null,
});
localStorage.setItem(LS_LAST_PROJECT, p.id);
setNewName(""); setPhase(-1); setClips([]); setClaudeReasoning(""); setFiles([]);
await loadProjectList(false);
setSelectedId(p.id);
} catch (e) {
setError("Projekt anlegen fehlgeschlagen: " + e.message);
}
}
function onPick(e) {
const picked = Array.from(e.target.files || []).map((f) => ({
file: f, name: f.name, size: (f.size / 1024 / 1024).toFixed(1) + " MB",
}));
setFiles(picked);
}
function removeFile(i) { setFiles(files.filter((_, idx) => idx !== i)); }
async function uploadFiles() {
if (!files.length || !selectedId) return;
setUploading(true); setError("");
try {
const form = new FormData();
files.forEach((f) => form.append("files", f.file));
await api("POST", `/api/projects/${selectedId}/upload`, form);
setFiles([]);
await loadProjectDetails(selectedId);
} catch (e) {
setError("Upload fehlgeschlagen: " + e.message);
} finally {
setUploading(false);
}
}
async function runPipeline() {
if (!selectedId) return;
setError(""); setClips([]); setClaudeReasoning("");
try {
setPhase(0); setPipelineMsg("Originalvideo wird analysiert … (Whisper + Visual, 1-3 Min)");
await api("POST", `/api/pipeline/${selectedId}/analyse`);
setPhase(1); setPipelineMsg("Rohschnitt wird geplant …");
const cutPlan = await api("POST", `/api/pipeline/${selectedId}/plan-cuts`);
setClips(cutPlan.clips || []);
setClaudeReasoning(cutPlan.claude_reasoning || "");
setPhase(2); setPipelineMsg("Rohschnitt wird gerendert (~30s) …");
await api("POST", `/api/feedback/${selectedId}/confirm`, { confirmed: true });
await api("POST", `/api/pipeline/${selectedId}/execute-cut`);
setPhase(3); setPipelineMsg("");
setVideoCacheBust(Date.now());
await loadProjectDetails(selectedId);
} catch (e) {
setError("Pipeline-Fehler: " + e.message);
setPhase(-1); setPipelineMsg("");
}
}
async function addSubtitles() {
if (!selectedId) return;
setSubtitling(true); setError("");
try {
await api("POST", `/api/pipeline/${selectedId}/add-subtitles`);
setVideoCacheBust(Date.now());
await loadProjectDetails(selectedId);
} catch (e) {
setError("Untertitel-Schritt fehlgeschlagen: " + e.message);
} finally {
setSubtitling(false);
}
}
function stepState(idx) {
if (phase === -1) return "pending";
if (phase === 3) return "done";
if (idx < phase) return "done";
if (idx === phase) return "running";
return "pending";
}
const progress = phase === -1 ? 0 : phase === 3 ? 100 : Math.round(((phase + 0.5) / 3) * 100);
const hasFiles = selected && selected.files && selected.files.length > 0;
const pipelineRunning = phase >= 0 && phase < 3;
const isDone = phase === 3;
const videoUrl = isDone && selected
? `/api/pipeline/${selected.id}/preview/rough_cut.mp4?t=${videoCacheBust}`
: null;
const showSubsOverlay = subsDoc && subsDoc.enabled;
return (
<>
{error && (
)}
{/* ===== MultiCut-Schalter — ganz oben ===== */}
}
title="Bestehende Projekte"
sub="Wähle ein Projekt, um daran weiterzuarbeiten"
action={
loadProjectList(false)} title="Aktualisieren">
}
>
onSelectProject(e.target.value)}>
— Projekt wählen —
{projects.map((p) => (
{p.name} · {p.platform} · {p.status} · {p.id}
))}
{selected && (
{selected.name}
{selected.platform}
Engine {selected.engine_version || "v1"}
{selected.status}
Dateien: {selected.files && selected.files.length ? selected.files.join(", ") : "—"}
)}
Projektname
setNewName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && createProject()} />
{multicutOn ? (
Content-Typ
setNewContentType(e.target.value)}>
{CONTENT_TYPES.map((c) => {c.label} )}
) : (
<>
Plattform
setNewPlatform(e.target.value)}>
{PLATFORMS.map((p) => {p.label} )}
Schnitt-Engine
setNewEngine(e.target.value)}>
v1 — Master-Prompt
v2 — Hybrid (Visual-Priorität)
v3 — Hybrid (Verständlichkeits-Priorität)
v4 — Hybrid + lokale Audio-Wahrheit (VAD)
v5 — Local-First (ohne Gemini)
v5.2 — Local-First + Feinschliff (Whisper small)
v5.3 — wie v5.2 · Transkript: Whisper large-v3
v5.4 — wie v5.2 · Transkript: CrisperWhisper
Untertitel-Stil
setNewSubStyle(e.target.value)}>
{subStyles.map((s) => {s.label} )}
>
)}
Projekt erstellen
{!multicutOn && (
setNewSubsEnabled(e.target.checked)} />
Untertitel automatisch nach dem Rohschnitt erstellen
)}
{multicutOn ? (
<>TOFU = kurze, polarisierende Statements (max. 15s) für Reichweite. MOFU = Insights, How-tos
und kompakte Takeaways (30–60s) für Vertrauen & Watchtime. Shorts werden 9:16 mittig gecroppt.>
) : (
<>v1 = Claude generiert Schnitt-Zeiten direkt. v2 = Code gruppiert Sätze + visuelle Priorität.
v3 = Verständlichkeit als Hauptkriterium. v4 = wie v3, plus lokale Audio-Wahrheit (Silero VAD).
v5 = komplett ohne Gemini (~3 Min schnellere Analyse, keine Quota). v5.2 = v5 + Feinschliff (MVP).
v5.3 / v5.4 = identisch zu v5.2, nur anderes Transkript-Modell (large-v3 bzw. CrisperWhisper) — zum A/B-Vergleich der Transkript-Qualität. Hinweis: large-v3 und CrisperWhisper laden beim ersten Lauf ein paar GB herunter und sind auf CPU spürbar langsamer als small.>
)}
{selectedId && (
fileRef.current && fileRef.current.click()}>
{files.length ? files.length + " Datei(en) ausgewählt" : "Dateien auswählen"}
MP4, MOV, WAV · mehrere Dateien möglich
{files.length > 0 && (
<>
{files.map((f, i) => (
{f.name}
{f.size}
removeFile(i)}>
))}
{uploading ? <> Wird hochgeladen …> : <> Hochladen>}
>
)}
)}
{/* MultiCut-Projekte bekommen ihren eigenen Flow — Single-Pipeline bleibt unberührt */}
{selectedId && selected && selected.mode === "multicut" && (
loadProjectDetails(selectedId)} />
)}
{selectedId && (!selected || selected.mode !== "multicut") && (
{STEPS.map((s, i) => {
const st = stepState(i);
return (
{st === "done" ? : s.icon()}
{st === "running" &&
}
{st === "pending" ? "Wartet" : st === "running" ? "Läuft…" : "Fertig"}
);
})}
{phase === -1 && Bereit zum Start }
{pipelineRunning && <> {pipelineMsg || "Video wird verarbeitet …"}>}
{isDone && Schnitt abgeschlossen }
{isDone ? "Erneut schneiden" : "Pipeline starten"}
{!hasFiles && phase === -1 && (
Lade zuerst Rohmaterial hoch, um die Pipeline zu starten.
)}
)}
{isDone && selected && videoUrl && selected.mode !== "multicut" && (
} title="Fertiges Video" sub="Vorschau, Download & Untertitel">
{showSubsOverlay && (
)}
{selected.name}_rough_cut.mp4
{clips.length} Schnitt-Clips · {selected.platform} · Status: {selected.status}
Schnitt fertig
{selected.platform}
{subsDoc &&
UT: {(subsDoc.style_def && subsDoc.style_def.label) || subsDoc.style} }
{selected.status === "subtitled" &&
mit Untertiteln }
{burning
? <> Untertitel werden eingebrannt …>
: <> Herunterladen{subsDoc && subsDoc.enabled ? " (mit Untertiteln)" : ""}>}
{!subsDoc && (
{subsBusy ? <> Whisper synchronisiert …> : <> Untertitel erstellen>}
)}
{subsDoc && (
Untertitel-Einstellungen — live in der Vorschau
toggleSubs(e.target.checked)} />
Untertitel anzeigen ({subsDoc.phrases.length} Phrasen)
Schriftart
patchSubs({ font_family: e.target.value })}
>
{SUB_FONT_CHOICES.map((f) => (
{f}
))}
Größe
patchSubs({ font_size: parseInt(e.target.value, 10) })}
>
{SUB_FONT_SIZES.map((s) => {s} )}
Textfarbe
patchSubs({ text_color: e.target.value })}
style={{ width: 36, height: 26, border: "none", cursor: "pointer", background: "transparent" }} />
Highlight-Farbe
patchSubs({ highlight_color: e.target.value })}
style={{ width: 36, height: 26, border: "none", cursor: "pointer", background: "transparent" }} />
patchSubs({ position: { x_pct: 50, y_pct: 65 } })}>
Position zurücksetzen
Untertitel in der Vorschau mit der Maus verschieben — alle Einstellungen werden gespeichert
und beim Download exakt so eingebrannt. Hinweis: Die Schriftart muss auf dem Mac installiert
sein, sonst nutzt das Rendering eine Ersatzschrift.
)}
)}
>
);
}
/* ===== Hauptkomponente ===== */
const PAGES = [
{ id: "editor", label: "Video Editor", Icon: Ico.scissors },
{ id: "analyst", label: "Video Analyst", Icon: Ico.brain },
];
const PAGE_META = {
editor: {
title: "Reels & Shorts automatisch schneiden",
desc: "Lade dein Rohmaterial hoch — Gemini analysiert, Claude plant den Schnitt und der Editor rendert dein fertiges Video für die Plattform deiner Wahl.",
appTitle: "KI Video Editor",
},
analyst: {
title: "AI Video Analyst",
desc: "Analysiere jedes Video auf Inhalt, Sprach-Qualität, Schnitt-Pacing & Plattform-Potenzial — lade einfach dein Video hoch.",
appTitle: "AI Video Analyst",
},
};
// Vom Server gesetzt (templates/index.html). True → nur Analyst, Editor versteckt.
const ANALYST_ONLY = typeof window !== "undefined" && window.ANALYST_ONLY === true;
function App() {
const [activePage, setActivePage] = useState(ANALYST_ONLY ? "analyst" : "editor");
const meta = PAGE_META[activePage];
// Admin-Ansicht: Passwort wurde server-seitig geprüft; es bleibt nur im State (nicht persistiert),
// damit ein Reload zurück in die Kunden-Ansicht fällt.
const [adminPw, setAdminPw] = useState("");
return (
{/* Topbar */}
MEINFLUSS
Studio
{meta.appTitle}
setAdminPw("")}
/>
C
chris@meinfluss.de
{/* Navigation — im Analyst-only-Deployment ausgeblendet */}
{!ANALYST_ONLY && (
{PAGES.map((p) => (
setActivePage(p.id)}
>
{p.label}
))}
)}
{/* Page Header */}
{/* Seiten — immer gemountet, nur versteckt → State bleibt erhalten.
Im Analyst-only-Deployment wird der Editor gar nicht gemountet (kein /api/projects-Call). */}
{!ANALYST_ONLY && (
)}
);
}
ReactDOM.createRoot(document.getElementById("root")).render( );