Initial commit: InkFlow — EPUB vers livre audio local (MLX/Kokoro)

This commit is contained in:
2026-06-21 00:10:11 +02:00
commit d3bb91394b
71 changed files with 8138 additions and 0 deletions

View File

@@ -0,0 +1,245 @@
import React, { useEffect, useMemo, useState } from "react";
import { api } from "./api.js";
import { Spinner } from "./ui.jsx";
const NARRATOR = "narrateur";
let _seq = 0;
const nextId = () => ++_seq;
export default function AnalysisEditor({ slug, book, state }) {
// Chapitres analysés (intersection ordre du livre x analyzed_chapters).
const analyzed = useMemo(() => {
const set = new Set(state.analyzed_chapters || []);
return book.chapters.filter((c) => set.has(c.index));
}, [book, state.analyzed_chapters]);
const [index, setIndex] = useState(() => analyzed[0]?.index ?? null);
const [analysis, setAnalysis] = useState(null); // { index, title, segments:[{_id,type,text,speaker}] }
const [names, setNames] = useState([]); // noms de personnages pour la datalist
const [loading, setLoading] = useState(false);
const [saved, setSaved] = useState(false);
// Derniere selection de texte dans une replique (pour "marquer comme incise").
const [sel, setSel] = useState({ id: null, start: 0, end: 0 });
// Filtres d'affichage (n'altèrent pas la sauvegarde).
const [query, setQuery] = useState("");
const [typeFilter, setTypeFilter] = useState("all");
const [speakerFilter, setSpeakerFilter] = useState("all");
// Si la liste des chapitres analysés change et que l'index courant disparaît.
useEffect(() => {
if (index == null || !analyzed.some((c) => c.index === index)) {
setIndex(analyzed[0]?.index ?? null);
}
}, [analyzed]); // eslint-disable-line react-hooks/exhaustive-deps
// Noms des personnages du casting (une fois).
useEffect(() => {
api.getCast(slug)
.then((d) => setNames((d.cast?.characters || []).map((c) => c.name)))
.catch(() => setNames([]));
}, [slug]);
// Chargement de l'analyse du chapitre sélectionné.
useEffect(() => {
if (index == null) { setAnalysis(null); return; }
setLoading(true);
setSaved(false);
api.getChapter(slug, index).then((d) => {
if (d.analysis) {
setAnalysis({
index: d.analysis.index,
title: d.analysis.title,
segments: (d.analysis.segments || []).map((s) => ({ ...s, _id: nextId() })),
});
} else {
setAnalysis({ index, title: d.chapter?.title || "", segments: null });
}
}).finally(() => setLoading(false));
}, [slug, index]);
const speakerOptions = useMemo(() => {
const set = new Set([NARRATOR, ...names]);
(analysis?.segments || []).forEach((s) => s.speaker && set.add(s.speaker));
return [...set];
}, [names, analysis]);
if (!analyzed.length)
return <p className="text-ink-muted">Lancez d'abord l'<b>Analyse</b> sur un chapitre.</p>;
const touch = (segments) => { setAnalysis((a) => ({ ...a, segments })); setSaved(false); };
const setSeg = (id, patch) =>
touch(analysis.segments.map((s) => {
if (s._id !== id) return s;
const next = { ...s, ...patch };
if (next.type === "narration") { next.speaker = NARRATOR; next.incises = []; }
// Edition du texte : on ecarte les incises devenues hors-bornes.
if (patch.text !== undefined) {
const len = next.text.length;
next.incises = (next.incises || []).filter(
(inc) => inc.start < inc.end && inc.end <= len);
}
return next;
}));
// Marque la portion [start,end) d'une replique comme incise (voix narrateur).
const addIncise = (id, start, end) =>
touch(analysis.segments.map((s) => {
if (s._id !== id) return s;
const incises = [...(s.incises || []), { start, end }]
.sort((a, b) => a.start - b.start)
.filter((inc, i, arr) => i === 0 || inc.start >= arr[i - 1].end);
return { ...s, incises };
}));
const removeIncise = (id, i) =>
touch(analysis.segments.map((s) =>
s._id !== id ? s : { ...s, incises: (s.incises || []).filter((_, k) => k !== i) }));
const removeSeg = (id) => touch(analysis.segments.filter((s) => s._id !== id));
const insertAfter = (id) => {
const segs = analysis.segments;
const pos = id == null ? segs.length : segs.findIndex((s) => s._id === id) + 1;
const next = [...segs];
next.splice(pos, 0, { _id: nextId(), type: "narration", text: "", speaker: NARRATOR });
touch(next);
};
const save = async () => {
const payload = {
index: analysis.index,
title: analysis.title,
segments: analysis.segments.map(({ _id, ...s }) => s),
};
await api.putAnalysis(slug, analysis.index, payload);
setSaved(true);
};
const segments = analysis?.segments;
const visible = (segments || []).filter((s) => {
if (typeFilter !== "all" && s.type !== typeFilter) return false;
if (speakerFilter !== "all" && s.speaker !== speakerFilter) return false;
if (query && !s.text.toLowerCase().includes(query.toLowerCase())) return false;
return true;
});
const dialogueCount = (segments || []).filter((s) => s.type === "dialogue").length;
return (
<div className="space-y-4">
<datalist id="speaker-list">
{speakerOptions.map((n) => <option key={n} value={n} />)}
</datalist>
{/* Barre de contrôle */}
<div className="card flex flex-wrap items-center gap-3 p-3">
<label className="text-sm text-ink-muted">Chapitre</label>
<select className="input" value={index ?? ""}
onChange={(e) => setIndex(Number(e.target.value))}>
{analyzed.map((c) => (
<option key={c.index} value={c.index}>{c.index} {c.title}</option>
))}
</select>
{segments && (
<span className="text-xs text-ink-muted">
{segments.length} segments · {dialogueCount} dialogues
</span>
)}
<button className="btn-primary ml-auto" disabled={!segments} onClick={save}>
{saved ? "✓ enregistré" : "Enregistrer"}
</button>
</div>
{loading && <p className="text-ink-muted"><Spinner /> chargement de l'analyse…</p>}
{!loading && segments === null && (
<p className="text-ink-muted">Ce chapitre n'a pas encore d'analyse. Lancez l'<b>Analyse</b>.</p>
)}
{!loading && segments && (
<>
{/* Filtres d'affichage */}
<div className="card flex flex-wrap items-center gap-3 p-3">
<input className="input flex-1 min-w-[12rem]" placeholder="Rechercher dans le texte…"
value={query} onChange={(e) => setQuery(e.target.value)} />
<select className="input" value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}>
<option value="all">tous types</option>
<option value="narration">narration</option>
<option value="dialogue">dialogue</option>
</select>
<select className="input" value={speakerFilter} onChange={(e) => setSpeakerFilter(e.target.value)}>
<option value="all">tous locuteurs</option>
{speakerOptions.map((n) => <option key={n} value={n}>{n}</option>)}
</select>
{visible.length !== segments.length && (
<span className="text-xs text-ink-muted">{visible.length} affichés</span>
)}
</div>
<div className="card divide-y divide-ink-edge">
{visible.map((s) => {
const canMark = s.type === "dialogue"
&& sel.id === s._id && sel.end > sel.start;
const incises = s.incises || [];
return (
<div key={s._id} className="px-4 py-2.5">
<div className="flex items-start gap-3">
<select className="input w-28 shrink-0" value={s.type}
onChange={(e) => setSeg(s._id, { type: e.target.value })}>
<option value="narration">narration</option>
<option value="dialogue">dialogue</option>
</select>
<textarea className="input flex-1 min-h-[2.5rem] resize-y font-serif text-sm"
rows={Math.min(6, Math.ceil((s.text.length || 1) / 80))}
value={s.text}
onSelect={(e) => s.type === "dialogue" && setSel({
id: s._id, start: e.target.selectionStart, end: e.target.selectionEnd })}
onChange={(e) => setSeg(s._id, { text: e.target.value })} />
<input className="input w-40 shrink-0" list="speaker-list"
placeholder="locuteur"
value={s.speaker} disabled={s.type === "narration"}
onChange={(e) => setSeg(s._id, { speaker: e.target.value })} />
<div className="flex shrink-0 gap-1">
<button className="btn-ghost" title="Insérer après"
onClick={() => insertAfter(s._id)}>+</button>
<button className="btn-ghost" title="Supprimer"
onClick={() => removeSeg(s._id)}></button>
</div>
</div>
{/* Incises : portions lues par le narrateur dans la réplique */}
{s.type === "dialogue" && (incises.length > 0 || canMark) && (
<div className="mt-1.5 ml-[7.75rem] flex flex-wrap items-center gap-1.5">
<span className="text-[11px] uppercase tracking-wide text-ink-muted">incises</span>
{incises.map((inc, i) => (
<span key={i}
className="inline-flex items-center gap-1 rounded bg-ink-edge/40 px-1.5 py-0.5 text-xs"
title="Lu par la voix du narrateur">
<span className="text-ink-muted">🎙</span>
<span className="font-serif">{s.text.slice(inc.start, inc.end)}</span>
<button className="text-ink-muted hover:text-ink"
title="Retirer l'incise"
onClick={() => removeIncise(s._id, i)}></button>
</span>
))}
{canMark && (
<button className="btn-ghost text-xs"
onClick={() => { addIncise(s._id, sel.start, sel.end);
setSel({ id: null, start: 0, end: 0 }); }}>
+ marquer la sélection
</button>
)}
</div>
)}
</div>
); })}
<div className="px-4 py-2.5">
<button className="btn-ghost" onClick={() => insertAfter(null)}>+ ajouter un segment</button>
</div>
</div>
</>
)}
</div>
);
}

44
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,44 @@
import React, { useState } from "react";
import Library from "./Library.jsx";
import BookView from "./BookView.jsx";
import Settings from "./Settings.jsx";
export default function App() {
// Permet d'ouvrir un livre directement via #slug (deep-link).
const [slug, setSlug] = useState(
() => (location.hash ? decodeURIComponent(location.hash.slice(1)) : null)
);
const [showSettings, setShowSettings] = useState(false);
const goHome = () => { setShowSettings(false); setSlug(null); };
return (
<div className="min-h-screen bg-ink-bg text-ink-text">
<header className="border-b border-ink-edge">
<div className="mx-auto flex max-w-6xl items-center gap-3 px-6 py-4">
<button onClick={goHome} className="flex items-center gap-2">
<span className="text-2xl">🖋</span>
<span className="font-serif text-xl tracking-wide">
Ink<span className="text-ink-accent">Flow</span>
</span>
</button>
<span className="ml-2 hidden text-sm text-ink-muted sm:inline">
EPUB livre audio · local · MLX
</span>
<button onClick={() => setShowSettings(true)} title="Réglages techniques"
className="ml-auto text-xl text-ink-muted hover:text-ink-text"></button>
</div>
</header>
<main className="mx-auto max-w-6xl px-6 py-8">
{showSettings ? (
<Settings onBack={goHome} />
) : slug ? (
<BookView slug={slug} onBack={() => setSlug(null)} />
) : (
<Library onOpen={setSlug} />
)}
</main>
</div>
);
}

99
frontend/src/BookView.jsx Normal file
View File

@@ -0,0 +1,99 @@
import React, { useEffect, useState } from "react";
import { api, subscribeState } from "./api.js";
import { StatusChip, ProgressBar, Spinner } from "./ui.jsx";
import Chapters from "./Chapters.jsx";
import AnalysisEditor from "./AnalysisEditor.jsx";
import CastEditor from "./CastEditor.jsx";
import PronunciationEditor from "./PronunciationEditor.jsx";
const STAGES = [
{ key: "analyze", label: "Analyse", action: (s) => api.analyze(s), hint: "Découpe le texte, détecte les locuteurs et le casting." },
{ key: "cast", label: "Casting", action: (s) => api.castAuto(s), hint: "Attribue une voix à chaque personnage." },
{ key: "pronounce", label: "Prononciations", action: (s) => api.pronounce(s), hint: "Repère les mots à risque de mauvaise prononciation." },
];
export default function BookView({ slug, onBack }) {
const [data, setData] = useState(null);
const [state, setState] = useState(null);
const [tab, setTab] = useState("chapters");
useEffect(() => {
api.getBook(slug).then((d) => { setData(d); setState(d.state); });
const unsub = subscribeState(slug, setState);
return unsub;
}, [slug]);
if (!data) return <p className="text-ink-muted"><Spinner /> chargement</p>;
const { book } = data;
const st = state || data.state;
const busy = !!st.active_stage;
return (
<div className="space-y-6">
<button onClick={onBack} className="text-sm text-ink-muted hover:text-ink-text"> Bibliothèque</button>
<div className="flex gap-5">
{book.cover_file && (
<img src={api.coverUrl(slug)} alt="" className="h-44 rounded-md border border-ink-edge object-cover" />
)}
<div className="flex-1">
<h1 className="font-serif text-2xl">{book.title}</h1>
<p className="text-ink-muted">{book.author}</p>
<p className="mt-1 text-sm text-ink-muted">{book.chapters.filter((c) => c.render).length} chapitres à narrer</p>
{busy && (
<div className="mt-4 max-w-md space-y-1">
<div className="flex justify-between text-xs text-ink-accent">
<span>{st.active_detail || st.active_stage}</span>
<span>{Math.round((st.active_progress || 0) * 100)}%</span>
</div>
<ProgressBar value={st.active_progress} />
</div>
)}
</div>
</div>
{/* Pipeline */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{STAGES.map((stage) => {
const status = st.stages?.[stage.key] || "pending";
return (
<div key={stage.key} className="card p-4">
<div className="flex items-center justify-between">
<span className="font-medium">{stage.label}</span>
<StatusChip status={status} />
</div>
<p className="mt-1 text-xs text-ink-muted">{stage.hint}</p>
<button className="btn-ghost mt-3" disabled={busy}
onClick={() => stage.action(slug)}>
{status === "done" ? "Relancer" : "Lancer"}
</button>
</div>
);
})}
</div>
{/* Onglets */}
<div className="flex gap-1 border-b border-ink-edge">
{[
["chapters", "Chapitres"],
["analysis", "Analyse"],
["cast", "Casting"],
["pron", "Prononciation"],
].map(([key, label]) => (
<button key={key} onClick={() => setTab(key)}
className={`px-4 py-2 text-sm ${tab === key
? "border-b-2 border-ink-accent text-ink-text"
: "text-ink-muted hover:text-ink-text"}`}>
{label}
</button>
))}
</div>
{tab === "chapters" && <Chapters slug={slug} book={book} state={st} busy={busy} />}
{tab === "analysis" && <AnalysisEditor slug={slug} book={book} state={st} />}
{tab === "cast" && <CastEditor slug={slug} busy={busy} />}
{tab === "pron" && <PronunciationEditor slug={slug} />}
</div>
);
}

119
frontend/src/CastEditor.jsx Normal file
View File

@@ -0,0 +1,119 @@
import React, { useEffect, useState } from "react";
import { api } from "./api.js";
import { Spinner } from "./ui.jsx";
function VoiceSelect({ voices, value, onChange }) {
return (
<select className="input" value={value || ""} onChange={(e) => onChange(e.target.value)}>
<option value=""> aucune </option>
{voices.map((v) => (
<option key={v.id} value={v.id}>
{v.label || v.id} ({v.gender === "male" ? "H" : v.gender === "female" ? "F" : "?"})
</option>
))}
</select>
);
}
export default function CastEditor({ slug, busy }) {
const [cast, setCast] = useState(null);
const [voices, setVoices] = useState([]);
const [saved, setSaved] = useState(false);
const [playing, setPlaying] = useState(null);
const [msg, setMsg] = useState(null);
const dedupPending = React.useRef(false);
const reload = () =>
api.getCast(slug).then((d) => { setCast(d.cast); setVoices(d.voicebank.entries); });
useEffect(() => { reload(); }, [slug]);
// Recharge le casting quand un job de fond (dédup / casting chapitre) se termine.
useEffect(() => {
if (busy) return;
reload().then(() => {
if (dedupPending.current) {
dedupPending.current = false;
api.getCast(slug).then((d) =>
setMsg(`✓ déduplication terminée — ${d.cast.characters.length} personnages`));
}
});
}, [busy]);
const dedup = async () => {
setMsg(null);
try {
dedupPending.current = true;
await api.castDedup(slug);
setMsg("Déduplication lancée…");
} catch (e) {
dedupPending.current = false;
setMsg("Échec : " + e + " (le serveur backend est-il à jour ? redémarre-le)");
}
};
if (!cast) return <p className="text-ink-muted"><Spinner /> chargement du casting</p>;
if (!cast.characters.length)
return <p className="text-ink-muted">Lancez d'abord l'<b>Analyse</b> puis le <b>Casting</b>.</p>;
const update = (patch) => { setCast({ ...cast, ...patch }); setSaved(false); };
const setChar = (name, voiceId) =>
update({ characters: cast.characters.map((c) => c.name === name ? { ...c, voice_id: voiceId } : c) });
const preview = async (voiceId) => {
if (!voiceId) return;
setPlaying(voiceId);
try {
const url = await api.previewVoice(voiceId, "Bonjour, voici un aperçu de cette voix.");
const a = new Audio(url);
a.onended = () => setPlaying(null);
a.play();
} catch { setPlaying(null); }
};
const save = async () => { await api.putCast(slug, cast); setSaved(true); };
return (
<div className="space-y-4">
<div className="card flex items-center gap-3 p-3">
<span className="text-sm text-ink-muted">Narrateur</span>
<VoiceSelect voices={voices} value={cast.narrator_voice_id}
onChange={(v) => update({ narrator_voice_id: v })} />
<button className="btn-ghost" onClick={() => preview(cast.narrator_voice_id)}>
{playing === cast.narrator_voice_id ? "♪" : "▶"} écouter
</button>
<button className="btn-ghost ml-auto" disabled={busy}
title="Fusionne les variantes d'un même personnage (Holden / James Holden / James)"
onClick={dedup}>
{busy ? "…" : "Dédupliquer"}
</button>
<button className="btn-primary" onClick={save}>
{saved ? "✓ enregistré" : "Enregistrer"}
</button>
</div>
{msg && <p className="px-1 text-sm text-ink-muted">{msg}</p>}
<div className="card divide-y divide-ink-edge">
{cast.characters.map((c) => (
<div key={c.name} className="flex items-center gap-3 px-4 py-2.5">
<div className="flex-1 min-w-0">
<p className="truncate font-serif text-sm">{c.name}</p>
{c.aliases?.length > 0 && (
<p className="truncate text-xs text-ink-muted">alias : {c.aliases.join(", ")}</p>
)}
{c.description && <p className="truncate text-xs text-ink-muted">{c.description}</p>}
</div>
<span className="chip bg-ink-edge text-ink-muted">
{c.gender === "male" ? "homme" : c.gender === "female" ? "femme" : "?"}
</span>
<VoiceSelect voices={voices} value={c.voice_id}
onChange={(v) => setChar(c.name, v)} />
<button className="btn-ghost" onClick={() => preview(c.voice_id)}>
{playing === c.voice_id ? "♪" : "▶"}
</button>
</div>
))}
</div>
</div>
);
}

98
frontend/src/Chapters.jsx Normal file
View File

@@ -0,0 +1,98 @@
import React, { useEffect, useState } from "react";
import { api } from "./api.js";
import { StatusChip, ProgressBar } from "./ui.jsx";
export default function Chapters({ slug, book, state, busy }) {
const chapters = book.chapters.filter((c) => c.render);
const [backend, setBackend] = useState("kokoro");
const [mono, setMono] = useState(false);
const [selected, setSelected] = useState(() => new Set());
// Initialise le moteur sur le backend par defaut des reglages.
useEffect(() => {
api.getSettings().then((s) => s?.default_backend && setBackend(s.default_backend)).catch(() => {});
}, []);
const toggle = (idx) => {
const next = new Set(selected);
next.has(idx) ? next.delete(idx) : next.add(idx);
setSelected(next);
};
const renderChapters = (indexes) => {
if (!indexes.length) return;
api.render(slug, indexes, backend, mono);
};
return (
<div className="space-y-4">
<div className="card flex flex-wrap items-center gap-3 p-3">
<label className="text-sm text-ink-muted">Moteur</label>
<select className="input" value={backend} onChange={(e) => setBackend(e.target.value)}>
<option value="kokoro">Kokoro (rapide)</option>
<option value="qwen3">Qwen3 (qualité + clonage)</option>
</select>
<label className="flex items-center gap-2 text-sm text-ink-muted">
<input type="checkbox" checked={mono} onChange={(e) => setMono(e.target.checked)} />
mono-narrateur
</label>
<div className="ml-auto flex gap-2">
<button className="btn-ghost" disabled={busy || !selected.size}
onClick={() => renderChapters([...selected])}>
Rendre la sélection ({selected.size})
</button>
<button className="btn-primary" disabled={busy}
onClick={() => renderChapters(chapters.map((c) => c.index))}>
Rendre tout
</button>
</div>
</div>
<div className="card divide-y divide-ink-edge">
{chapters.map((c) => {
const rs = state.render?.[c.index] || state.render?.[String(c.index)] || {};
const analyzed = (state.analyzed_chapters || []).includes(c.index);
return (
<div key={c.index} className="flex items-center gap-3 px-4 py-2.5">
<input type="checkbox" checked={selected.has(c.index)}
onChange={() => toggle(c.index)} />
<div className="w-9 text-center text-xs text-ink-muted">{c.index}</div>
<div className="flex-1 min-w-0">
<p className="truncate font-serif text-sm">{c.title}</p>
<div className="mt-0.5 flex items-center gap-2 text-xs text-ink-muted">
<span>{c.word_count} mots</span>
{c.pov && <span className="chip bg-ink-edge text-ink-muted">{c.pov}</span>}
{analyzed && <span className="text-emerald-400">analysé</span>}
</div>
{rs.status === "running" && (
<div className="mt-1.5 max-w-xs"><ProgressBar value={rs.progress} /></div>
)}
</div>
{rs.status && <StatusChip status={rs.status} />}
{rs.mp3 && (
<>
<audio controls src={api.audioUrl(slug, c.index)} className="h-8" />
<a className="btn-ghost" href={api.audioUrl(slug, c.index)} download></a>
</>
)}
{!busy && (
<>
<button className="btn-ghost" title={analyzed ? "Ré-analyser ce chapitre" : "Analyser ce chapitre"}
onClick={() => api.analyze(slug, [c.index])}>
{analyzed ? "Ré-analyser" : "Analyser"}
</button>
<button className="btn-ghost" title="Ré-analyser le casting de ce chapitre (sans re-segmenter)"
onClick={() => api.castAnalyze(slug, [c.index])}>
Casting
</button>
<button className="btn-ghost" title="Rendre ce chapitre"
onClick={() => renderChapters([c.index])}></button>
</>
)}
</div>
);
})}
</div>
</div>
);
}

80
frontend/src/Library.jsx Normal file
View File

@@ -0,0 +1,80 @@
import React, { useEffect, useRef, useState } from "react";
import { api } from "./api.js";
import { Spinner } from "./ui.jsx";
export default function Library({ onOpen }) {
const [books, setBooks] = useState(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState(null);
const fileRef = useRef();
const refresh = () => api.listBooks().then(setBooks).catch((e) => setError(String(e)));
useEffect(() => { refresh(); }, []);
const upload = async (file) => {
if (!file) return;
setUploading(true);
setError(null);
try {
const { slug } = await api.uploadBook(file);
await refresh();
onOpen(slug);
} catch (e) {
setError("Échec de l'import : " + e);
} finally {
setUploading(false);
}
};
return (
<div className="space-y-8">
<section
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => { e.preventDefault(); upload(e.dataTransfer.files[0]); }}
className="card flex flex-col items-center justify-center gap-3 border-dashed py-12 text-center"
>
<div className="text-4xl">📖</div>
<p className="font-serif text-lg">Déposez un fichier EPUB</p>
<p className="text-sm text-ink-muted">ou</p>
<button className="btn-primary" disabled={uploading}
onClick={() => fileRef.current?.click()}>
{uploading ? <Spinner /> : null}
{uploading ? "Import en cours…" : "Choisir un fichier"}
</button>
<input ref={fileRef} type="file" accept=".epub" className="hidden"
onChange={(e) => upload(e.target.files[0])} />
</section>
{error && <p className="text-sm text-red-400">{error}</p>}
<section>
<h2 className="mb-3 font-serif text-lg text-ink-muted">Bibliothèque</h2>
{books === null ? (
<p className="text-ink-muted"><Spinner /> chargement</p>
) : books.length === 0 ? (
<p className="text-ink-muted">Aucun livre pour l'instant.</p>
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{books.map((b) => (
<button key={b.slug} onClick={() => onOpen(b.slug)}
className="card group overflow-hidden text-left transition-transform hover:-translate-y-1">
<div className="aspect-[2/3] w-full bg-ink-edge">
{b.cover && (
<img src={b.cover} alt="" className="h-full w-full object-cover" />
)}
</div>
<div className="p-3">
<p className="line-clamp-2 font-serif text-sm">{b.title}</p>
<p className="mt-1 text-xs text-ink-muted">{b.author}</p>
<p className="mt-2 text-xs text-ink-accent">
{b.rendered}/{b.chapters} chapitres rendus
</p>
</div>
</button>
))}
</div>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import React, { useEffect, useState } from "react";
import { api } from "./api.js";
import { Spinner } from "./ui.jsx";
export default function PronunciationEditor({ slug }) {
const [entries, setEntries] = useState(null);
const [saved, setSaved] = useState(false);
useEffect(() => {
api.getPron(slug).then((d) => setEntries(d.entries || []));
}, [slug]);
if (entries === null) return <p className="text-ink-muted"><Spinner /> chargement</p>;
const dirty = () => setSaved(false);
const setRow = (i, patch) => {
setEntries(entries.map((e, j) => (j === i ? { ...e, ...patch } : e)));
dirty();
};
const add = () => { setEntries([...entries, { term: "", replacement: "", enabled: true }]); dirty(); };
const remove = (i) => { setEntries(entries.filter((_, j) => j !== i)); dirty(); };
const save = async () => {
await api.putPron(slug, { entries: entries.filter((e) => e.term) });
setSaved(true);
};
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<p className="text-sm text-ink-muted">
Corrigez la graphie des mots mal prononcés. La colonne « prononciation » remplace le terme avant la synthèse.
</p>
<button className="btn-ghost ml-auto" onClick={add}>+ ajouter</button>
<button className="btn-primary" onClick={save}>{saved ? "✓ enregistré" : "Enregistrer"}</button>
</div>
{entries.length === 0 ? (
<p className="text-ink-muted">Aucune entrée. Lancez l'étape <b>Prononciations</b> ou ajoutez-en.</p>
) : (
<div className="card divide-y divide-ink-edge">
<div className="grid grid-cols-[1fr_1fr_auto_auto] gap-3 px-4 py-2 text-xs uppercase text-ink-muted">
<span>Terme</span><span>Prononciation</span><span>Actif</span><span></span>
</div>
{entries.map((e, i) => (
<div key={i} className="grid grid-cols-[1fr_1fr_auto_auto] items-center gap-3 px-4 py-2">
<input className="input" value={e.term}
onChange={(ev) => setRow(i, { term: ev.target.value })} />
<input className="input" value={e.replacement}
onChange={(ev) => setRow(i, { replacement: ev.target.value })} />
<input type="checkbox" checked={e.enabled !== false}
onChange={(ev) => setRow(i, { enabled: ev.target.checked })} />
<button className="text-ink-muted hover:text-red-400" onClick={() => remove(i)}></button>
</div>
))}
</div>
)}
</div>
);
}

142
frontend/src/Settings.jsx Normal file
View File

@@ -0,0 +1,142 @@
import React, { useEffect, useState } from "react";
import { api } from "./api.js";
import { Spinner } from "./ui.jsx";
// Description declarative des champs, groupes par section.
const SECTIONS = [
{
title: "Modèles (identifiants MLX / HuggingFace)",
hint: "Changer un identifiant recharge un autre modèle (peut déclencher un téléchargement au prochain usage).",
fields: [
{ key: "gemma_model", label: "Gemma (analyse)", type: "text" },
{ key: "qwen3_model", label: "Qwen3-TTS (rendu)", type: "text" },
{ key: "kokoro_model", label: "Kokoro (preview)", type: "text" },
],
},
{
title: "Génération Gemma",
hint: "Paramètres d'échantillonnage de l'analyse (locuteurs, personnages, prononciations).",
fields: [
{ key: "gemma_temperature", label: "Température", type: "number", step: 0.05, min: 0, max: 2 },
{ key: "gemma_max_tokens", label: "Max tokens", type: "number", step: 1, min: 64, max: 8192 },
],
},
{
title: "Prompts système (analyse)",
hint: "Instructions envoyées à Gemma avant chaque tâche. Le modèle doit répondre en JSON.",
fields: [
{ key: "prompt_speakers", label: "Attribution des locuteurs", type: "textarea" },
{ key: "prompt_characters", label: "Extraction des personnages", type: "textarea" },
{ key: "prompt_pronunciation", label: "Mots à risque (prononciation)", type: "textarea" },
],
},
{
title: "Casting (déduplication)",
hint: "Le rapprochement des variantes de noms (Holden / James Holden / James) est heuristique et sûr. La passe Gemma ajoute les variantes non évidentes (diminutifs, titres) mais, avec un petit modèle local, produit des fusions erronées.",
fields: [
{ key: "dedup_use_gemma", label: "Affiner la déduplication avec Gemma (moins sûr)", type: "checkbox" },
],
},
{
title: "TTS (voix par défaut)",
hint: "Backend et voix utilisés par défaut pour le rendu et les replis.",
fields: [
{ key: "default_backend", label: "Backend par défaut", type: "select",
options: [["kokoro", "Kokoro (rapide)"], ["qwen3", "Qwen3 (qualité + clonage)"]] },
{ key: "language", label: "Langue (Qwen3)", type: "text" },
{ key: "kokoro_lang_code", label: "Code langue Kokoro", type: "text" },
{ key: "kokoro_default_voice", label: "Voix Kokoro par défaut", type: "text" },
{ key: "qwen3_default_voice", label: "Voix Qwen3 par défaut", type: "text" },
],
},
{
title: "Audio (encodage final)",
hint: "Appliqué à la concaténation et à l'export MP3.",
fields: [
{ key: "target_sample_rate", label: "Sample rate (Hz)", type: "number", step: 1000, min: 8000, max: 48000 },
{ key: "mp3_bitrate", label: "Bitrate MP3", type: "text" },
{ key: "target_dbfs", label: "Normalisation (dBFS)", type: "number", step: 0.5, min: -40, max: 0 },
],
},
];
function Field({ field, value, onChange }) {
const common = "input w-full";
if (field.type === "checkbox")
return <input type="checkbox" className="h-4 w-4"
checked={!!value} onChange={(e) => onChange(e.target.checked)} />;
if (field.type === "textarea")
return <textarea className={`${common} min-h-[5rem] resize-y text-sm`} rows={4}
value={value ?? ""} onChange={(e) => onChange(e.target.value)} />;
if (field.type === "select")
return <select className={common} value={value ?? ""} onChange={(e) => onChange(e.target.value)}>
{field.options.map(([v, lbl]) => <option key={v} value={v}>{lbl}</option>)}
</select>;
if (field.type === "number")
return <input className={common} type="number"
step={field.step} min={field.min} max={field.max}
value={value ?? ""} onChange={(e) => onChange(e.target.value === "" ? "" : Number(e.target.value))} />;
return <input className={common} type="text"
value={value ?? ""} onChange={(e) => onChange(e.target.value)} />;
}
export default function Settings({ onBack }) {
const [settings, setSettings] = useState(null);
const [saved, setSaved] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
api.getSettings().then(setSettings).catch((e) => setError(String(e)));
}, []);
if (error) return <p className="text-sm text-red-400">{error}</p>;
if (!settings) return <p className="text-ink-muted"><Spinner /> chargement des réglages</p>;
const set = (key, val) => { setSettings({ ...settings, [key]: val }); setSaved(false); };
const save = async () => {
setError(null);
try { await api.putSettings(settings); setSaved(true); }
catch (e) { setError("Échec de l'enregistrement : " + e); }
};
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<button onClick={onBack} className="text-sm text-ink-muted hover:text-ink-text"> Bibliothèque</button>
<h1 className="font-serif text-2xl">Réglages techniques</h1>
<button className="btn-primary ml-auto" onClick={save}>
{saved ? "✓ enregistré" : "Enregistrer"}
</button>
</div>
<p className="text-sm text-ink-muted">
Réglages globaux appliqués à toute l'app. Les changements de modèle prennent effet au
prochain lancement d'analyse ou de rendu.
</p>
{SECTIONS.map((sec) => (
<section key={sec.title} className="card p-4 space-y-3">
<div>
<h2 className="font-medium">{sec.title}</h2>
{sec.hint && <p className="text-xs text-ink-muted">{sec.hint}</p>}
</div>
<div className="grid gap-3">
{sec.fields.map((f) => (
<label key={f.key} className="grid gap-1">
<span className="text-sm text-ink-muted">{f.label}</span>
<Field field={f} value={settings[f.key]} onChange={(v) => set(f.key, v)} />
</label>
))}
</div>
</section>
))}
<div className="flex justify-end">
<button className="btn-primary" onClick={save}>
{saved ? "✓ enregistré" : "Enregistrer"}
</button>
</div>
</div>
);
}

64
frontend/src/api.js Normal file
View File

@@ -0,0 +1,64 @@
// Client API InkFlow : wrappers fetch + abonnement WebSocket a l'etat.
async function j(url, opts) {
const res = await fetch(url, opts);
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const ct = res.headers.get("content-type") || "";
return ct.includes("application/json") ? res.json() : res;
}
const json = (method, body) => ({
method,
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
export const api = {
listBooks: () => j("/api/books"),
uploadBook: (file) => {
const fd = new FormData();
fd.append("file", file);
return j("/api/books", { method: "POST", body: fd });
},
getBook: (slug) => j(`/api/books/${slug}`),
getChapter: (slug, idx) => j(`/api/books/${slug}/chapters/${idx}`),
putAnalysis: (slug, idx, analysis) =>
j(`/api/books/${slug}/chapters/${idx}/analysis`, json("PUT", analysis)),
analyze: (slug, chapters) => j(`/api/books/${slug}/analyze`, json("POST", { chapters })),
pronounce: (slug) => j(`/api/books/${slug}/pronounce`, json("POST")),
castAuto: (slug) => j(`/api/books/${slug}/cast/auto`, json("POST")),
castAnalyze: (slug, chapters) =>
j(`/api/books/${slug}/cast/analyze`, json("POST", { chapters })),
castDedup: (slug) => j(`/api/books/${slug}/cast/dedup`, json("POST")),
render: (slug, chapters, backend, mono) =>
j(`/api/books/${slug}/render`, json("POST", { chapters, backend, mono })),
getCast: (slug) => j(`/api/books/${slug}/cast`),
putCast: (slug, cast) => j(`/api/books/${slug}/cast`, json("PUT", cast)),
getPron: (slug) => j(`/api/books/${slug}/pronunciation`),
putPron: (slug, pron) => j(`/api/books/${slug}/pronunciation`, json("PUT", pron)),
getSettings: () => j("/api/settings"),
putSettings: (settings) => j("/api/settings", json("PUT", settings)),
audioUrl: (slug, idx) => `/api/books/${slug}/audio/${idx}`,
coverUrl: (slug) => `/api/books/${slug}/cover`,
previewVoice: async (voiceId, text) => {
const res = await fetch("/api/voicebank/preview", json("POST", { voice_id: voiceId, text }));
if (!res.ok) throw new Error("preview");
return URL.createObjectURL(await res.blob());
},
};
// Abonnement temps reel a l'etat d'un livre. Reconnecte automatiquement.
export function subscribeState(slug, onState) {
let ws, closed = false;
const connect = () => {
const proto = location.protocol === "https:" ? "wss" : "ws";
ws = new WebSocket(`${proto}://${location.host}/ws/${slug}`);
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === "state") onState(msg.state);
};
ws.onclose = () => { if (!closed) setTimeout(connect, 1500); };
};
connect();
return () => { closed = true; ws && ws.close(); };
}

37
frontend/src/index.css Normal file
View File

@@ -0,0 +1,37 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
}
body {
margin: 0;
background: #14110f;
color: #ede4d8;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
@layer components {
.btn {
@apply inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium
transition-colors disabled:opacity-40 disabled:cursor-not-allowed;
}
.btn-primary {
@apply btn bg-ink-accent text-ink-bg hover:bg-ink-accent2;
}
.btn-ghost {
@apply btn border border-ink-edge text-ink-text hover:bg-ink-edge;
}
.card {
@apply rounded-lg border border-ink-edge bg-ink-panel;
}
.chip {
@apply inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium;
}
.input {
@apply rounded-md border border-ink-edge bg-ink-bg px-2 py-1 text-sm
text-ink-text outline-none focus:border-ink-accent;
}
}

6
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,6 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./index.css";
createRoot(document.getElementById("root")).render(<App />);

35
frontend/src/ui.jsx Normal file
View File

@@ -0,0 +1,35 @@
// Petits widgets partages.
import React from "react";
const STATUS_STYLE = {
done: "bg-emerald-900/50 text-emerald-300",
running: "bg-ink-accent/20 text-ink-accent",
error: "bg-red-900/50 text-red-300",
pending: "bg-ink-edge text-ink-muted",
};
const STATUS_LABEL = { done: "terminé", running: "en cours", error: "erreur", pending: "en attente" };
export function StatusChip({ status }) {
return (
<span className={`chip ${STATUS_STYLE[status] || STATUS_STYLE.pending}`}>
{STATUS_LABEL[status] || status}
</span>
);
}
export function ProgressBar({ value }) {
return (
<div className="h-1.5 w-full overflow-hidden rounded-full bg-ink-edge">
<div
className="h-full bg-ink-accent transition-all duration-300"
style={{ width: `${Math.round((value || 0) * 100)}%` }}
/>
</div>
);
}
export function Spinner() {
return (
<span className="inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-ink-accent border-t-transparent" />
);
}