Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,12 @@ Déposer plusieurs fichiers ouvre « Organiser les enregistrements ».
Ils restent séparés par défaut. Avec la poignée, déposer un audio au
centre d’un autre les réunit ; le déposer au bord change seulement l’ordre.
On peut ajouter d’autres audios à un groupe et ordonner ses morceaux.
La poignée d’une réunion déplace tout le groupe ; celles des morceaux
changent seulement leur ordre interne. « Modifier les morceaux » ouvre
un éditeur limité à la réunion courante, sans toucher à la file.
Saisir une poignée révèle une corbeille temporaire : y déposer la réunion
ou le morceau le retire de la sélection. « Annuler le retrait » le restaure.
Les fichiers originaux ne sont jamais supprimés. Au clavier, Suppr retire.
Au clavier, les flèches déplacent ; Entrée choisit la réunion, puis Entrée
sur une autre poignée les réunit. Échap annule le choix. « Séparer » et « Annuler » permettent
de revenir sur ce choix. « Organiser » rouvre la fenêtre depuis la file.
Expand All @@ -161,11 +167,16 @@ du navigateur. Chaque morceau est normalisé en AAC mono 48 kHz, puis ses
paquets rejoignent une piste unique sans second réencodage. Les originaux
restent intacts ; les vidéos restent séparées. La transcription et
l'archivage suivent ensuite le parcours habituel, pour une seule réunion.
Le lecteur marque les jonctions et propose un accès à chaque morceau.
Les repères utilisent les durées réellement encodées, suivent le rognage
et restent disponibles après archivage ou lors d’un retraitement. Les
anciennes réunions sans repères gardent le lecteur habituel.

La file prépare les contextes en arrière-plan, une recherche d'avance à
la fois. Le formulaire réutilise une recherche déjà terminée ou rejoint
celle en cours. Aucun traitement ni dépôt Odoo ne démarre à ce stade.
Une recherche indisponible laisse la saisie manuelle possible. Changer
Une recherche indisponible laisse la saisie manuelle possible. Un pictogramme
par fichier et un résumé commun indiquent l’état des contextes, sans bulles. Changer
l'ordre des morceaux crée un contexte distinct ; changer seulement
l'ordre des réunions réutilise leurs contextes.

Expand Down
28 changes: 28 additions & 0 deletions web/app/audio_parts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Repères d’écoute du fichier assemblé, relatifs à la partie conservée."""

import json
import math


def audio_parts(context_json: str | None, duration: float) -> list[dict]:
"""Une ancienne réunion ou des métadonnées invalides restent lisibles."""
try:
context = json.loads(context_json or "{}")
parts = context.get("audio_parts", []) if isinstance(context, dict) else []
if not isinstance(parts, list) or len(parts) > 200:
return []
result, previous = [], 0.0
for part in parts:
if not isinstance(part, dict):
return []
name, start, end = part.get("name"), part.get("start"), part.get("end")
if (not isinstance(name, str) or not name or len(name) > 500
or any(isinstance(n, bool) or not isinstance(n, (int, float))
or not math.isfinite(n) for n in (start, end))
or start < previous or end <= start or end > duration + 0.1):
return []
result.append({"name": name, "start": start, "end": end})
previous = end
return result
except (TypeError, ValueError, OverflowError):
return []
3 changes: 3 additions & 0 deletions web/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
provider_for_model,
)

from .audio_parts import audio_parts
from . import avancement
from .auth import AccessVerifier, AuthError
from .chatter import ChatterError, composer
Expand Down Expand Up @@ -1090,6 +1091,7 @@ def list_jobs(
"status": job["status"],
"model": job["model"],
"duration_seconds": job["duration_seconds"],
"audio_parts": audio_parts(job["context_json"], job["duration_seconds"]),
"cost_usd": job["cloud_cost_usd"],
"created_at": job["created_at"],
"has_versions": bool(job["previous_versions_json"]),
Expand Down Expand Up @@ -1156,6 +1158,7 @@ def job_detail(job_id: int, owner_id: int = Depends(current_user)) -> dict:
"reprocessable": job["status"] in REPROCESSABLE and not job["transcript"],
"model": job["model"],
"duration_seconds": job["duration_seconds"],
"audio_parts": audio_parts(job["context_json"], job["duration_seconds"]),
"cost_usd": job["cloud_cost_usd"],
"transcript": job["transcript"] or "",
"speakers": json.loads(job["speaker_map_json"] or "{}"),
Expand Down
14 changes: 14 additions & 0 deletions web/client/src/audio-parts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/** Repères relatifs au fichier réellement écouté / archivé, rognage compris. */
export function clipAudioParts(parts, start = 0, end = Infinity) {
if (!Array.isArray(parts) || !Number.isFinite(start) || start < 0 || !(end > start)) return [];
let previous = 0;
const result = [];
for (const part of parts) {
if (!part || !Number.isFinite(part.start) || !Number.isFinite(part.end) ||
part.start < previous || part.end <= part.start || typeof part.name !== 'string') return [];
previous = part.end;
const left = Math.max(start, part.start), right = Math.min(end, part.end);
if (right > left) result.push({ name: part.name, start: left - start, end: right - start });
}
return result;
}
1 change: 1 addition & 0 deletions web/client/src/components/Apercu.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export function Apercu({ fichier, duree, debut, fin, surDebut, surFin, actif })
start={debut}
end={fin}
duration={duree}
parts={fichier.audioParts}
onTrim={rogner}
onUnreadable={() => setLisible(false)}
inert={actif}
Expand Down
13 changes: 6 additions & 7 deletions web/client/src/components/Bibliotheque.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { MOD, useRaccourcis } from '../raccourcis.js';
import { surlignerMots } from '../surlignage.jsx';
import { Welcome } from './Welcome.jsx';
import { FileOrganizer } from './FileOrganizer.jsx';
import { PreparationStatus } from './PreparationStatus.jsx';
import { PreparationStatus, PreparationSummary } from './PreparationStatus.jsx';
import { uniqueFiles } from '../organizer.js';
import { interrompreReunion } from '../usePipeline.js';
import {
Expand Down Expand Up @@ -599,18 +599,17 @@ function ALancer({ surLancer }) {
<h2 className="titre text-[1.0625rem] font-medium">
À lancer <span className="tabular-nums text-ekn-text-muted">{enAttente.length}</span>
</h2>
<span className="text-ekn-sm text-ekn-text-muted">
Restés sur ton poste : la liste se vide si tu fermes l’onglet.
</span>
<PreparationSummary files={enAttente.map((entry) => entry.file)} />
{(enAttente.length > 1 || enAttente.some((entry) => entry.file.mergedParts)) && <button className="ekn-button ekn-button--subtle ekn-button--compact"
onClick={() => setOrganisation(enAttente.map((entry) => entry.file))}>Organiser</button>}
</div>
<ul className="mt-2 divide-y divide-bord/60">
{enAttente.map((entree) => (
<li key={entree.id} className="flex items-center gap-3 py-2 text-ekn-sm">
<span className="min-w-0 flex-1 truncate">{entree.file.name}<PreparationStatus file={entree.file} /></span>
<span className="whitespace-nowrap text-ekn-text-muted">{jour(new Date(entree.file.lastModified).toISOString())}</span>
<span className="w-16 whitespace-nowrap text-right tabular-nums text-ekn-text-muted">{fileSize(entree.file.size)}</span>
<PreparationStatus file={entree.file} />
<span className="min-w-0 flex-1 truncate" title={entree.file.name}>{entree.file.name}</span>
<span className="hidden md:block whitespace-nowrap text-ekn-text-muted">{jour(new Date(entree.file.lastModified).toISOString())}</span>
<span className="hidden sm:block w-16 whitespace-nowrap text-right tabular-nums text-ekn-text-muted">{fileSize(entree.file.size)}</span>
<button
type="button"
onClick={() => { requestLaunch(entree.id); surLancer?.(); }}
Expand Down
6 changes: 4 additions & 2 deletions web/client/src/components/Detail.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ export function Detail({ jobId, recherche = '', surRetour, surRetraiter }) {
</div>
</div>
<Video jobId={jobId} video={fiche.video} filename={fiche.filename} surMaj={recharger}
duree={fiche.duration_seconds} meetingDate={fiche.meeting_date}
parts={fiche.audio_parts} duree={fiche.duration_seconds} meetingDate={fiche.meeting_date}
lecteur={lecteur} surTemps={setEcoute} />
{trouver ? (
<div className="mt-3 flex items-center gap-2 rounded-lg border border-bord bg-ekn-field px-3 py-1.5">
Expand Down Expand Up @@ -364,7 +364,7 @@ const DEBIT_LOURD = 1_000_000;
// Sans durée, seule la taille parle : une heure compressée pèse ~150 Mo.
const TAILLE_LOURDE = 200 * 1024 * 1024;

function Video({ jobId, video, filename, duree, meetingDate, surMaj, lecteur, surTemps }) {
function Video({ jobId, video, filename, duree, parts, meetingDate, surMaj, lecteur, surTemps }) {
const audio = AUDIO.test(filename || '');
const tache = useArchivage(jobId);
const [etape, setEtape] = useState('repos'); // repos → question → lecture | allegement
Expand Down Expand Up @@ -397,6 +397,8 @@ function Video({ jobId, video, filename, duree, meetingDate, surMaj, lecteur, su
src={`/api/jobs/${jobId}/video`}
autoPlay
onTime={surTemps}
duration={duree}
parts={parts}
/>
);
}
Expand Down
Loading
Loading