hlassiege/darylpublic Fork
⑂ c637961
Commits
⬇ Clone ▾
git clone https://git.rickub.com/hlassiege/daryl.git
git clone ssh://git@rickub.com/hlassiege/daryl.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat(montage): queue Daryl's requests and run up to three in parallel

Each chat request becomes its own task with its own conversation. Daryl
works on a private copy of the timeline (montage/daryl/<task>/) that is
three-way merged into the client's timeline at the end of the turn, so
manual edits (volume, cuts, moves, undo) are no longer locked while he
works; on conflicts the client's version wins and the reply says so.

- three worker queues; a request whose range overlaps an earlier running
  one waits for it, whole-video requests wait for the others
- motion ids reserved per task, so parallel turns never write the same
  composition; undo reverts only the compositions a turn touched
- timeline revisions (montage/.revs) let a stale edit merge into a newer
  version instead of overwriting it
- side panel: request queue (status, scope, stop, remove), reopening a
  request shows its thread and selects its scope

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hlassiege committed 2026-10-11T14:39:25+02:00 Browse files
c637961 parent: 2847388
modified backend/studio/modules/projects/api.py +55 -22
@@ -1201,13 +1201,27 @@ def capture_sources(pid: str):
12011201
12021202 # --- Montage de la vidéo longue ----------------------------------------------------------
12031203
1204-def _montage_job(pid: str) -> jobs.Job | None:
1205- return jobs.get(f"{pid}-montage")
1204+def _montage_tasks(pid: str) -> list[jobs.Job]:
1205+ """Les demandes faites à Daryl dans le chat du montage, une tâche (et une conversation) chacune, dans l'ordre où
1206+ elles ont été faites ; le worker en traite plusieurs à la fois (montage.TASK_SLOTS)."""
1207+ found = [j for j in jobs.list_jobs(service.MODULE) if j.kind in montage.TASK_KINDS and j.payload.get("project") == pid]
1208+ return sorted(found, key=lambda j: j.created_at)
1209+
1210+
1211+def _montage_task(pid: str, task: str) -> jobs.Job:
1212+ job = jobs.get(task)
1213+ if not job or job.kind not in montage.TASK_KINDS or job.payload.get("project") != pid:
1214+ raise HTTPException(404, "demande introuvable")
1215+ return job
1216+
1217+
1218+def _task_status(job: jobs.Job) -> str:
1219+ return job.status if job.status == "queued" or job.finished else "running"
12061220
12071221
12081222 def _montage_busy(pid: str) -> bool:
1209- job = _montage_job(pid)
1210- return bool(job and not job.finished)
1223+ """Daryl est en train de traiter une demande (pas seulement en file)."""
1224+ return any(_task_status(j) == "running" for j in _montage_tasks(pid))
12111225
12121226
12131227 def _montage_view(pid: str) -> dict:
@@ -1215,8 +1229,9 @@ def _montage_view(pid: str) -> dict:
12151229 source = montage._source(project)
12161230 timeline = montage.load(pid)
12171231 montage.ensure_media(pid, project)
1218- job = _montage_job(pid)
1219- chat = job.state if job else {}
1232+ tasks = _montage_tasks(pid)
1233+ # l'utilisation de l'abonnement : la dernière connue
1234+ last = max((j for j in tasks if j.state.get("usage")), key=lambda j: j.started_at or 0, default=None)
12201235 path = montage.export_path(pid)
12211236 return {
12221237 "timeline": timeline,
@@ -1231,9 +1246,10 @@ def _montage_view(pid: str) -> dict:
12311246 "can_undo": bool(montage.history_steps(pid)),
12321247 "export": {"rel": path.relative_to(service.project_dir(pid)).as_posix(), "name": path.name,
12331248 "exists": path.is_file()},
1234- "chat": {"messages": chat.get("messages", []), "busy": bool(job and not job.finished),
1235- "activity": chat.get("activity"), "usage": chat.get("usage"),
1236- "error": job.error if job and job.status == "error" else None},
1249+ "tasks": [{"id": j.id, "status": _task_status(j), "messages": j.state.get("messages", []),
1250+ "activity": j.state.get("activity"), "error": j.error if j.status == "error" else None} for j in tasks],
1251+ "busy": any(_task_status(j) == "running" for j in tasks),
1252+ "usage": last.state.get("usage") if last else None,
12371253 }
12381254
12391255
@@ -1288,8 +1304,8 @@ class MontageChatRequest(BaseModel):
12881304 frame: float | None = None
12891305 # l'image telle que le client la voit dans l'aperçu (A-roll, B-roll, motion, beats) : data URL JPEG
12901306 image: str | None = None
1291- # repartir d'une discussion vierge (nouvelle session Claude, fil effacé)
1292- fresh: bool = False
1307+ # la demande à laquelle on répond (sa conversation continue) ; absente : une nouvelle demande, mise en file
1308+ task: str | None = None
12931309
12941310
12951311 def _save_view(pid: str, data_url: str) -> str:
@@ -1313,35 +1329,52 @@ def montage_chat(pid: str, req: MontageChatRequest):
13131329 _check(pid)
13141330 if not req.text.strip():
13151331 raise HTTPException(400, "message vide")
1316- job = _montage_job(pid)
1332+ job = _montage_task(pid, req.task) if req.task else None
13171333 state = job.state if job else {"messages": []}
1318- if req.fresh:
1319- if _montage_busy(pid):
1320- raise HTTPException(409, "le monteur est en train de modifier la timeline")
1321- state = {"messages": [], "usage": state.get("usage")}
1322- elif job and job.status == "error" and state["messages"] and state["messages"][-1]["role"] == "user":
1334+ # une demande ratée ou arrêtée avant d'être traitée : remplacée par la nouvelle
1335+ if job and job.status in ("error", "cancelled") and state["messages"] and state["messages"][-1]["role"] == "user":
13231336 state["messages"].pop()
13241337 state["messages"].append({"role": "user", "text": req.text.strip(), "selection": req.selection or None,
13251338 "frame": req.frame, "image": _save_view(pid, req.image) if req.image else None,
13261339 "at": time.time()})
1340+ # la file la moins chargée : plusieurs demandes sont traitées à la fois
1341+ slot = montage.task_slot()
1342+ if job and job.finished:
1343+ jobs.update(job.id, kind=slot)
13271344 try:
1328- service.requeue(f"{pid}-montage", "project_montage", {"project": pid}, state)
1345+ job = service.requeue(job.id if job else f"{pid}-montage-{uuid.uuid4().hex[:8]}", slot, {"project": pid}, state)
13291346 except ValueError as e:
13301347 raise HTTPException(409, str(e)) from e
13311348 with service.editing(pid) as p:
13321349 service.start_step(p, "montage")
1333- return _montage_view(pid)
1350+ return {**_montage_view(pid), "task": job.id}
1351+
1352+
1353+class MontageTaskRequest(BaseModel):
1354+ task: str
13341355
13351356
13361357 @router.post("/{pid}/montage/stop")
1337-def stop_montage(pid: str):
1358+def stop_montage(pid: str, req: MontageTaskRequest):
1359+ """Arrête une demande en cours, ou la retire de la file."""
13381360 _check(pid)
1339- job = _montage_job(pid)
1340- if job and not job.finished:
1361+ job = _montage_task(pid, req.task)
1362+ if not job.finished:
13411363 jobs.cancel(job)
13421364 return _montage_view(pid)
13431365
13441366
1367+@router.delete("/{pid}/montage/tasks/{task}")
1368+def delete_montage_task(pid: str, task: str):
1369+ """Retire une demande terminée de la liste (ses changements restent dans la timeline)."""
1370+ _check(pid)
1371+ job = _montage_task(pid, task)
1372+ if not job.finished:
1373+ raise HTTPException(409, "Daryl travaille sur cette demande : arrête-la d'abord")
1374+ jobs.delete(job.id)
1375+ return _montage_view(pid)
1376+
1377+
13451378 class StoryboardRequest(BaseModel):
13461379 density: str = storyboard.DEFAULT_DENSITY
13471380 # consigne de l'auteur (« peu de plein écran », « insiste sur les chiffres »…)
@@ -1201,13 +1201,27 @@ def capture_sources(pid: str):
1201 1201
1202 # --- Montage de la vidéo longue ----------------------------------------------------------1202 # --- Montage de la vidéo longue ----------------------------------------------------------
1203 1203
1204-def _montage_job(pid: str) -> jobs.Job | None:1204+def _montage_tasks(pid: str) -> list[jobs.Job]:
1205- return jobs.get(f"{pid}-montage")1205+ """Les demandes faites à Daryl dans le chat du montage, une tâche (et une conversation) chacune, dans l'ordre où
1206+ elles ont été faites ; le worker en traite plusieurs à la fois (montage.TASK_SLOTS)."""
1207+ found = [j for j in jobs.list_jobs(service.MODULE) if j.kind in montage.TASK_KINDS and j.payload.get("project") == pid]
1208+ return sorted(found, key=lambda j: j.created_at)
1209+
1210+
1211+def _montage_task(pid: str, task: str) -> jobs.Job:
1212+ job = jobs.get(task)
1213+ if not job or job.kind not in montage.TASK_KINDS or job.payload.get("project") != pid:
1214+ raise HTTPException(404, "demande introuvable")
1215+ return job
1216+
1217+
1218+def _task_status(job: jobs.Job) -> str:
1219+ return job.status if job.status == "queued" or job.finished else "running"
1206 1220
1207 1221
1208 def _montage_busy(pid: str) -> bool:1222 def _montage_busy(pid: str) -> bool:
1209- job = _montage_job(pid)1223+ """Daryl est en train de traiter une demande (pas seulement en file)."""
1210- return bool(job and not job.finished)1224+ return any(_task_status(j) == "running" for j in _montage_tasks(pid))
1211 1225
1212 1226
1213 def _montage_view(pid: str) -> dict:1227 def _montage_view(pid: str) -> dict:
@@ -1215,8 +1229,9 @@ def _montage_view(pid: str) -> dict:
1215 source = montage._source(project)1229 source = montage._source(project)
1216 timeline = montage.load(pid)1230 timeline = montage.load(pid)
1217 montage.ensure_media(pid, project)1231 montage.ensure_media(pid, project)
1218- job = _montage_job(pid)1232+ tasks = _montage_tasks(pid)
1219- chat = job.state if job else {}1233+ # l'utilisation de l'abonnement : la dernière connue
1234+ last = max((j for j in tasks if j.state.get("usage")), key=lambda j: j.started_at or 0, default=None)
1220 path = montage.export_path(pid)1235 path = montage.export_path(pid)
1221 return {1236 return {
1222 "timeline": timeline,1237 "timeline": timeline,
@@ -1231,9 +1246,10 @@ def _montage_view(pid: str) -> dict:
1231 "can_undo": bool(montage.history_steps(pid)),1246 "can_undo": bool(montage.history_steps(pid)),
1232 "export": {"rel": path.relative_to(service.project_dir(pid)).as_posix(), "name": path.name,1247 "export": {"rel": path.relative_to(service.project_dir(pid)).as_posix(), "name": path.name,
1233 "exists": path.is_file()},1248 "exists": path.is_file()},
1234- "chat": {"messages": chat.get("messages", []), "busy": bool(job and not job.finished),1249+ "tasks": [{"id": j.id, "status": _task_status(j), "messages": j.state.get("messages", []),
1235- "activity": chat.get("activity"), "usage": chat.get("usage"),1250+ "activity": j.state.get("activity"), "error": j.error if j.status == "error" else None} for j in tasks],
1236- "error": job.error if job and job.status == "error" else None},1251+ "busy": any(_task_status(j) == "running" for j in tasks),
1252+ "usage": last.state.get("usage") if last else None,
1237 }1253 }
1238 1254
1239 1255
@@ -1288,8 +1304,8 @@ class MontageChatRequest(BaseModel):
1288 frame: float | None = None1304 frame: float | None = None
1289 # l'image telle que le client la voit dans l'aperçu (A-roll, B-roll, motion, beats) : data URL JPEG1305 # l'image telle que le client la voit dans l'aperçu (A-roll, B-roll, motion, beats) : data URL JPEG
1290 image: str | None = None1306 image: str | None = None
1291- # repartir d'une discussion vierge (nouvelle session Claude, fil effacé)1307+ # la demande à laquelle on répond (sa conversation continue) ; absente : une nouvelle demande, mise en file
1292- fresh: bool = False1308+ task: str | None = None
1293 1309
1294 1310
1295 def _save_view(pid: str, data_url: str) -> str:1311 def _save_view(pid: str, data_url: str) -> str:
@@ -1313,35 +1329,52 @@ def montage_chat(pid: str, req: MontageChatRequest):
1313 _check(pid)1329 _check(pid)
1314 if not req.text.strip():1330 if not req.text.strip():
1315 raise HTTPException(400, "message vide")1331 raise HTTPException(400, "message vide")
1316- job = _montage_job(pid)1332+ job = _montage_task(pid, req.task) if req.task else None
1317 state = job.state if job else {"messages": []}1333 state = job.state if job else {"messages": []}
1318- if req.fresh:1334+ # une demande ratée ou arrêtée avant d'être traitée : remplacée par la nouvelle
1319- if _montage_busy(pid):1335+ if job and job.status in ("error", "cancelled") and state["messages"] and state["messages"][-1]["role"] == "user":
1320- raise HTTPException(409, "le monteur est en train de modifier la timeline")
1321- state = {"messages": [], "usage": state.get("usage")}
1322- elif job and job.status == "error" and state["messages"] and state["messages"][-1]["role"] == "user":
1323 state["messages"].pop()1336 state["messages"].pop()
1324 state["messages"].append({"role": "user", "text": req.text.strip(), "selection": req.selection or None,1337 state["messages"].append({"role": "user", "text": req.text.strip(), "selection": req.selection or None,
1325 "frame": req.frame, "image": _save_view(pid, req.image) if req.image else None,1338 "frame": req.frame, "image": _save_view(pid, req.image) if req.image else None,
1326 "at": time.time()})1339 "at": time.time()})
1340+ # la file la moins chargée : plusieurs demandes sont traitées à la fois
1341+ slot = montage.task_slot()
1342+ if job and job.finished:
1343+ jobs.update(job.id, kind=slot)
1327 try:1344 try:
1328- service.requeue(f"{pid}-montage", "project_montage", {"project": pid}, state)1345+ job = service.requeue(job.id if job else f"{pid}-montage-{uuid.uuid4().hex[:8]}", slot, {"project": pid}, state)
1329 except ValueError as e:1346 except ValueError as e:
1330 raise HTTPException(409, str(e)) from e1347 raise HTTPException(409, str(e)) from e
1331 with service.editing(pid) as p:1348 with service.editing(pid) as p:
1332 service.start_step(p, "montage")1349 service.start_step(p, "montage")
1333- return _montage_view(pid)1350+ return {**_montage_view(pid), "task": job.id}
1351+
1352+
1353+class MontageTaskRequest(BaseModel):
1354+ task: str
1334 1355
1335 1356
1336 @router.post("/{pid}/montage/stop")1357 @router.post("/{pid}/montage/stop")
1337-def stop_montage(pid: str):1358+def stop_montage(pid: str, req: MontageTaskRequest):
1359+ """Arrête une demande en cours, ou la retire de la file."""
1338 _check(pid)1360 _check(pid)
1339- job = _montage_job(pid)1361+ job = _montage_task(pid, req.task)
1340- if job and not job.finished:1362+ if not job.finished:
1341 jobs.cancel(job)1363 jobs.cancel(job)
1342 return _montage_view(pid)1364 return _montage_view(pid)
1343 1365
1344 1366
1367+@router.delete("/{pid}/montage/tasks/{task}")
1368+def delete_montage_task(pid: str, task: str):
1369+ """Retire une demande terminée de la liste (ses changements restent dans la timeline)."""
1370+ _check(pid)
1371+ job = _montage_task(pid, task)
1372+ if not job.finished:
1373+ raise HTTPException(409, "Daryl travaille sur cette demande : arrête-la d'abord")
1374+ jobs.delete(job.id)
1375+ return _montage_view(pid)
1376+
1377+
1345 class StoryboardRequest(BaseModel):1378 class StoryboardRequest(BaseModel):
1346 density: str = storyboard.DEFAULT_DENSITY1379 density: str = storyboard.DEFAULT_DENSITY
1347 # consigne de l'auteur (« peu de plein écran », « insiste sur les chiffres »…)1380 # consigne de l'auteur (« peu de plein écran », « insiste sur les chiffres »…)
modified backend/studio/modules/projects/montage.py +78 -32
@@ -10,8 +10,9 @@ Tout vit dans le dossier du projet, que Resolve lit tel quel (chemins absolus) :
1010 montage/<titre>.fcpxml l'export pour Resolve, refait à chaque modification
1111 montage/.history/ les états précédents de timeline.json, pour annuler
1212 montage/.revs/ les dernières versions écrites, par empreinte (fusionner une retouche faite sur l'une)
13- montage/daryl/timeline.json la copie de travail de Daryl pendant un tour du chat, fusionnée à la fin du tour
14- avec la timeline, que le client continue de monter à la main (merge)
13+ montage/daryl/<demande>/ la copie de travail de Daryl pendant un tour du chat (timeline.json, contexte.md,
14+ insertions.json), fusionnée à la fin du tour avec la timeline, que le client continue
15+ de monter à la main (merge) ; plusieurs demandes tournent à la fois
1516 montage/.cache/ forme d'onde et vignettes de la vidéo source
1617 assets/<catégorie>/ B-roll, musique, sons : un son de la bibliothèque y est copié
1718 dès qu'il entre dans la timeline
@@ -64,9 +65,16 @@ RENDERS_DIR = "renders"
6465 HISTORY_DIR = ".history"
6566 CACHE_DIR = ".cache"
6667 HISTORY_MAX = 60
67-# la copie de travail de Daryl ; dedans, WORK_BASE : la timeline et les compositions au début du tour, ses insertions
68+# les copies de travail de Daryl, une par demande en cours (daryl/<demande>/) ; dans chacune, WORK_BASE : la timeline
69+# et les compositions au début du tour, ses insertions ; RESERVED_FILE : les ids de motion qui lui sont réservés
6870 WORK_DIR = "daryl"
6971 WORK_BASE = ".depart"
72+RESERVED_FILE = "ids.json"
73+RESERVED = 10
74+# les demandes faites à Daryl : une file par place, SLOTS demandes traitées à la fois (la première garde le nom
75+# d'avant, pour les demandes déjà en base)
76+TASK_SLOTS = 3
77+TASK_KINDS = ["project_montage"] + [f"project_montage_{i}" for i in range(1, TASK_SLOTS)]
7078 REVS_DIR = ".revs"
7179 REVS_MAX = 30
7280 # une écriture de la timeline à la fois (l'API et le worker sont deux processus)
@@ -362,14 +370,15 @@ def shift_beats(pid: str, at: float, length: float) -> None:
362370 file.write_text(json.dumps(done + [{"at": at, "length": length}]), encoding="utf-8")
363371
364372
365-def apply_insertions(pid: str) -> list[str]:
373+def apply_insertions(pid: str, task: str) -> list[str]:
366374 """
367- Les clips que Daryl a demandé d'insérer (montage/insertions.json : [{src, at, in, out, gain_db?}], `at` en temps
375+ Les clips que Daryl a demandé d'insérer pour la demande `task` (insertions.json dans son dossier de travail :
376+ [{src, at, in, out, gain_db?}], `at` en temps
368377 de la timeline au moment où il écrit le fichier) : insérés dans sa copie de travail l'un après l'autre, du dernier
369378 au premier pour que les temps restent justes. Les beats du storyboard ne suivent qu'à la fin du tour, si son A-roll
370379 est gardé (finish_work). Le fichier est effacé ; rend ce qui n'a pas pu l'être.
371380 """
372- file = root(pid) / INSERTIONS_FILE
381+ file = work_dir(pid, task) / INSERTIONS_FILE
373382 if not file.is_file():
374383 return []
375384 try:
@@ -382,7 +391,7 @@ def apply_insertions(pid: str) -> list[str]:
382391 wanted = [wanted]
383392 project = service.load(pid)
384393 duration = (_source(project) or {}).get("duration") or 0.0
385- timeline = clean(pid, _read(work_file(pid)) or load(pid), duration)
394+ timeline = clean(pid, _read(work_file(pid, task)) or load(pid), duration)
386395 problems, shifts = [], []
387396 for w in sorted((w for w in wanted if isinstance(w, dict)), key=lambda w: -_num(w.get("at"))):
388397 src = _resolve_src(pid, str(w.get("src") or ""))
@@ -397,8 +406,8 @@ def apply_insertions(pid: str) -> list[str]:
397406 continue
398407 done = insert_aroll(timeline, _num(w.get("at")), {"src": src, "in": a, "out": b, "gain_db": _num(w.get("gain_db"))})
399408 shifts.append({"at": done["at"], "length": done["length"]})
400- _dump(work_file(pid), clean(pid, timeline, duration))
401- noted = work_file(pid).parent / WORK_BASE / SHIFTS_FILE
409+ _dump(work_file(pid, task), clean(pid, timeline, duration))
410+ noted = work_dir(pid, task) / WORK_BASE / SHIFTS_FILE
402411 before = json.loads(noted.read_text(encoding="utf-8")) if noted.is_file() else []
403412 noted.write_text(json.dumps(before + shifts), encoding="utf-8")
404413 return problems
@@ -468,8 +477,8 @@ def _locked(pid: str) -> Iterator[None]:
468477 path.unlink(missing_ok=True)
469478
470479
471-def snapshot(pid: str, motions: Path | None = None) -> None:
472- """Garde l'état actuel de la timeline (et des compositions : celles de `motions` si donné) avant une modification."""
480+def snapshot(pid: str, motions: list[Path] | None = None) -> None:
481+ """Garde l'état actuel de la timeline (et des compositions : seulement `motions` si donné) avant une modification."""
473482 path = root(pid) / TIMELINE_FILE
474483 if not path.is_file():
475484 return
@@ -478,7 +487,7 @@ def snapshot(pid: str, motions: Path | None = None) -> None:
478487 target = history / str(n)
479488 target.mkdir(parents=True, exist_ok=True)
480489 shutil.copy2(path, target / TIMELINE_FILE)
481- for comp in (motions or motion_dir(pid)).glob("*.html"):
490+ for comp in motion_dir(pid).glob("*.html") if motions is None else motions:
482491 (target / MOTION_DIR).mkdir(exist_ok=True)
483492 shutil.copy2(comp, target / MOTION_DIR / comp.name)
484493 for old in sorted((p for p in history.iterdir() if p.name.isdigit()), key=lambda p: int(p.name))[:-HISTORY_MAX]:
@@ -535,33 +544,69 @@ def save(pid: str, raw: dict, keep_history: bool = True, shifts: list[dict] | No
535544
536545 # --- La copie de travail de Daryl ------------------------------------------------------------
537546
538-def work_file(pid: str) -> Path:
539- return root(pid) / WORK_DIR / TIMELINE_FILE
547+def task_slot() -> str:
548+ """La file la moins chargée pour une demande à Daryl (tous projets confondus)."""
549+ load_ = {k: 0 for k in TASK_KINDS}
550+ for j in jobs.list_jobs(service.MODULE):
551+ if j.kind in load_ and not j.finished:
552+ load_[j.kind] += 1
553+ return min(TASK_KINDS, key=lambda k: load_[k])
540554
541555
542-def start_work(pid: str) -> None:
543- """Début d'un tour du chat : Daryl reçoit une copie de la timeline ; l'état de départ (timeline et compositions)
544- est gardé pour la fusion et pour annuler son tour."""
545- work = root(pid) / WORK_DIR
546- shutil.rmtree(work, ignore_errors=True)
547- (work / WORK_BASE / MOTION_DIR).mkdir(parents=True)
548- timeline = load(pid)
549- _dump(work / TIMELINE_FILE, timeline)
550- _dump(work / WORK_BASE / TIMELINE_FILE, timeline)
551- for comp in motion_dir(pid).glob("*.html"):
552- shutil.copy2(comp, work / WORK_BASE / MOTION_DIR / comp.name)
556+def work_dir(pid: str, task: str) -> Path:
557+ """Le dossier de travail de Daryl pour une demande : sa copie de la timeline, son contexte, ses insertions."""
558+ return root(pid) / WORK_DIR / task
559+
560+
561+def work_file(pid: str, task: str) -> Path:
562+ return work_dir(pid, task) / TIMELINE_FILE
553563
554564
555-def finish_work(pid: str, motions_changed: bool) -> tuple[bool, list[str]]:
565+def _motion_numbers(timeline: dict | None) -> set[int]:
566+ return {int(m["id"][1:]) for m in (timeline or {}).get("motion") or [] if re.fullmatch(r"m\d+", str(m.get("id")))}
567+
568+
569+def start_work(pid: str, task: str) -> list[str]:
570+ """
571+ Début d'un tour du chat : Daryl reçoit une copie de la timeline ; l'état de départ (timeline et compositions)
572+ est gardé pour la fusion et pour annuler son tour. D'autres demandes tournent peut-être en même temps : il reçoit
573+ des ids de motion à lui (pas deux fois le même fichier de composition). Rend ces ids.
574+ """
575+ with _locked(pid):
576+ work = work_dir(pid, task)
577+ shutil.rmtree(work, ignore_errors=True)
578+ (work / WORK_BASE / MOTION_DIR).mkdir(parents=True)
579+ timeline = load(pid)
580+ _dump(work / TIMELINE_FILE, timeline)
581+ _dump(work / WORK_BASE / TIMELINE_FILE, timeline)
582+ for comp in motion_dir(pid).glob("*.html"):
583+ shutil.copy2(comp, work / WORK_BASE / MOTION_DIR / comp.name)
584+ # au-delà de tout ce qui existe ou est réservé par les autres demandes en cours
585+ used = _motion_numbers(timeline) | {int(p.stem[1:]) for p in motion_dir(pid).glob("m*.html") if p.stem[1:].isdigit()}
586+ for other in (root(pid) / WORK_DIR).iterdir():
587+ used |= _motion_numbers(_read(other / TIMELINE_FILE))
588+ used |= {int(i[1:]) for i in json.loads((other / RESERVED_FILE).read_text(encoding="utf-8"))} \
589+ if (other / RESERVED_FILE).is_file() else set()
590+ n = max(used, default=0) + 1
591+ ids = [f"m{n + i}" for i in range(RESERVED)]
592+ (work / RESERVED_FILE).write_text(json.dumps(ids), encoding="utf-8")
593+ return ids
594+
595+
596+def finish_work(pid: str, task: str, touched: set[str]) -> tuple[bool, list[str]]:
556597 """
557- Fin d'un tour : la copie de Daryl est fusionnée dans la timeline, que le client a pu retoucher pendant ce temps
558- (sa version gagne quand les deux ont changé la même chose). L'état d'avant est historisé : annuler défait le tour
559- de Daryl, pas les retouches du client. Rend (quelque chose a changé, ce qui n'a pas pu être pris).
598+ Fin d'un tour : la copie de Daryl est fusionnée dans la timeline, que le client (et les autres demandes finies
599+ entre-temps) a pu changer pendant ce temps : la timeline gagne quand les deux ont changé la même chose. `touched` :
600+ les compositions qu'il a écrites (noms de fichiers). L'état d'avant est historisé : annuler défait ce tour, pas le
601+ reste. Rend (quelque chose a changé, ce qui n'a pas pu être pris).
560602 """
561- work = root(pid) / WORK_DIR
603+ work = work_dir(pid, task)
562604 base, theirs = _read(work / WORK_BASE / TIMELINE_FILE), _read(work / TIMELINE_FILE)
563605 if base is None or theirs is None:
564- return motions_changed, []
606+ return False, []
607+ before = {name: work / WORK_BASE / MOTION_DIR / name for name in touched}
608+ motions_changed = any(not p.is_file() or not (motion_dir(pid) / p.name).is_file()
609+ or p.read_bytes() != (motion_dir(pid) / p.name).read_bytes() for p in before.values())
565610 project = service.load(pid)
566611 duration = (_source(project) or {}).get("duration") or 0.0
567612 theirs = clean(pid, theirs, duration)
@@ -576,7 +621,8 @@ def finish_work(pid: str, motions_changed: bool) -> tuple[bool, list[str]]:
576621 comp = motion_dir(pid) / f"{old}.html"
577622 if comp.is_file():
578623 comp.replace(motion_dir(pid) / f"{new}.html")
579- snapshot(pid, work / WORK_BASE / MOTION_DIR)
624+ # les compositions qu'il a retouchées, telles qu'avant son tour
625+ snapshot(pid, [p for p in before.values() if p.is_file()])
580626 _write(pid, clean(pid, merged, duration))
581627 # ses insertions dans l'A-roll sont gardées : les beats du storyboard les suivent
582628 if _shape(merged["aroll"]) == _shape(theirs["aroll"]):
@@ -10,8 +10,9 @@ Tout vit dans le dossier du projet, que Resolve lit tel quel (chemins absolus) :
10 montage/<titre>.fcpxml l'export pour Resolve, refait à chaque modification10 montage/<titre>.fcpxml l'export pour Resolve, refait à chaque modification
11 montage/.history/ les états précédents de timeline.json, pour annuler11 montage/.history/ les états précédents de timeline.json, pour annuler
12 montage/.revs/ les dernières versions écrites, par empreinte (fusionner une retouche faite sur l'une)12 montage/.revs/ les dernières versions écrites, par empreinte (fusionner une retouche faite sur l'une)
13- montage/daryl/timeline.json la copie de travail de Daryl pendant un tour du chat, fusionnée à la fin du tour13+ montage/daryl/<demande>/ la copie de travail de Daryl pendant un tour du chat (timeline.json, contexte.md,
14- avec la timeline, que le client continue de monter à la main (merge)14+ insertions.json), fusionnée à la fin du tour avec la timeline, que le client continue
15+ de monter à la main (merge) ; plusieurs demandes tournent à la fois
15 montage/.cache/ forme d'onde et vignettes de la vidéo source16 montage/.cache/ forme d'onde et vignettes de la vidéo source
16 assets/<catégorie>/ B-roll, musique, sons : un son de la bibliothèque y est copié17 assets/<catégorie>/ B-roll, musique, sons : un son de la bibliothèque y est copié
17 dès qu'il entre dans la timeline18 dès qu'il entre dans la timeline
@@ -64,9 +65,16 @@ RENDERS_DIR = "renders"
64 HISTORY_DIR = ".history"65 HISTORY_DIR = ".history"
65 CACHE_DIR = ".cache"66 CACHE_DIR = ".cache"
66 HISTORY_MAX = 6067 HISTORY_MAX = 60
67-# la copie de travail de Daryl ; dedans, WORK_BASE : la timeline et les compositions au début du tour, ses insertions68+# les copies de travail de Daryl, une par demande en cours (daryl/<demande>/) ; dans chacune, WORK_BASE : la timeline
69+# et les compositions au début du tour, ses insertions ; RESERVED_FILE : les ids de motion qui lui sont réservés
68 WORK_DIR = "daryl"70 WORK_DIR = "daryl"
69 WORK_BASE = ".depart"71 WORK_BASE = ".depart"
72+RESERVED_FILE = "ids.json"
73+RESERVED = 10
74+# les demandes faites à Daryl : une file par place, SLOTS demandes traitées à la fois (la première garde le nom
75+# d'avant, pour les demandes déjà en base)
76+TASK_SLOTS = 3
77+TASK_KINDS = ["project_montage"] + [f"project_montage_{i}" for i in range(1, TASK_SLOTS)]
70 REVS_DIR = ".revs"78 REVS_DIR = ".revs"
71 REVS_MAX = 3079 REVS_MAX = 30
72 # une écriture de la timeline à la fois (l'API et le worker sont deux processus)80 # une écriture de la timeline à la fois (l'API et le worker sont deux processus)
@@ -362,14 +370,15 @@ def shift_beats(pid: str, at: float, length: float) -> None:
362 file.write_text(json.dumps(done + [{"at": at, "length": length}]), encoding="utf-8")370 file.write_text(json.dumps(done + [{"at": at, "length": length}]), encoding="utf-8")
363 371
364 372
365-def apply_insertions(pid: str) -> list[str]:373+def apply_insertions(pid: str, task: str) -> list[str]:
366 """374 """
367- Les clips que Daryl a demandé d'insérer (montage/insertions.json : [{src, at, in, out, gain_db?}], `at` en temps375+ Les clips que Daryl a demandé d'insérer pour la demande `task` (insertions.json dans son dossier de travail :
376+ [{src, at, in, out, gain_db?}], `at` en temps
368 de la timeline au moment où il écrit le fichier) : insérés dans sa copie de travail l'un après l'autre, du dernier377 de la timeline au moment où il écrit le fichier) : insérés dans sa copie de travail l'un après l'autre, du dernier
369 au premier pour que les temps restent justes. Les beats du storyboard ne suivent qu'à la fin du tour, si son A-roll378 au premier pour que les temps restent justes. Les beats du storyboard ne suivent qu'à la fin du tour, si son A-roll
370 est gardé (finish_work). Le fichier est effacé ; rend ce qui n'a pas pu l'être.379 est gardé (finish_work). Le fichier est effacé ; rend ce qui n'a pas pu l'être.
371 """380 """
372- file = root(pid) / INSERTIONS_FILE381+ file = work_dir(pid, task) / INSERTIONS_FILE
373 if not file.is_file():382 if not file.is_file():
374 return []383 return []
375 try:384 try:
@@ -382,7 +391,7 @@ def apply_insertions(pid: str) -> list[str]:
382 wanted = [wanted]391 wanted = [wanted]
383 project = service.load(pid)392 project = service.load(pid)
384 duration = (_source(project) or {}).get("duration") or 0.0393 duration = (_source(project) or {}).get("duration") or 0.0
385- timeline = clean(pid, _read(work_file(pid)) or load(pid), duration)394+ timeline = clean(pid, _read(work_file(pid, task)) or load(pid), duration)
386 problems, shifts = [], []395 problems, shifts = [], []
387 for w in sorted((w for w in wanted if isinstance(w, dict)), key=lambda w: -_num(w.get("at"))):396 for w in sorted((w for w in wanted if isinstance(w, dict)), key=lambda w: -_num(w.get("at"))):
388 src = _resolve_src(pid, str(w.get("src") or ""))397 src = _resolve_src(pid, str(w.get("src") or ""))
@@ -397,8 +406,8 @@ def apply_insertions(pid: str) -> list[str]:
397 continue406 continue
398 done = insert_aroll(timeline, _num(w.get("at")), {"src": src, "in": a, "out": b, "gain_db": _num(w.get("gain_db"))})407 done = insert_aroll(timeline, _num(w.get("at")), {"src": src, "in": a, "out": b, "gain_db": _num(w.get("gain_db"))})
399 shifts.append({"at": done["at"], "length": done["length"]})408 shifts.append({"at": done["at"], "length": done["length"]})
400- _dump(work_file(pid), clean(pid, timeline, duration))409+ _dump(work_file(pid, task), clean(pid, timeline, duration))
401- noted = work_file(pid).parent / WORK_BASE / SHIFTS_FILE410+ noted = work_dir(pid, task) / WORK_BASE / SHIFTS_FILE
402 before = json.loads(noted.read_text(encoding="utf-8")) if noted.is_file() else []411 before = json.loads(noted.read_text(encoding="utf-8")) if noted.is_file() else []
403 noted.write_text(json.dumps(before + shifts), encoding="utf-8")412 noted.write_text(json.dumps(before + shifts), encoding="utf-8")
404 return problems413 return problems
@@ -468,8 +477,8 @@ def _locked(pid: str) -> Iterator[None]:
468 path.unlink(missing_ok=True)477 path.unlink(missing_ok=True)
469 478
470 479
471-def snapshot(pid: str, motions: Path | None = None) -> None:480+def snapshot(pid: str, motions: list[Path] | None = None) -> None:
472- """Garde l'état actuel de la timeline (et des compositions : celles de `motions` si donné) avant une modification."""481+ """Garde l'état actuel de la timeline (et des compositions : seulement `motions` si donné) avant une modification."""
473 path = root(pid) / TIMELINE_FILE482 path = root(pid) / TIMELINE_FILE
474 if not path.is_file():483 if not path.is_file():
475 return484 return
@@ -478,7 +487,7 @@ def snapshot(pid: str, motions: Path | None = None) -> None:
478 target = history / str(n)487 target = history / str(n)
479 target.mkdir(parents=True, exist_ok=True)488 target.mkdir(parents=True, exist_ok=True)
480 shutil.copy2(path, target / TIMELINE_FILE)489 shutil.copy2(path, target / TIMELINE_FILE)
481- for comp in (motions or motion_dir(pid)).glob("*.html"):490+ for comp in motion_dir(pid).glob("*.html") if motions is None else motions:
482 (target / MOTION_DIR).mkdir(exist_ok=True)491 (target / MOTION_DIR).mkdir(exist_ok=True)
483 shutil.copy2(comp, target / MOTION_DIR / comp.name)492 shutil.copy2(comp, target / MOTION_DIR / comp.name)
484 for old in sorted((p for p in history.iterdir() if p.name.isdigit()), key=lambda p: int(p.name))[:-HISTORY_MAX]:493 for old in sorted((p for p in history.iterdir() if p.name.isdigit()), key=lambda p: int(p.name))[:-HISTORY_MAX]:
@@ -535,33 +544,69 @@ def save(pid: str, raw: dict, keep_history: bool = True, shifts: list[dict] | No
535 544
536 # --- La copie de travail de Daryl ------------------------------------------------------------545 # --- La copie de travail de Daryl ------------------------------------------------------------
537 546
538-def work_file(pid: str) -> Path:547+def task_slot() -> str:
539- return root(pid) / WORK_DIR / TIMELINE_FILE548+ """La file la moins chargée pour une demande à Daryl (tous projets confondus)."""
549+ load_ = {k: 0 for k in TASK_KINDS}
550+ for j in jobs.list_jobs(service.MODULE):
551+ if j.kind in load_ and not j.finished:
552+ load_[j.kind] += 1
553+ return min(TASK_KINDS, key=lambda k: load_[k])
540 554
541 555
542-def start_work(pid: str) -> None:556+def work_dir(pid: str, task: str) -> Path:
543- """Début d'un tour du chat : Daryl reçoit une copie de la timeline ; l'état de départ (timeline et compositions)557+ """Le dossier de travail de Daryl pour une demande : sa copie de la timeline, son contexte, ses insertions."""
544- est gardé pour la fusion et pour annuler son tour."""558+ return root(pid) / WORK_DIR / task
545- work = root(pid) / WORK_DIR559+
546- shutil.rmtree(work, ignore_errors=True)560+
547- (work / WORK_BASE / MOTION_DIR).mkdir(parents=True)561+def work_file(pid: str, task: str) -> Path:
548- timeline = load(pid)562+ return work_dir(pid, task) / TIMELINE_FILE
549- _dump(work / TIMELINE_FILE, timeline)
550- _dump(work / WORK_BASE / TIMELINE_FILE, timeline)
551- for comp in motion_dir(pid).glob("*.html"):
552- shutil.copy2(comp, work / WORK_BASE / MOTION_DIR / comp.name)
553 563
554 564
555-def finish_work(pid: str, motions_changed: bool) -> tuple[bool, list[str]]:565+def _motion_numbers(timeline: dict | None) -> set[int]:
566+ return {int(m["id"][1:]) for m in (timeline or {}).get("motion") or [] if re.fullmatch(r"m\d+", str(m.get("id")))}
567+
568+
569+def start_work(pid: str, task: str) -> list[str]:
570+ """
571+ Début d'un tour du chat : Daryl reçoit une copie de la timeline ; l'état de départ (timeline et compositions)
572+ est gardé pour la fusion et pour annuler son tour. D'autres demandes tournent peut-être en même temps : il reçoit
573+ des ids de motion à lui (pas deux fois le même fichier de composition). Rend ces ids.
574+ """
575+ with _locked(pid):
576+ work = work_dir(pid, task)
577+ shutil.rmtree(work, ignore_errors=True)
578+ (work / WORK_BASE / MOTION_DIR).mkdir(parents=True)
579+ timeline = load(pid)
580+ _dump(work / TIMELINE_FILE, timeline)
581+ _dump(work / WORK_BASE / TIMELINE_FILE, timeline)
582+ for comp in motion_dir(pid).glob("*.html"):
583+ shutil.copy2(comp, work / WORK_BASE / MOTION_DIR / comp.name)
584+ # au-delà de tout ce qui existe ou est réservé par les autres demandes en cours
585+ used = _motion_numbers(timeline) | {int(p.stem[1:]) for p in motion_dir(pid).glob("m*.html") if p.stem[1:].isdigit()}
586+ for other in (root(pid) / WORK_DIR).iterdir():
587+ used |= _motion_numbers(_read(other / TIMELINE_FILE))
588+ used |= {int(i[1:]) for i in json.loads((other / RESERVED_FILE).read_text(encoding="utf-8"))} \
589+ if (other / RESERVED_FILE).is_file() else set()
590+ n = max(used, default=0) + 1
591+ ids = [f"m{n + i}" for i in range(RESERVED)]
592+ (work / RESERVED_FILE).write_text(json.dumps(ids), encoding="utf-8")
593+ return ids
594+
595+
596+def finish_work(pid: str, task: str, touched: set[str]) -> tuple[bool, list[str]]:
556 """597 """
557- Fin d'un tour : la copie de Daryl est fusionnée dans la timeline, que le client a pu retoucher pendant ce temps598+ Fin d'un tour : la copie de Daryl est fusionnée dans la timeline, que le client (et les autres demandes finies
558- (sa version gagne quand les deux ont changé la même chose). L'état d'avant est historisé : annuler défait le tour599+ entre-temps) a pu changer pendant ce temps : la timeline gagne quand les deux ont changé la même chose. `touched` :
559- de Daryl, pas les retouches du client. Rend (quelque chose a changé, ce qui n'a pas pu être pris).600+ les compositions qu'il a écrites (noms de fichiers). L'état d'avant est historisé : annuler défait ce tour, pas le
601+ reste. Rend (quelque chose a changé, ce qui n'a pas pu être pris).
560 """602 """
561- work = root(pid) / WORK_DIR603+ work = work_dir(pid, task)
562 base, theirs = _read(work / WORK_BASE / TIMELINE_FILE), _read(work / TIMELINE_FILE)604 base, theirs = _read(work / WORK_BASE / TIMELINE_FILE), _read(work / TIMELINE_FILE)
563 if base is None or theirs is None:605 if base is None or theirs is None:
564- return motions_changed, []606+ return False, []
607+ before = {name: work / WORK_BASE / MOTION_DIR / name for name in touched}
608+ motions_changed = any(not p.is_file() or not (motion_dir(pid) / p.name).is_file()
609+ or p.read_bytes() != (motion_dir(pid) / p.name).read_bytes() for p in before.values())
565 project = service.load(pid)610 project = service.load(pid)
566 duration = (_source(project) or {}).get("duration") or 0.0611 duration = (_source(project) or {}).get("duration") or 0.0
567 theirs = clean(pid, theirs, duration)612 theirs = clean(pid, theirs, duration)
@@ -576,7 +621,8 @@ def finish_work(pid: str, motions_changed: bool) -> tuple[bool, list[str]]:
576 comp = motion_dir(pid) / f"{old}.html"621 comp = motion_dir(pid) / f"{old}.html"
577 if comp.is_file():622 if comp.is_file():
578 comp.replace(motion_dir(pid) / f"{new}.html")623 comp.replace(motion_dir(pid) / f"{new}.html")
579- snapshot(pid, work / WORK_BASE / MOTION_DIR)624+ # les compositions qu'il a retouchées, telles qu'avant son tour
625+ snapshot(pid, [p for p in before.values() if p.is_file()])
580 _write(pid, clean(pid, merged, duration))626 _write(pid, clean(pid, merged, duration))
581 # ses insertions dans l'A-roll sont gardées : les beats du storyboard les suivent627 # ses insertions dans l'A-roll sont gardées : les beats du storyboard les suivent
582 if _shape(merged["aroll"]) == _shape(theirs["aroll"]):628 if _shape(merged["aroll"]) == _shape(theirs["aroll"]):
modified backend/studio/modules/projects/montage_tasks.py +102 -37
@@ -11,7 +11,7 @@ import subprocess
1111 import time
1212 from pathlib import Path
1313
14-from ...core import claude
14+from ...core import claude, jobs
1515 from ...core.system import tool
1616 from ...core.tasks import TaskContext
1717 from ..animations import render as hyperframes
@@ -49,7 +49,9 @@ devient noire avant la fin."""
4949 TRANSITIONS = "\n".join(transitions.prompt_lines())
5050
5151
52-def system_prompt(project: dict) -> str:
52+def system_prompt(project: dict, work: str, ids: list[str]) -> str:
53+ """`work` : le dossier de travail de la demande (relatif au projet) ; `ids` : les ids de motion qui lui sont
54+ réservés."""
5355 return f"""\
5456 Tu es le monteur d'un créateur de vidéos YouTube. Il est ton client : il te demande des \
5557 changements dans le chat et voit ta timeline en direct dans son interface. Tu montes la vidéo \
@@ -58,19 +60,20 @@ sound design, musique, chapitres. Vise le niveau d'un monteur professionnel de c
5860 éducative : rythmé, lisible, jamais gratuit.
5961
6062 # Fichiers (dossier courant = le dossier du projet)
61-- montage/contexte.md : régénéré avant chaque demande. Le transcript EN TEMPS DE LA TIMELINE, \
63+- {work}/contexte.md : régénéré avant chaque demande. Le transcript EN TEMPS DE LA TIMELINE, \
6264 les chapitres, la liste exacte des médias utilisables (B-roll, musique, sons) avec leur durée, \
6365 et la sélection faite par le client dans la timeline. Lis-le en premier.
64-- montage/daryl/timeline.json : ta copie de travail de la timeline, que tu modifies (Edit de \
66+- {work}/timeline.json : ta copie de travail de la timeline, que tu modifies (Edit de \
6567 préférence, ou Write du fichier entier). Le client continue de monter à la main pendant que tu \
6668 travailles, dans sa propre timeline (montage/timeline.json : n'y touche pas) ; le studio fusionne \
67-ta copie dans la sienne à la fin de ton tour. Ne change que ce que la demande implique : ce que \
68-tu laisses tel quel garde ses retouches à lui.
69+ta copie dans la sienne à la fin de ton tour. D'autres de ses demandes sont peut-être traitées en \
70+même temps, chacune dans sa copie, sur d'autres passages. Ne change que ce que la demande implique : \
71+ce que tu laisses tel quel garde ses retouches et celles des autres demandes.
6972 - montage/motion/<id>.html : une composition HyperFrames par motion design (voir le contrat).
70-- montage/insertions.json : pour insérer un clip dans l'A-roll (voir plus bas). Le studio l'applique \
73+- {work}/insertions.json : pour insérer un clip dans l'A-roll (voir plus bas). Le studio l'applique \
7174 et l'efface aussitôt écrit.
7275
73-# La timeline (montage/daryl/timeline.json)
76+# La timeline ({work}/timeline.json)
7477 Temps de la timeline en secondes (`at`, `dur`, `t`) ; `in`/`out` : temps dans le média.
7578 {{
7679 "aroll": [{{"id": "a1", "in": 0, "out": 264.25, "transition": null}}, {{"id": "a2", "src": "assets/broll/extrait.mp4", "in": 0, "out": 3.9, "transition": null, "gain_db": -3}}, {{"id": "a3", "in": 264.25, "out": 754.2, "transition": null}}],
@@ -87,18 +90,19 @@ précédent (null : coupe franche). Un clip avec `src` est un autre média insé
8790 une archive) : il passe en plein écran AVEC SON SON, la voix du client s'arrête le temps du clip ; pas de \
8891 transition avec lui. `gain_db` : le volume d'un clip (−20 à +12, absent : 0) ; tu peux le changer directement.
8992 - Insérer un clip dans l'A-roll (« mets l'extrait X à tel moment ») : ne le fais JAMAIS à la main dans \
90-ta copie (il faudrait décaler tout ce qui suit). Écris montage/insertions.json : \
93+ta copie (il faudrait décaler tout ce qui suit). Écris {work}/insertions.json : \
9194 [{{"src": "<chemin listé dans contexte.md>", "at": <instant de la timeline, de préférence dans un silence \
9295 entre deux phrases>, "in": 0, "out": <durée du média>, "gain_db": 0}}]. Le studio coupe l'A-roll à `at`, \
9396 insère le clip et décale tout ce qui suit (zooms, B-roll, motions, sons, marqueurs, storyboard). Ensuite \
94-RELIS montage/daryl/timeline.json (elle a changé) avant toute autre modification : le clip y est, avec son id ; les temps \
97+RELIS {work}/timeline.json (elle a changé) avant toute autre modification : le clip y est, avec son id ; les temps \
9598 d'après sont décalés de sa durée. Ajoute alors ce qui l'accompagne (un whoosh à l'entrée et à la sortie, \
9699 par exemple) en temps de la timeline à jour.
97100 - zooms : recadrage progressif de l'A-roll (montée et descente de 0,4 s faites à l'export). \
98101 scale 1.1 à 1.4 ; x, y : décalage du centre en fraction de l'image (−0.5 à 0.5).
99102 - broll : plan de coupe muet qui couvre l'A-roll (la voix continue). `src` : EXACTEMENT un \
100103 chemin listé dans contexte.md ; in/out dans la durée du média. 2 à 6 s en général.
101-- motion : `id` nouveau (m + numéro jamais utilisé), puis écris montage/motion/<id>.html. \
104+- motion : `id` nouveau, pris dans les ids qui te sont réservés pour cette demande, dans l'ordre : \
105+{", ".join(ids)} (les autres demandes en cours ont les leurs), puis écris montage/motion/<id>.html. \
102106 Durée = celle de la composition (data-duration).
103107 - audio : sons (role "sfx", −8 à −14 dB) et musique (role "music", −20 à −26 dB sous la voix) ; \
104108 quand contexte.md donne un volume (« à poser à … dB »), c'est celui-là. \
@@ -131,10 +135,10 @@ indiquée dans contexte.md et dans le message. Tiens-t'en à cette portée.
131135 - Les motions d'ensemble viennent du storyboard que le client a validé ; leurs animations sont créées \
132136 par des tâches à part. Un motion en cours de création (listé dans contexte.md) : n'y touche pas.
133137 - Pour une demande précise du client (« un zoom ici », « remplace ce B-roll », « corrige ce motion »), \
134-fais-la directement : modifie montage/daryl/timeline.json (le client voit tes changements à la fin \
138+fais-la directement : modifie {work}/timeline.json (le client voit tes changements à la fin \
135139 de ton tour), puis écris les compositions. Ne lance pas de création d'ensemble de toi-même : propose plutôt de passer par \
136140 le storyboard.
137-- Ne crée ni ne modifie aucun autre fichier que montage/daryl/timeline.json, montage/insertions.json \
141+- Ne crée ni ne modifie aucun autre fichier que {work}/timeline.json, {work}/insertions.json \
138142 et montage/motion/*.html.
139143 - Si un média manque (pas de B-roll adapté, pas de son), dis-le dans ta réponse plutôt que \
140144 d'inventer un chemin.
@@ -156,8 +160,8 @@ def _item_scope(item: dict) -> str:
156160 return f"la transition {fx} de {what} : ne change que ce champ, sauf demande contraire" if fx else what
157161
158162
159-def write_context(pid: str, selection: dict | None) -> None:
160- """montage/contexte.md : ce que Claude doit savoir avant chaque demande."""
163+def write_context(pid: str, selection: dict | None, path: Path) -> None:
164+ """contexte.md (`path`) : ce que Claude doit savoir avant chaque demande."""
161165 project = service.load(pid)
162166 from . import runtime
163167
@@ -206,7 +210,6 @@ def write_context(pid: str, selection: dict | None) -> None:
206210 for m in items) if items else "(aucun)"))
207211 parts.append("\n## Transcript (temps de la timeline)\n" + "\n".join(
208212 f"[{_mmss(l['start'])} → {_mmss(l['end'])}] {l['text']}" for l in lines))
209- path = montage.root(pid) / "contexte.md"
210213 path.parent.mkdir(parents=True, exist_ok=True)
211214 path.write_text("\n".join(parts) + "\n", encoding="utf-8")
212215
@@ -219,20 +222,82 @@ def _label(block: dict) -> str:
219222 return f"{verb} {shown}".strip()
220223
221224
225+# une image en pause : le passage autour d'elle (s), pour savoir si deux demandes se recoupent
226+FRAME_SPAN = 1.0
227+WAIT_POLL = 2.0
228+
229+
230+def _span(pid: str, request: dict) -> list[float] | None:
231+ """Le passage de la timeline sur lequel porte une demande ; None : toute la vidéo."""
232+ selection = request.get("selection") or {}
233+ if selection.get("range"):
234+ return [float(x) for x in selection["range"]]
235+ if selection.get("item"):
236+ item = selection["item"]
237+ timeline = montage.load(pid)
238+ if item.get("track") == "aroll":
239+ for c, start in zip(timeline["aroll"], montage._starts(timeline["aroll"])):
240+ if c["id"] == item.get("id"):
241+ return [start, start + c["out"] - c["in"]]
242+ for x in timeline.get(item.get("track")) or []:
243+ if x["id"] == item.get("id"):
244+ at = x.get("at", x.get("t", 0.0))
245+ return [at, at + (x["dur"] if "dur" in x else x.get("out", 0.0) - x.get("in", 0.0))]
246+ if request.get("frame") is not None:
247+ return [float(request["frame"]) - FRAME_SPAN, float(request["frame"]) + FRAME_SPAN]
248+ return None
249+
250+
251+def _overlap(a: list[float] | None, b: list[float] | None) -> bool:
252+ return a is None or b is None or (a[0] < b[1] and b[0] < a[1])
253+
254+
255+def _wait_turn(ctx: TaskContext, pid: str) -> None:
256+ """Plusieurs demandes tournent à la fois ; une demande attend que celles parties avant elle sur le même passage
257+ (ou sur toute la vidéo) soient finies, pour partir de la timeline où elles sont fusionnées."""
258+ me = jobs.get(ctx.job.id)
259+ order = (me.started_at or 0, me.id)
260+ while True:
261+ before = [j for j in jobs.list_jobs(service.MODULE)
262+ if j.kind in montage.TASK_KINDS and j.payload.get("project") == pid and j.id != me.id
263+ and not j.finished and j.status != "queued" and ((j.started_at or 0), j.id) < order]
264+ # « span » absent (la demande n'a pas encore dit sur quoi elle porte) : comme toute la vidéo
265+ if not any(_overlap(ctx.state["span"], j.state.get("span")) for j in before):
266+ return
267+ ctx.state["activity"] = "attend la fin d'une demande sur le même passage"
268+ ctx.save(force=True)
269+ ctx.check_cancel()
270+ time.sleep(WAIT_POLL)
271+
272+
273+def _composition(pid: str, block: dict) -> str | None:
274+ """La composition qu'écrit un Write ou un Edit de Daryl (son nom de fichier), sinon None."""
275+ if block.get("name") not in ("Write", "Edit"):
276+ return None
277+ target = Path((block.get("input") or {}).get("file_path") or "")
278+ if not target.is_absolute():
279+ target = service.project_dir(pid) / target
280+ return target.name if target.suffix == ".html" and target.parent.resolve() == montage.motion_dir(pid).resolve() else None
281+
282+
222283 def chat(ctx: TaskContext) -> None:
223284 state, pid = ctx.state, ctx.payload["project"]
285+ # la demande : son dossier de travail (daryl/<demande>/), à part de ceux des demandes qui tournent en même temps
286+ task = ctx.job.id.rsplit("-", 1)[-1]
224287 messages = state["messages"]
225288 while messages and messages[-1]["role"] == "ai":
226289 messages.pop()
227290 request = messages[-1]
291+ state["span"] = _span(pid, request)
292+ _wait_turn(ctx, pid)
228293 montage.prepare(pid)
229- path = montage.root(pid) / montage.TIMELINE_FILE
230- if not path.is_file():
294+ if not (montage.root(pid) / montage.TIMELINE_FILE).is_file():
231295 montage.save(pid, montage.load(pid), keep_history=False)
232- write_context(pid, request.get("selection"))
233296 # Daryl travaille sur une copie : le client continue de monter à la main, les deux sont fusionnées à la fin
234- montage.start_work(pid)
235- motions_before = {p.name: p.stat().st_mtime for p in montage.motion_dir(pid).glob("*.html")}
297+ ids = montage.start_work(pid, task)
298+ work = montage.work_dir(pid, task).relative_to(service.project_dir(pid)).as_posix()
299+ write_context(pid, request.get("selection"), montage.work_dir(pid, task) / "contexte.md")
300+ touched: set[str] = set()
236301
237302 reply = {"role": "ai", "text": "", "tools": [], "at": time.time()}
238303 messages.append(reply)
@@ -250,16 +315,15 @@ def chat(ctx: TaskContext) -> None:
250315 scope = f"Portée : {_item_scope(selection['item'])}.\n\n"
251316 if request.get("frame") is not None:
252317 scope += _frame_scope(pid, float(request["frame"]), request.get("image"))
253- prompt = f"Lis montage/contexte.md puis montage/daryl/timeline.json.\n\n{scope}Demande : {request['text']}"
318+ prompt = f"Lis {work}/contexte.md puis {work}/timeline.json.\n\n{scope}Demande : {request['text']}"
254319 project = service.load(pid)
255- insertions = montage.root(pid) / montage.INSERTIONS_FILE
256- insertions.unlink(missing_ok=True)
320+ insertions = montage.work_dir(pid, task) / montage.INSERTIONS_FILE
257321 try:
258- for event in claude.stream(prompt, service.project_dir(pid), system_prompt(project), state.get("session_id"),
259- None, ctx.check_cancel):
260- # une insertion demandée : appliquée tout de suite, pour que Daryl relise la timeline à jour
322+ for event in claude.stream(prompt, service.project_dir(pid), system_prompt(project, work, ids),
323+ state.get("session_id"), None, ctx.check_cancel):
324+ # une insertion demandée : appliquée tout de suite, pour que Daryl relise sa copie à jour
261325 if insertions.is_file():
262- _insert(pid, reply)
326+ _insert(pid, task, reply)
263327 ctx.save(force=True)
264328 kind = event.get("type")
265329 if kind == "system" and event.get("subtype") == "init":
@@ -280,6 +344,8 @@ def chat(ctx: TaskContext) -> None:
280344 elif kind == "assistant":
281345 for block in event["message"]["content"]:
282346 if block["type"] == "tool_use":
347+ if comp := _composition(pid, block):
348+ touched.add(comp)
283349 label = _label(block)
284350 reply["tools"].append(label)
285351 state["activity"] = label
@@ -294,10 +360,9 @@ def chat(ctx: TaskContext) -> None:
294360 finally:
295361 state["activity"] = None
296362 if insertions.is_file():
297- _insert(pid, reply)
298- _apply(pid)
299- changed, kept = montage.finish_work(pid, any(
300- motions_before.get(p.name) != p.stat().st_mtime for p in montage.motion_dir(pid).glob("*.html")))
363+ _insert(pid, task, reply)
364+ _apply(pid, task)
365+ changed, kept = montage.finish_work(pid, task, touched)
301366 if changed:
302367 montage.after_change(pid)
303368 problems = reply.pop("problems", []) + kept
@@ -329,25 +394,25 @@ def _frame_scope(pid: str, t: float, image: str | None) -> str:
329394 + seen + "\n")
330395
331396
332-def _insert(pid: str, reply: dict) -> None:
397+def _insert(pid: str, task: str, reply: dict) -> None:
333398 """Les clips que Daryl a demandé d'insérer dans l'A-roll ; ce qui a échoué s'ajoute à sa réponse."""
334- problems = montage.apply_insertions(pid)
399+ problems = montage.apply_insertions(pid, task)
335400 reply["tools"].append("insère un clip dans l'A-roll")
336401 if problems:
337402 reply.setdefault("problems", []).extend(problems)
338403
339404
340-def _apply(pid: str) -> None:
405+def _apply(pid: str, task: str) -> None:
341406 """Fin de tour : la copie écrite par Claude est nettoyée (ids, bornes, médias copiés).
342407 Pas pendant le tour : réécrire le fichier ferait échouer ses Edit suivants."""
343408 try:
344- raw = json.loads(montage.work_file(pid).read_text(encoding="utf-8"))
409+ raw = json.loads(montage.work_file(pid, task).read_text(encoding="utf-8"))
345410 except (OSError, json.JSONDecodeError):
346411 return
347412 project = service.load(pid)
348413 cleaned = montage.clean(pid, raw, (montage._source(project) or {}).get("duration") or 0.0)
349414 if cleaned != raw:
350- montage._dump(montage.work_file(pid), cleaned)
415+ montage._dump(montage.work_file(pid, task), cleaned)
351416
352417
353418 # --- Rendu d'un motion design --------------------------------------------------------------
@@ -11,7 +11,7 @@ import subprocess
11 import time11 import time
12 from pathlib import Path12 from pathlib import Path
13 13
14-from ...core import claude14+from ...core import claude, jobs
15 from ...core.system import tool15 from ...core.system import tool
16 from ...core.tasks import TaskContext16 from ...core.tasks import TaskContext
17 from ..animations import render as hyperframes17 from ..animations import render as hyperframes
@@ -49,7 +49,9 @@ devient noire avant la fin."""
49 TRANSITIONS = "\n".join(transitions.prompt_lines())49 TRANSITIONS = "\n".join(transitions.prompt_lines())
50 50
51 51
52-def system_prompt(project: dict) -> str:52+def system_prompt(project: dict, work: str, ids: list[str]) -> str:
53+ """`work` : le dossier de travail de la demande (relatif au projet) ; `ids` : les ids de motion qui lui sont
54+ réservés."""
53 return f"""\55 return f"""\
54 Tu es le monteur d'un créateur de vidéos YouTube. Il est ton client : il te demande des \56 Tu es le monteur d'un créateur de vidéos YouTube. Il est ton client : il te demande des \
55 changements dans le chat et voit ta timeline en direct dans son interface. Tu montes la vidéo \57 changements dans le chat et voit ta timeline en direct dans son interface. Tu montes la vidéo \
@@ -58,19 +60,20 @@ sound design, musique, chapitres. Vise le niveau d'un monteur professionnel de c
58 éducative : rythmé, lisible, jamais gratuit.60 éducative : rythmé, lisible, jamais gratuit.
59 61
60 # Fichiers (dossier courant = le dossier du projet)62 # Fichiers (dossier courant = le dossier du projet)
61-- montage/contexte.md : régénéré avant chaque demande. Le transcript EN TEMPS DE LA TIMELINE, \63+- {work}/contexte.md : régénéré avant chaque demande. Le transcript EN TEMPS DE LA TIMELINE, \
62 les chapitres, la liste exacte des médias utilisables (B-roll, musique, sons) avec leur durée, \64 les chapitres, la liste exacte des médias utilisables (B-roll, musique, sons) avec leur durée, \
63 et la sélection faite par le client dans la timeline. Lis-le en premier.65 et la sélection faite par le client dans la timeline. Lis-le en premier.
64-- montage/daryl/timeline.json : ta copie de travail de la timeline, que tu modifies (Edit de \66+- {work}/timeline.json : ta copie de travail de la timeline, que tu modifies (Edit de \
65 préférence, ou Write du fichier entier). Le client continue de monter à la main pendant que tu \67 préférence, ou Write du fichier entier). Le client continue de monter à la main pendant que tu \
66 travailles, dans sa propre timeline (montage/timeline.json : n'y touche pas) ; le studio fusionne \68 travailles, dans sa propre timeline (montage/timeline.json : n'y touche pas) ; le studio fusionne \
67-ta copie dans la sienne à la fin de ton tour. Ne change que ce que la demande implique : ce que \69+ta copie dans la sienne à la fin de ton tour. D'autres de ses demandes sont peut-être traitées en \
68-tu laisses tel quel garde ses retouches à lui.70+même temps, chacune dans sa copie, sur d'autres passages. Ne change que ce que la demande implique : \
71+ce que tu laisses tel quel garde ses retouches et celles des autres demandes.
69 - montage/motion/<id>.html : une composition HyperFrames par motion design (voir le contrat).72 - montage/motion/<id>.html : une composition HyperFrames par motion design (voir le contrat).
70-- montage/insertions.json : pour insérer un clip dans l'A-roll (voir plus bas). Le studio l'applique \73+- {work}/insertions.json : pour insérer un clip dans l'A-roll (voir plus bas). Le studio l'applique \
71 et l'efface aussitôt écrit.74 et l'efface aussitôt écrit.
72 75
73-# La timeline (montage/daryl/timeline.json)76+# La timeline ({work}/timeline.json)
74 Temps de la timeline en secondes (`at`, `dur`, `t`) ; `in`/`out` : temps dans le média.77 Temps de la timeline en secondes (`at`, `dur`, `t`) ; `in`/`out` : temps dans le média.
75 {{78 {{
76 "aroll": [{{"id": "a1", "in": 0, "out": 264.25, "transition": null}}, {{"id": "a2", "src": "assets/broll/extrait.mp4", "in": 0, "out": 3.9, "transition": null, "gain_db": -3}}, {{"id": "a3", "in": 264.25, "out": 754.2, "transition": null}}],79 "aroll": [{{"id": "a1", "in": 0, "out": 264.25, "transition": null}}, {{"id": "a2", "src": "assets/broll/extrait.mp4", "in": 0, "out": 3.9, "transition": null, "gain_db": -3}}, {{"id": "a3", "in": 264.25, "out": 754.2, "transition": null}}],
@@ -87,18 +90,19 @@ précédent (null : coupe franche). Un clip avec `src` est un autre média insé
87 une archive) : il passe en plein écran AVEC SON SON, la voix du client s'arrête le temps du clip ; pas de \90 une archive) : il passe en plein écran AVEC SON SON, la voix du client s'arrête le temps du clip ; pas de \
88 transition avec lui. `gain_db` : le volume d'un clip (−20 à +12, absent : 0) ; tu peux le changer directement.91 transition avec lui. `gain_db` : le volume d'un clip (−20 à +12, absent : 0) ; tu peux le changer directement.
89 - Insérer un clip dans l'A-roll (« mets l'extrait X à tel moment ») : ne le fais JAMAIS à la main dans \92 - Insérer un clip dans l'A-roll (« mets l'extrait X à tel moment ») : ne le fais JAMAIS à la main dans \
90-ta copie (il faudrait décaler tout ce qui suit). Écris montage/insertions.json : \93+ta copie (il faudrait décaler tout ce qui suit). Écris {work}/insertions.json : \
91 [{{"src": "<chemin listé dans contexte.md>", "at": <instant de la timeline, de préférence dans un silence \94 [{{"src": "<chemin listé dans contexte.md>", "at": <instant de la timeline, de préférence dans un silence \
92 entre deux phrases>, "in": 0, "out": <durée du média>, "gain_db": 0}}]. Le studio coupe l'A-roll à `at`, \95 entre deux phrases>, "in": 0, "out": <durée du média>, "gain_db": 0}}]. Le studio coupe l'A-roll à `at`, \
93 insère le clip et décale tout ce qui suit (zooms, B-roll, motions, sons, marqueurs, storyboard). Ensuite \96 insère le clip et décale tout ce qui suit (zooms, B-roll, motions, sons, marqueurs, storyboard). Ensuite \
94-RELIS montage/daryl/timeline.json (elle a changé) avant toute autre modification : le clip y est, avec son id ; les temps \97+RELIS {work}/timeline.json (elle a changé) avant toute autre modification : le clip y est, avec son id ; les temps \
95 d'après sont décalés de sa durée. Ajoute alors ce qui l'accompagne (un whoosh à l'entrée et à la sortie, \98 d'après sont décalés de sa durée. Ajoute alors ce qui l'accompagne (un whoosh à l'entrée et à la sortie, \
96 par exemple) en temps de la timeline à jour.99 par exemple) en temps de la timeline à jour.
97 - zooms : recadrage progressif de l'A-roll (montée et descente de 0,4 s faites à l'export). \100 - zooms : recadrage progressif de l'A-roll (montée et descente de 0,4 s faites à l'export). \
98 scale 1.1 à 1.4 ; x, y : décalage du centre en fraction de l'image (−0.5 à 0.5).101 scale 1.1 à 1.4 ; x, y : décalage du centre en fraction de l'image (−0.5 à 0.5).
99 - broll : plan de coupe muet qui couvre l'A-roll (la voix continue). `src` : EXACTEMENT un \102 - broll : plan de coupe muet qui couvre l'A-roll (la voix continue). `src` : EXACTEMENT un \
100 chemin listé dans contexte.md ; in/out dans la durée du média. 2 à 6 s en général.103 chemin listé dans contexte.md ; in/out dans la durée du média. 2 à 6 s en général.
101-- motion : `id` nouveau (m + numéro jamais utilisé), puis écris montage/motion/<id>.html. \104+- motion : `id` nouveau, pris dans les ids qui te sont réservés pour cette demande, dans l'ordre : \
105+{", ".join(ids)} (les autres demandes en cours ont les leurs), puis écris montage/motion/<id>.html. \
102 Durée = celle de la composition (data-duration).106 Durée = celle de la composition (data-duration).
103 - audio : sons (role "sfx", −8 à −14 dB) et musique (role "music", −20 à −26 dB sous la voix) ; \107 - audio : sons (role "sfx", −8 à −14 dB) et musique (role "music", −20 à −26 dB sous la voix) ; \
104 quand contexte.md donne un volume (« à poser à … dB »), c'est celui-là. \108 quand contexte.md donne un volume (« à poser à … dB »), c'est celui-là. \
@@ -131,10 +135,10 @@ indiquée dans contexte.md et dans le message. Tiens-t'en à cette portée.
131 - Les motions d'ensemble viennent du storyboard que le client a validé ; leurs animations sont créées \135 - Les motions d'ensemble viennent du storyboard que le client a validé ; leurs animations sont créées \
132 par des tâches à part. Un motion en cours de création (listé dans contexte.md) : n'y touche pas.136 par des tâches à part. Un motion en cours de création (listé dans contexte.md) : n'y touche pas.
133 - Pour une demande précise du client (« un zoom ici », « remplace ce B-roll », « corrige ce motion »), \137 - Pour une demande précise du client (« un zoom ici », « remplace ce B-roll », « corrige ce motion »), \
134-fais-la directement : modifie montage/daryl/timeline.json (le client voit tes changements à la fin \138+fais-la directement : modifie {work}/timeline.json (le client voit tes changements à la fin \
135 de ton tour), puis écris les compositions. Ne lance pas de création d'ensemble de toi-même : propose plutôt de passer par \139 de ton tour), puis écris les compositions. Ne lance pas de création d'ensemble de toi-même : propose plutôt de passer par \
136 le storyboard.140 le storyboard.
137-- Ne crée ni ne modifie aucun autre fichier que montage/daryl/timeline.json, montage/insertions.json \141+- Ne crée ni ne modifie aucun autre fichier que {work}/timeline.json, {work}/insertions.json \
138 et montage/motion/*.html.142 et montage/motion/*.html.
139 - Si un média manque (pas de B-roll adapté, pas de son), dis-le dans ta réponse plutôt que \143 - Si un média manque (pas de B-roll adapté, pas de son), dis-le dans ta réponse plutôt que \
140 d'inventer un chemin.144 d'inventer un chemin.
@@ -156,8 +160,8 @@ def _item_scope(item: dict) -> str:
156 return f"la transition {fx} de {what} : ne change que ce champ, sauf demande contraire" if fx else what160 return f"la transition {fx} de {what} : ne change que ce champ, sauf demande contraire" if fx else what
157 161
158 162
159-def write_context(pid: str, selection: dict | None) -> None:163+def write_context(pid: str, selection: dict | None, path: Path) -> None:
160- """montage/contexte.md : ce que Claude doit savoir avant chaque demande."""164+ """contexte.md (`path`) : ce que Claude doit savoir avant chaque demande."""
161 project = service.load(pid)165 project = service.load(pid)
162 from . import runtime166 from . import runtime
163 167
@@ -206,7 +210,6 @@ def write_context(pid: str, selection: dict | None) -> None:
206 for m in items) if items else "(aucun)"))210 for m in items) if items else "(aucun)"))
207 parts.append("\n## Transcript (temps de la timeline)\n" + "\n".join(211 parts.append("\n## Transcript (temps de la timeline)\n" + "\n".join(
208 f"[{_mmss(l['start'])} → {_mmss(l['end'])}] {l['text']}" for l in lines))212 f"[{_mmss(l['start'])} → {_mmss(l['end'])}] {l['text']}" for l in lines))
209- path = montage.root(pid) / "contexte.md"
210 path.parent.mkdir(parents=True, exist_ok=True)213 path.parent.mkdir(parents=True, exist_ok=True)
211 path.write_text("\n".join(parts) + "\n", encoding="utf-8")214 path.write_text("\n".join(parts) + "\n", encoding="utf-8")
212 215
@@ -219,20 +222,82 @@ def _label(block: dict) -> str:
219 return f"{verb} {shown}".strip()222 return f"{verb} {shown}".strip()
220 223
221 224
225+# une image en pause : le passage autour d'elle (s), pour savoir si deux demandes se recoupent
226+FRAME_SPAN = 1.0
227+WAIT_POLL = 2.0
228+
229+
230+def _span(pid: str, request: dict) -> list[float] | None:
231+ """Le passage de la timeline sur lequel porte une demande ; None : toute la vidéo."""
232+ selection = request.get("selection") or {}
233+ if selection.get("range"):
234+ return [float(x) for x in selection["range"]]
235+ if selection.get("item"):
236+ item = selection["item"]
237+ timeline = montage.load(pid)
238+ if item.get("track") == "aroll":
239+ for c, start in zip(timeline["aroll"], montage._starts(timeline["aroll"])):
240+ if c["id"] == item.get("id"):
241+ return [start, start + c["out"] - c["in"]]
242+ for x in timeline.get(item.get("track")) or []:
243+ if x["id"] == item.get("id"):
244+ at = x.get("at", x.get("t", 0.0))
245+ return [at, at + (x["dur"] if "dur" in x else x.get("out", 0.0) - x.get("in", 0.0))]
246+ if request.get("frame") is not None:
247+ return [float(request["frame"]) - FRAME_SPAN, float(request["frame"]) + FRAME_SPAN]
248+ return None
249+
250+
251+def _overlap(a: list[float] | None, b: list[float] | None) -> bool:
252+ return a is None or b is None or (a[0] < b[1] and b[0] < a[1])
253+
254+
255+def _wait_turn(ctx: TaskContext, pid: str) -> None:
256+ """Plusieurs demandes tournent à la fois ; une demande attend que celles parties avant elle sur le même passage
257+ (ou sur toute la vidéo) soient finies, pour partir de la timeline où elles sont fusionnées."""
258+ me = jobs.get(ctx.job.id)
259+ order = (me.started_at or 0, me.id)
260+ while True:
261+ before = [j for j in jobs.list_jobs(service.MODULE)
262+ if j.kind in montage.TASK_KINDS and j.payload.get("project") == pid and j.id != me.id
263+ and not j.finished and j.status != "queued" and ((j.started_at or 0), j.id) < order]
264+ # « span » absent (la demande n'a pas encore dit sur quoi elle porte) : comme toute la vidéo
265+ if not any(_overlap(ctx.state["span"], j.state.get("span")) for j in before):
266+ return
267+ ctx.state["activity"] = "attend la fin d'une demande sur le même passage"
268+ ctx.save(force=True)
269+ ctx.check_cancel()
270+ time.sleep(WAIT_POLL)
271+
272+
273+def _composition(pid: str, block: dict) -> str | None:
274+ """La composition qu'écrit un Write ou un Edit de Daryl (son nom de fichier), sinon None."""
275+ if block.get("name") not in ("Write", "Edit"):
276+ return None
277+ target = Path((block.get("input") or {}).get("file_path") or "")
278+ if not target.is_absolute():
279+ target = service.project_dir(pid) / target
280+ return target.name if target.suffix == ".html" and target.parent.resolve() == montage.motion_dir(pid).resolve() else None
281+
282+
222 def chat(ctx: TaskContext) -> None:283 def chat(ctx: TaskContext) -> None:
223 state, pid = ctx.state, ctx.payload["project"]284 state, pid = ctx.state, ctx.payload["project"]
285+ # la demande : son dossier de travail (daryl/<demande>/), à part de ceux des demandes qui tournent en même temps
286+ task = ctx.job.id.rsplit("-", 1)[-1]
224 messages = state["messages"]287 messages = state["messages"]
225 while messages and messages[-1]["role"] == "ai":288 while messages and messages[-1]["role"] == "ai":
226 messages.pop()289 messages.pop()
227 request = messages[-1]290 request = messages[-1]
291+ state["span"] = _span(pid, request)
292+ _wait_turn(ctx, pid)
228 montage.prepare(pid)293 montage.prepare(pid)
229- path = montage.root(pid) / montage.TIMELINE_FILE294+ if not (montage.root(pid) / montage.TIMELINE_FILE).is_file():
230- if not path.is_file():
231 montage.save(pid, montage.load(pid), keep_history=False)295 montage.save(pid, montage.load(pid), keep_history=False)
232- write_context(pid, request.get("selection"))
233 # Daryl travaille sur une copie : le client continue de monter à la main, les deux sont fusionnées à la fin296 # Daryl travaille sur une copie : le client continue de monter à la main, les deux sont fusionnées à la fin
234- montage.start_work(pid)297+ ids = montage.start_work(pid, task)
235- motions_before = {p.name: p.stat().st_mtime for p in montage.motion_dir(pid).glob("*.html")}298+ work = montage.work_dir(pid, task).relative_to(service.project_dir(pid)).as_posix()
299+ write_context(pid, request.get("selection"), montage.work_dir(pid, task) / "contexte.md")
300+ touched: set[str] = set()
236 301
237 reply = {"role": "ai", "text": "", "tools": [], "at": time.time()}302 reply = {"role": "ai", "text": "", "tools": [], "at": time.time()}
238 messages.append(reply)303 messages.append(reply)
@@ -250,16 +315,15 @@ def chat(ctx: TaskContext) -> None:
250 scope = f"Portée : {_item_scope(selection['item'])}.\n\n"315 scope = f"Portée : {_item_scope(selection['item'])}.\n\n"
251 if request.get("frame") is not None:316 if request.get("frame") is not None:
252 scope += _frame_scope(pid, float(request["frame"]), request.get("image"))317 scope += _frame_scope(pid, float(request["frame"]), request.get("image"))
253- prompt = f"Lis montage/contexte.md puis montage/daryl/timeline.json.\n\n{scope}Demande : {request['text']}"318+ prompt = f"Lis {work}/contexte.md puis {work}/timeline.json.\n\n{scope}Demande : {request['text']}"
254 project = service.load(pid)319 project = service.load(pid)
255- insertions = montage.root(pid) / montage.INSERTIONS_FILE320+ insertions = montage.work_dir(pid, task) / montage.INSERTIONS_FILE
256- insertions.unlink(missing_ok=True)
257 try:321 try:
258- for event in claude.stream(prompt, service.project_dir(pid), system_prompt(project), state.get("session_id"),322+ for event in claude.stream(prompt, service.project_dir(pid), system_prompt(project, work, ids),
259- None, ctx.check_cancel):323+ state.get("session_id"), None, ctx.check_cancel):
260- # une insertion demandée : appliquée tout de suite, pour que Daryl relise la timeline à jour324+ # une insertion demandée : appliquée tout de suite, pour que Daryl relise sa copie à jour
261 if insertions.is_file():325 if insertions.is_file():
262- _insert(pid, reply)326+ _insert(pid, task, reply)
263 ctx.save(force=True)327 ctx.save(force=True)
264 kind = event.get("type")328 kind = event.get("type")
265 if kind == "system" and event.get("subtype") == "init":329 if kind == "system" and event.get("subtype") == "init":
@@ -280,6 +344,8 @@ def chat(ctx: TaskContext) -> None:
280 elif kind == "assistant":344 elif kind == "assistant":
281 for block in event["message"]["content"]:345 for block in event["message"]["content"]:
282 if block["type"] == "tool_use":346 if block["type"] == "tool_use":
347+ if comp := _composition(pid, block):
348+ touched.add(comp)
283 label = _label(block)349 label = _label(block)
284 reply["tools"].append(label)350 reply["tools"].append(label)
285 state["activity"] = label351 state["activity"] = label
@@ -294,10 +360,9 @@ def chat(ctx: TaskContext) -> None:
294 finally:360 finally:
295 state["activity"] = None361 state["activity"] = None
296 if insertions.is_file():362 if insertions.is_file():
297- _insert(pid, reply)363+ _insert(pid, task, reply)
298- _apply(pid)364+ _apply(pid, task)
299- changed, kept = montage.finish_work(pid, any(365+ changed, kept = montage.finish_work(pid, task, touched)
300- motions_before.get(p.name) != p.stat().st_mtime for p in montage.motion_dir(pid).glob("*.html")))
301 if changed:366 if changed:
302 montage.after_change(pid)367 montage.after_change(pid)
303 problems = reply.pop("problems", []) + kept368 problems = reply.pop("problems", []) + kept
@@ -329,25 +394,25 @@ def _frame_scope(pid: str, t: float, image: str | None) -> str:
329 + seen + "\n")394 + seen + "\n")
330 395
331 396
332-def _insert(pid: str, reply: dict) -> None:397+def _insert(pid: str, task: str, reply: dict) -> None:
333 """Les clips que Daryl a demandé d'insérer dans l'A-roll ; ce qui a échoué s'ajoute à sa réponse."""398 """Les clips que Daryl a demandé d'insérer dans l'A-roll ; ce qui a échoué s'ajoute à sa réponse."""
334- problems = montage.apply_insertions(pid)399+ problems = montage.apply_insertions(pid, task)
335 reply["tools"].append("insère un clip dans l'A-roll")400 reply["tools"].append("insère un clip dans l'A-roll")
336 if problems:401 if problems:
337 reply.setdefault("problems", []).extend(problems)402 reply.setdefault("problems", []).extend(problems)
338 403
339 404
340-def _apply(pid: str) -> None:405+def _apply(pid: str, task: str) -> None:
341 """Fin de tour : la copie écrite par Claude est nettoyée (ids, bornes, médias copiés).406 """Fin de tour : la copie écrite par Claude est nettoyée (ids, bornes, médias copiés).
342 Pas pendant le tour : réécrire le fichier ferait échouer ses Edit suivants."""407 Pas pendant le tour : réécrire le fichier ferait échouer ses Edit suivants."""
343 try:408 try:
344- raw = json.loads(montage.work_file(pid).read_text(encoding="utf-8"))409+ raw = json.loads(montage.work_file(pid, task).read_text(encoding="utf-8"))
345 except (OSError, json.JSONDecodeError):410 except (OSError, json.JSONDecodeError):
346 return411 return
347 project = service.load(pid)412 project = service.load(pid)
348 cleaned = montage.clean(pid, raw, (montage._source(project) or {}).get("duration") or 0.0)413 cleaned = montage.clean(pid, raw, (montage._source(project) or {}).get("duration") or 0.0)
349 if cleaned != raw:414 if cleaned != raw:
350- montage._dump(montage.work_file(pid), cleaned)415+ montage._dump(montage.work_file(pid, task), cleaned)
351 416
352 417
353 # --- Rendu d'un motion design --------------------------------------------------------------418 # --- Rendu d'un motion design --------------------------------------------------------------
modified backend/studio/modules/projects/tasks.py +3 -2
@@ -272,7 +272,7 @@ def render(ctx: TaskContext) -> None:
272272 partial.replace(dest)
273273
274274
275-from . import asset_tasks, autoderush, beat_anim, kit_adapt, montage_tasks, publish, render as long_render, roast, storyboard, storyboard_chat, thumb_tasks, thumbs # noqa: E402
275+from . import asset_tasks, autoderush, beat_anim, kit_adapt, montage, montage_tasks, publish, render as long_render, roast, storyboard, storyboard_chat, thumb_tasks, thumbs # noqa: E402
276276
277277 TASKS = {
278278 # un type de job (et une file) par modèle d'image : les modèles tournent en parallèle
@@ -287,7 +287,8 @@ TASKS = {
287287 "project_copy": Task(queue="io", run=copy),
288288 "project_render": Task(queue="render", run=render),
289289 autoderush.KIND: Task(queue="autoderush", run=autoderush.run),
290- "project_montage": Task(queue="montage", run=montage_tasks.chat),
290+ # les demandes à Daryl dans le chat du montage : une file par place, pour en traiter plusieurs à la fois
291+ **{kind: Task(queue=f"montage_{i}", run=montage_tasks.chat) for i, kind in enumerate(montage.TASK_KINDS)},
291292 storyboard.KIND: Task(queue="storyboard", run=storyboard.run),
292293 storyboard_chat.KIND: Task(queue="storyboard", run=storyboard_chat.run),
293294 # les animations des beats : une file par place, pour en créer plusieurs à la fois
@@ -272,7 +272,7 @@ def render(ctx: TaskContext) -> None:
272 partial.replace(dest)272 partial.replace(dest)
273 273
274 274
275-from . import asset_tasks, autoderush, beat_anim, kit_adapt, montage_tasks, publish, render as long_render, roast, storyboard, storyboard_chat, thumb_tasks, thumbs # noqa: E402275+from . import asset_tasks, autoderush, beat_anim, kit_adapt, montage, montage_tasks, publish, render as long_render, roast, storyboard, storyboard_chat, thumb_tasks, thumbs # noqa: E402
276 276
277 TASKS = {277 TASKS = {
278 # un type de job (et une file) par modèle d'image : les modèles tournent en parallèle278 # un type de job (et une file) par modèle d'image : les modèles tournent en parallèle
@@ -287,7 +287,8 @@ TASKS = {
287 "project_copy": Task(queue="io", run=copy),287 "project_copy": Task(queue="io", run=copy),
288 "project_render": Task(queue="render", run=render),288 "project_render": Task(queue="render", run=render),
289 autoderush.KIND: Task(queue="autoderush", run=autoderush.run),289 autoderush.KIND: Task(queue="autoderush", run=autoderush.run),
290- "project_montage": Task(queue="montage", run=montage_tasks.chat),290+ # les demandes à Daryl dans le chat du montage : une file par place, pour en traiter plusieurs à la fois
291+ **{kind: Task(queue=f"montage_{i}", run=montage_tasks.chat) for i, kind in enumerate(montage.TASK_KINDS)},
291 storyboard.KIND: Task(queue="storyboard", run=storyboard.run),292 storyboard.KIND: Task(queue="storyboard", run=storyboard.run),
292 storyboard_chat.KIND: Task(queue="storyboard", run=storyboard_chat.run),293 storyboard_chat.KIND: Task(queue="storyboard", run=storyboard_chat.run),
293 # les animations des beats : une file par place, pour en créer plusieurs à la fois294 # les animations des beats : une file par place, pour en créer plusieurs à la fois
modified web/src/i18n/locales/en/editing.ts +13 -4
@@ -231,9 +231,18 @@ export default {
231231 density: "{n} beats kept, the chosen density asks for {a} to {b}",
232232 },
233233 },
234+ tasks: {
235+ queued: "queued",
236+ running: "running",
237+ done: "done",
238+ error: "error",
239+ cancelled: "stopped",
240+ unqueue: "Remove from the queue",
241+ close: "Remove from the list (the changes stay)",
242+ },
234243 chat: {
235244 sub: "your requests, his edits",
236- empty: "Ask for anything: “add motion design to the selected part with a transition and sound design”, “suggest chapters”, “put calm music under the intro”… He edits the timeline right in front of you.",
245+ empty: "Ask for anything: “add motion design to the selected part with a transition and sound design”, “suggest chapters”, “put calm music under the intro”… Requests are queued: send them one after another without waiting, and keep editing while Daryl works.",
237246 chipsSelection: {
238247 motion: "Add motion design here, with transitions and sound design",
239248 broll: "Add relevant B-roll",
@@ -252,9 +261,9 @@ export default {
252261 onSelection: "Ask about the selection…",
253262 onVideo: "Ask about the whole video…",
254263 onFrame: "Your request about this frame… (“the B-roll hides my face”, “a zoom here?”)",
255- new: "New conversation",
256- fresh: "New conversation: Daryl starts from scratch, without the history. The previous thread is cleared when you send.",
257- resume: "Back to the current conversation",
264+ new: "New request",
265+ reply: "Your reply to Daryl on this request…",
266+ queued: "Queued: Daryl handles 3 requests at a time and starts this one as soon as a slot frees up.",
258267 frame: "Frame at {tc}",
259268 frameRef: "⏸ {tc}",
260269 noFrame: "Don't attach the frame",
@@ -231,9 +231,18 @@ export default {
231 density: "{n} beats kept, the chosen density asks for {a} to {b}",231 density: "{n} beats kept, the chosen density asks for {a} to {b}",
232 },232 },
233 },233 },
234+ tasks: {
235+ queued: "queued",
236+ running: "running",
237+ done: "done",
238+ error: "error",
239+ cancelled: "stopped",
240+ unqueue: "Remove from the queue",
241+ close: "Remove from the list (the changes stay)",
242+ },
234 chat: {243 chat: {
235 sub: "your requests, his edits",244 sub: "your requests, his edits",
236- empty: "Ask for anything: “add motion design to the selected part with a transition and sound design”, “suggest chapters”, “put calm music under the intro”… He edits the timeline right in front of you.",245+ empty: "Ask for anything: “add motion design to the selected part with a transition and sound design”, “suggest chapters”, “put calm music under the intro”… Requests are queued: send them one after another without waiting, and keep editing while Daryl works.",
237 chipsSelection: {246 chipsSelection: {
238 motion: "Add motion design here, with transitions and sound design",247 motion: "Add motion design here, with transitions and sound design",
239 broll: "Add relevant B-roll",248 broll: "Add relevant B-roll",
@@ -252,9 +261,9 @@ export default {
252 onSelection: "Ask about the selection…",261 onSelection: "Ask about the selection…",
253 onVideo: "Ask about the whole video…",262 onVideo: "Ask about the whole video…",
254 onFrame: "Your request about this frame… (“the B-roll hides my face”, “a zoom here?”)",263 onFrame: "Your request about this frame… (“the B-roll hides my face”, “a zoom here?”)",
255- new: "New conversation",264+ new: "New request",
256- fresh: "New conversation: Daryl starts from scratch, without the history. The previous thread is cleared when you send.",265+ reply: "Your reply to Daryl on this request…",
257- resume: "Back to the current conversation",266+ queued: "Queued: Daryl handles 3 requests at a time and starts this one as soon as a slot frees up.",
258 frame: "Frame at {tc}",267 frame: "Frame at {tc}",
259 frameRef: "⏸ {tc}",268 frameRef: "⏸ {tc}",
260 noFrame: "Don't attach the frame",269 noFrame: "Don't attach the frame",
modified web/src/i18n/locales/fr/editing.ts +13 -4
@@ -231,9 +231,18 @@ export default {
231231 density: "{n} beats gardés, la densité choisie en demande {a} à {b}",
232232 },
233233 },
234+ tasks: {
235+ queued: "en file",
236+ running: "en cours",
237+ done: "faite",
238+ error: "erreur",
239+ cancelled: "arrêtée",
240+ unqueue: "Retirer de la file",
241+ close: "Retirer de la liste (ses changements restent)",
242+ },
234243 chat: {
235244 sub: "tes demandes, ses modifications",
236- empty: "Demande-lui ce que tu veux : « ajoute du motion design sur la partie sélectionnée avec transition et sound design », « propose les chapitres », « pose une musique calme sous l’intro »… Il modifie la timeline sous tes yeux.",
245+ empty: "Demande-lui ce que tu veux : « ajoute du motion design sur la partie sélectionnée avec transition et sound design », « propose les chapitres », « pose une musique calme sous l’intro »… Tes demandes se mettent en file : enchaîne-les sans attendre, et continue de monter pendant qu’il travaille.",
237246 chipsSelection: {
238247 motion: "Ajoute du motion design ici, avec transitions et sound design",
239248 broll: "Place des B-roll pertinents",
@@ -252,9 +261,9 @@ export default {
252261 onSelection: "Demande sur la sélection…",
253262 onVideo: "Demande sur toute la vidéo…",
254263 onFrame: "Ta demande sur cette image… (« le B-roll cache mon visage », « un zoom ici ? »)",
255- new: "Nouvelle discussion",
256- fresh: "Nouvelle discussion : Daryl repart de zéro, sans l’historique. Le fil précédent sera effacé à l’envoi.",
257- resume: "Revenir à la discussion en cours",
264+ new: "Nouvelle demande",
265+ reply: "Ta réponse à Daryl sur cette demande…",
266+ queued: "En file : Daryl traite 3 demandes à la fois, il s’y met dès qu’une place se libère.",
258267 frame: "Image à {tc}",
259268 frameRef: "⏸ {tc}",
260269 noFrame: "Ne pas joindre l’image",
@@ -231,9 +231,18 @@ export default {
231 density: "{n} beats gardés, la densité choisie en demande {a} à {b}",231 density: "{n} beats gardés, la densité choisie en demande {a} à {b}",
232 },232 },
233 },233 },
234+ tasks: {
235+ queued: "en file",
236+ running: "en cours",
237+ done: "faite",
238+ error: "erreur",
239+ cancelled: "arrêtée",
240+ unqueue: "Retirer de la file",
241+ close: "Retirer de la liste (ses changements restent)",
242+ },
234 chat: {243 chat: {
235 sub: "tes demandes, ses modifications",244 sub: "tes demandes, ses modifications",
236- empty: "Demande-lui ce que tu veux : « ajoute du motion design sur la partie sélectionnée avec transition et sound design », « propose les chapitres », « pose une musique calme sous l’intro »… Il modifie la timeline sous tes yeux.",245+ empty: "Demande-lui ce que tu veux : « ajoute du motion design sur la partie sélectionnée avec transition et sound design », « propose les chapitres », « pose une musique calme sous l’intro »… Tes demandes se mettent en file : enchaîne-les sans attendre, et continue de monter pendant qu’il travaille.",
237 chipsSelection: {246 chipsSelection: {
238 motion: "Ajoute du motion design ici, avec transitions et sound design",247 motion: "Ajoute du motion design ici, avec transitions et sound design",
239 broll: "Place des B-roll pertinents",248 broll: "Place des B-roll pertinents",
@@ -252,9 +261,9 @@ export default {
252 onSelection: "Demande sur la sélection…",261 onSelection: "Demande sur la sélection…",
253 onVideo: "Demande sur toute la vidéo…",262 onVideo: "Demande sur toute la vidéo…",
254 onFrame: "Ta demande sur cette image… (« le B-roll cache mon visage », « un zoom ici ? »)",263 onFrame: "Ta demande sur cette image… (« le B-roll cache mon visage », « un zoom ici ? »)",
255- new: "Nouvelle discussion",264+ new: "Nouvelle demande",
256- fresh: "Nouvelle discussion : Daryl repart de zéro, sans l’historique. Le fil précédent sera effacé à l’envoi.",265+ reply: "Ta réponse à Daryl sur cette demande…",
257- resume: "Revenir à la discussion en cours",266+ queued: "En file : Daryl traite 3 demandes à la fois, il s’y met dès qu’une place se libère.",
258 frame: "Image à {tc}",267 frame: "Image à {tc}",
259 frameRef: "⏸ {tc}",268 frameRef: "⏸ {tc}",
260 noFrame: "Ne pas joindre l’image",269 noFrame: "Ne pas joindre l’image",
modified web/src/project/steps/MontageStep.vue +39 -11
@@ -1,14 +1,15 @@
11 <script setup lang="ts">
22 /**
33 * Montage de la vidéo longue : l'atelier du monteur. Aperçu, inspecteur, timeline zoomable, et le chat
4- * où le monteur (Claude) reçoit les demandes et modifie la timeline sous les yeux du client.
4+ * où le monteur (Claude) reçoit les demandes : elles se mettent en file, chacune avec sa conversation, et
5+ * il en traite plusieurs à la fois, chacune sur sa copie de la timeline, pendant que le client continue de monter.
56 * Tout s'exporte en FCPXML dans le dossier du projet, que DaVinci Resolve ouvre tel quel.
67 *
78 * Raccourcis (conventions de Resolve) : Espace lecture, J/K/L, ←/→ image par image (Maj : 1 s),
89 * ↑/↓ coupe précédente / suivante, A sélection, B lame, Ctrl+B couper à la tête, Suppr supprimer,
910 * M beat (posé à la tête de lecture, à tirer sur son passage), I/O plage, Alt+X effacer la plage, Ctrl+molette ou +/− zoom, Maj+Z ajuster, Ctrl+Z annuler.
1011 */
11-import { computed, onMounted, onUnmounted, ref, watch } from "vue";
12+import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
1213 import { useI18n } from "vue-i18n";
1314 import { api, post } from "../../lib/api";
1415 import { toast } from "../../lib/toast";
@@ -17,10 +18,11 @@ import { usePlaylist, type Piece } from "./derush/usePlaylist";
1718 import BeatPanel from "./montage/BeatPanel.vue";
1819 import Inspector from "./montage/Inspector.vue";
1920 import MonteurChat from "./montage/MonteurChat.vue";
21+import TaskQueue from "./montage/TaskQueue.vue";
2022 import { addMedia, bladeAroll, bladeItem, insertAroll, isVideo, remove } from "./montage/ops";
2123 import Preview from "./montage/Preview.vue";
2224 import Timeline from "./montage/Timeline.vue";
23-import { clipStarts, fxSpan, isImage, needsAnim, spanOf, tcd, trackLabel, TYPE_COLOR, type AnimState, type Beat, type AvailableMedia, type Broll, type Motion, type Selection, type Shot, type Timeline as TL, type Track } from "./montage/types";
25+import { clipStarts, fxSpan, isImage, needsAnim, spanOf, tcd, trackLabel, TYPE_COLOR, type AnimState, type Beat, type AvailableMedia, type Broll, type MontageTask, type Motion, type Selection, type Shot, type Timeline as TL, type Track } from "./montage/types";
2426 import { useMontage } from "./montage/useMontage";
2527 import { useStoryboard } from "./montage/useStoryboard";
2628
@@ -111,19 +113,43 @@ function pick(s: Selection) {
111113 if (span) seek(span[0]);
112114 }
113115
114-async function send(text: string, sel: Selection | null, shot: Shot | null, fresh: boolean) {
116+// --- les demandes à Daryl : celle dont on voit la conversation (null : une nouvelle demande)
117+const current = ref<string | null>(null);
118+const task = computed(() => view.value?.tasks.find((x) => x.id === current.value) ?? null);
119+// à l'ouverture : la dernière demande
120+watch(() => !!view.value, (ready) => { if (ready) current.value = view.value!.tasks.at(-1)?.id ?? null; }, { once: true });
121+
122+async function send(text: string, sel: Selection | null, shot: Shot | null) {
115123 if (player.playing.value) player.toggle();
116- await M.send(text, sel, shot, fresh);
124+ const id = await M.send(text, sel, shot, task.value?.id ?? null);
125+ if (id) current.value = id;
126+}
127+
128+/** Une nouvelle demande, pendant que les autres attendent ou tournent. */
129+async function newRequest() {
130+ current.value = null;
131+ await nextTick();
132+}
133+
134+/** Ouvre une demande de la file : sa conversation, et sa portée dans la timeline. */
135+function openTask(x: MontageTask) {
136+ current.value = x.id;
137+ const first = x.messages.find((m) => m.role === "user");
138+ if (first?.selection) pick(first.selection);
139+ else if (first?.frame != null) seek(first.frame);
117140 }
118141
119-/** Discuter de l'image en pause : l'aperçu capturé tel qu'on le voit, dans une nouvelle discussion. */
142+/** Discuter de l'image en pause : l'aperçu capturé tel qu'on le voit, dans une nouvelle demande. */
120143 async function discussFrame() {
121144 const at = t.value;
122145 const image = await preview.value?.capture().catch(() => null) ?? null;
123- chat.value?.restart({ t: at, image });
146+ await newRequest();
147+ chat.value?.frame({ t: at, image });
124148 }
125149
126-function comment(text?: string) {
150+/** Commenter, demandes de l'inspecteur : une nouvelle demande sur la sélection. */
151+async function comment(text?: string) {
152+ await newRequest();
127153 chat.value?.compose(text ?? "");
128154 }
129155
@@ -360,10 +386,12 @@ onUnmounted(() => window.removeEventListener("keydown", onKey));
360386 <div class="tabs">
361387 <span class="tab on" :title="tr('editing.chat.sub')">{{ tr("common.assistant") }}</span>
362388 <span class="spacer" />
363- <button class="link-btn new" :disabled="M.busy.value || !view.chat.messages.length" @click="chat?.restart()">{{ tr("editing.chat.new") }}</button>
389+ <button class="link-btn new" :disabled="!current" @click="newRequest">{{ tr("editing.chat.new") }}</button>
364390 </div>
365- <MonteurChat ref="chat" :chat="view.chat" :selection="selection" :describe="describe"
366- @send="send" @stop="M.stop()" @pick="pick" @seek="seek" />
391+ <TaskQueue v-if="view.tasks.length" :tasks="view.tasks" :current="current" :describe="describe"
392+ @open="openTask" @stop="M.stop" @close="M.close" />
393+ <MonteurChat ref="chat" :task="task" :usage="view.usage" :selection="selection" :describe="describe"
394+ @send="send" @stop="M.stop(current!)" @pick="pick" @seek="seek" />
367395 </div>
368396 </div>
369397 </template>
@@ -1,14 +1,15 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2 /**2 /**
3 * Montage de la vidéo longue : l'atelier du monteur. Aperçu, inspecteur, timeline zoomable, et le chat3 * Montage de la vidéo longue : l'atelier du monteur. Aperçu, inspecteur, timeline zoomable, et le chat
4- * où le monteur (Claude) reçoit les demandes et modifie la timeline sous les yeux du client.4+ * où le monteur (Claude) reçoit les demandes : elles se mettent en file, chacune avec sa conversation, et
5+ * il en traite plusieurs à la fois, chacune sur sa copie de la timeline, pendant que le client continue de monter.
5 * Tout s'exporte en FCPXML dans le dossier du projet, que DaVinci Resolve ouvre tel quel.6 * Tout s'exporte en FCPXML dans le dossier du projet, que DaVinci Resolve ouvre tel quel.
6 *7 *
7 * Raccourcis (conventions de Resolve) : Espace lecture, J/K/L, ←/→ image par image (Maj : 1 s),8 * Raccourcis (conventions de Resolve) : Espace lecture, J/K/L, ←/→ image par image (Maj : 1 s),
8 * ↑/↓ coupe précédente / suivante, A sélection, B lame, Ctrl+B couper à la tête, Suppr supprimer,9 * ↑/↓ coupe précédente / suivante, A sélection, B lame, Ctrl+B couper à la tête, Suppr supprimer,
9 * M beat (posé à la tête de lecture, à tirer sur son passage), I/O plage, Alt+X effacer la plage, Ctrl+molette ou +/− zoom, Maj+Z ajuster, Ctrl+Z annuler.10 * M beat (posé à la tête de lecture, à tirer sur son passage), I/O plage, Alt+X effacer la plage, Ctrl+molette ou +/− zoom, Maj+Z ajuster, Ctrl+Z annuler.
10 */11 */
11-import { computed, onMounted, onUnmounted, ref, watch } from "vue";12+import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
12 import { useI18n } from "vue-i18n";13 import { useI18n } from "vue-i18n";
13 import { api, post } from "../../lib/api";14 import { api, post } from "../../lib/api";
14 import { toast } from "../../lib/toast";15 import { toast } from "../../lib/toast";
@@ -17,10 +18,11 @@ import { usePlaylist, type Piece } from "./derush/usePlaylist";
17 import BeatPanel from "./montage/BeatPanel.vue";18 import BeatPanel from "./montage/BeatPanel.vue";
18 import Inspector from "./montage/Inspector.vue";19 import Inspector from "./montage/Inspector.vue";
19 import MonteurChat from "./montage/MonteurChat.vue";20 import MonteurChat from "./montage/MonteurChat.vue";
21+import TaskQueue from "./montage/TaskQueue.vue";
20 import { addMedia, bladeAroll, bladeItem, insertAroll, isVideo, remove } from "./montage/ops";22 import { addMedia, bladeAroll, bladeItem, insertAroll, isVideo, remove } from "./montage/ops";
21 import Preview from "./montage/Preview.vue";23 import Preview from "./montage/Preview.vue";
22 import Timeline from "./montage/Timeline.vue";24 import Timeline from "./montage/Timeline.vue";
23-import { clipStarts, fxSpan, isImage, needsAnim, spanOf, tcd, trackLabel, TYPE_COLOR, type AnimState, type Beat, type AvailableMedia, type Broll, type Motion, type Selection, type Shot, type Timeline as TL, type Track } from "./montage/types";25+import { clipStarts, fxSpan, isImage, needsAnim, spanOf, tcd, trackLabel, TYPE_COLOR, type AnimState, type Beat, type AvailableMedia, type Broll, type MontageTask, type Motion, type Selection, type Shot, type Timeline as TL, type Track } from "./montage/types";
24 import { useMontage } from "./montage/useMontage";26 import { useMontage } from "./montage/useMontage";
25 import { useStoryboard } from "./montage/useStoryboard";27 import { useStoryboard } from "./montage/useStoryboard";
26 28
@@ -111,19 +113,43 @@ function pick(s: Selection) {
111 if (span) seek(span[0]);113 if (span) seek(span[0]);
112 }114 }
113 115
114-async function send(text: string, sel: Selection | null, shot: Shot | null, fresh: boolean) {116+// --- les demandes à Daryl : celle dont on voit la conversation (null : une nouvelle demande)
117+const current = ref<string | null>(null);
118+const task = computed(() => view.value?.tasks.find((x) => x.id === current.value) ?? null);
119+// à l'ouverture : la dernière demande
120+watch(() => !!view.value, (ready) => { if (ready) current.value = view.value!.tasks.at(-1)?.id ?? null; }, { once: true });
121+
122+async function send(text: string, sel: Selection | null, shot: Shot | null) {
115 if (player.playing.value) player.toggle();123 if (player.playing.value) player.toggle();
116- await M.send(text, sel, shot, fresh);124+ const id = await M.send(text, sel, shot, task.value?.id ?? null);
125+ if (id) current.value = id;
126+}
127+
128+/** Une nouvelle demande, pendant que les autres attendent ou tournent. */
129+async function newRequest() {
130+ current.value = null;
131+ await nextTick();
132+}
133+
134+/** Ouvre une demande de la file : sa conversation, et sa portée dans la timeline. */
135+function openTask(x: MontageTask) {
136+ current.value = x.id;
137+ const first = x.messages.find((m) => m.role === "user");
138+ if (first?.selection) pick(first.selection);
139+ else if (first?.frame != null) seek(first.frame);
117 }140 }
118 141
119-/** Discuter de l'image en pause : l'aperçu capturé tel qu'on le voit, dans une nouvelle discussion. */142+/** Discuter de l'image en pause : l'aperçu capturé tel qu'on le voit, dans une nouvelle demande. */
120 async function discussFrame() {143 async function discussFrame() {
121 const at = t.value;144 const at = t.value;
122 const image = await preview.value?.capture().catch(() => null) ?? null;145 const image = await preview.value?.capture().catch(() => null) ?? null;
123- chat.value?.restart({ t: at, image });146+ await newRequest();
147+ chat.value?.frame({ t: at, image });
124 }148 }
125 149
126-function comment(text?: string) {150+/** Commenter, demandes de l'inspecteur : une nouvelle demande sur la sélection. */
151+async function comment(text?: string) {
152+ await newRequest();
127 chat.value?.compose(text ?? "");153 chat.value?.compose(text ?? "");
128 }154 }
129 155
@@ -360,10 +386,12 @@ onUnmounted(() => window.removeEventListener("keydown", onKey));
360 <div class="tabs">386 <div class="tabs">
361 <span class="tab on" :title="tr('editing.chat.sub')">{{ tr("common.assistant") }}</span>387 <span class="tab on" :title="tr('editing.chat.sub')">{{ tr("common.assistant") }}</span>
362 <span class="spacer" />388 <span class="spacer" />
363- <button class="link-btn new" :disabled="M.busy.value || !view.chat.messages.length" @click="chat?.restart()">{{ tr("editing.chat.new") }}</button>389+ <button class="link-btn new" :disabled="!current" @click="newRequest">{{ tr("editing.chat.new") }}</button>
364 </div>390 </div>
365- <MonteurChat ref="chat" :chat="view.chat" :selection="selection" :describe="describe"391+ <TaskQueue v-if="view.tasks.length" :tasks="view.tasks" :current="current" :describe="describe"
366- @send="send" @stop="M.stop()" @pick="pick" @seek="seek" />392+ @open="openTask" @stop="M.stop" @close="M.close" />
393+ <MonteurChat ref="chat" :task="task" :usage="view.usage" :selection="selection" :describe="describe"
394+ @send="send" @stop="M.stop(current!)" @pick="pick" @seek="seek" />
367 </div>395 </div>
368 </div>396 </div>
369 </template>397 </template>
modified web/src/project/steps/montage/MonteurChat.vue +29 -36
@@ -1,16 +1,18 @@
11 <script setup lang="ts">
22 /**
3- * Le chat avec le monteur (Claude) : tu es le client, il travaille dans la timeline sous tes yeux.
4- * Une demande porte sur la sélection en cours (élément ou plage), affichée au-dessus de la saisie.
5- * Discuter de l'image en pause ouvre une nouvelle discussion, avec son timecode et l'image joints.
3+ * Une demande au monteur (Claude) et sa conversation, ou une nouvelle demande (`task` null), mise en file :
4+ * on peut en lancer d'autres sans attendre. Une demande porte sur la sélection en cours (élément ou plage),
5+ * affichée au-dessus de la saisie, ou sur l'image en pause, avec son timecode et la capture jointes.
66 */
77 import { computed, nextTick, ref, watch } from "vue";
88 import { useI18n } from "vue-i18n";
99 import { store } from "../../store";
10-import { tcd, trackLabel, type MontageView, type Selection, type Shot } from "./types";
10+import { tcd, trackLabel, type MontageTask, type Selection, type Shot } from "./types";
1111
12-const props = defineProps<{ chat: MontageView["chat"]; selection: Selection | null; describe: (s: Selection) => string }>();
13-const emit = defineEmits<{ send: [string, Selection | null, Shot | null, boolean]; stop: []; pick: [Selection]; seek: [number] }>();
12+const props = defineProps<{
13+ task: MontageTask | null; usage: Record<string, number> | null; selection: Selection | null; describe: (s: Selection) => string;
14+}>();
15+const emit = defineEmits<{ send: [string, Selection | null, Shot | null]; stop: []; pick: [Selection]; seek: [number] }>();
1416
1517 const { t } = useI18n();
1618 const input = ref("");
@@ -22,25 +24,24 @@ watch(() => JSON.stringify(props.selection), () => { attach.value = true; });
2224 const target = computed(() => (attach.value ? props.selection : null));
2325 /** l'image en pause dont on parle (son temps et sa capture), jointe à la demande */
2426 const shot = ref<Shot | null>(null);
25-/** la prochaine demande ouvre une nouvelle discussion : l'ancien fil est masqué, puis effacé à l'envoi */
26-const fresh = ref(false);
27-const messages = computed(() => (fresh.value ? [] : props.chat.messages));
27+const messages = computed(() => props.task?.messages ?? []);
28+/** Daryl traite la demande, ou elle attend son tour : on ne peut pas y répondre */
29+const busy = computed(() => props.task?.status === "queued" || props.task?.status === "running");
2830
2931 const CHIPS = computed(() => (shot.value ? [] : target.value
3032 ? ["motion", "broll", "zooms", "sound"].map((k) => t(`editing.chat.chipsSelection.${k}`))
3133 : ["chapters", "music", "zooms"].map((k) => t(`editing.chat.chipsVideo.${k}`))));
3234
33-watch(() => [props.chat.messages.length, props.chat.messages.at(-1)?.text.length, props.chat.busy, props.chat.activity], async () => {
35+watch(() => [props.task?.id, messages.value.length, messages.value.at(-1)?.text.length, props.task?.status, props.task?.activity], async () => {
3436 await nextTick();
3537 thread.value?.scrollTo({ top: thread.value.scrollHeight });
3638 }, { immediate: true });
3739
3840 function send(text = input.value) {
39- if (!text.trim() || props.chat.busy) return;
40- emit("send", text.trim(), target.value, shot.value, fresh.value);
41+ if (!text.trim() || busy.value) return;
42+ emit("send", text.trim(), target.value, shot.value);
4143 input.value = "";
4244 shot.value = null;
43- fresh.value = false;
4445 }
4546 function onKey(e: KeyboardEvent) {
4647 e.stopPropagation();
@@ -62,29 +63,21 @@ async function compose(text = "") {
6263 await nextTick();
6364 box.value?.focus();
6465 }
65-/** Repart d'une discussion vierge ; `at` : l'image en pause dont le client veut parler. */
66-async function restart(at: Shot | null = null) {
67- fresh.value = true;
66+/** Joint l'image en pause dont le client veut parler (à la place de la sélection). */
67+async function frame(at: Shot) {
6868 shot.value = at;
69- if (at) attach.value = false;
69+ attach.value = false;
7070 await nextTick();
7171 box.value?.focus();
7272 }
73-function resume() {
74- fresh.value = false;
75- shot.value = null;
76-}
7773 const fileUrl = (rel: string) => `/api/projects/${store.project?.id}/files/${rel}`;
78-defineExpose({ compose, send, restart });
74+defineExpose({ compose, send, frame });
7975 </script>
8076
8177 <template>
8278 <div class="chat">
8379 <div ref="thread" class="thread">
84- <span v-if="!messages.length" class="empty">
85- {{ t(fresh ? "editing.chat.fresh" : "editing.chat.empty") }}
86- <button v-if="fresh && chat.messages.length" class="link-btn" @click="resume">{{ t("editing.chat.resume") }}</button>
87- </span>
80+ <span v-if="!messages.length" class="empty">{{ t("editing.chat.empty") }}</span>
8881 <div v-for="(m, i) in messages" :key="i" class="msg" :class="m.role">
8982 <img v-if="m.role === 'user' && m.image" class="shot" :src="fileUrl(m.image)" alt="" @click="emit('seek', m.frame!)">
9083 <span v-if="m.role === 'user' && m.frame != null" class="ref mono" @click="emit('seek', m.frame!)">{{ t("editing.chat.frameRef", { tc: tcd(m.frame) }) }}</span>
@@ -92,12 +85,13 @@ defineExpose({ compose, send, restart });
9285 <span v-if="m.text" class="text">{{ m.text }}</span>
9386 <span v-if="m.role === 'ai' && m.tools?.length" class="tools mono" :title="m.tools.join(' · ')">{{ t("editing.chat.actions", m.tools.length) }}{{ m.changed ? t("editing.chat.timelineChanged") : "" }}</span>
9487 </div>
95- <span v-if="chat.busy && !fresh" class="busy mono">{{ chat.activity ? `${chat.activity}…` : "…" }}</span>
96- <span v-if="chat.error" class="error">{{ chat.error }}</span>
88+ <span v-if="task?.status === 'queued'" class="busy mono">{{ t("editing.chat.queued") }}</span>
89+ <span v-else-if="task?.status === 'running'" class="busy mono">{{ task.activity ? `${task.activity}…` : "…" }}</span>
90+ <span v-if="task?.error" class="error">{{ task.error }}</span>
9791 </div>
9892 <div class="compose">
9993 <div class="chips">
100- <button v-for="c in CHIPS" :key="c" class="chip" :disabled="chat.busy" @click="send(c)">{{ c }}</button>
94+ <button v-for="c in CHIPS" :key="c" class="chip" :disabled="busy" @click="send(c)">{{ c }}</button>
10195 </div>
10296 <div v-if="shot" class="ctx">
10397 <img v-if="shot.image" class="shot sm" :src="shot.image" alt="">
@@ -108,14 +102,14 @@ defineExpose({ compose, send, restart });
108102 <span class="mono">{{ describe(target) }}</span>
109103 <button class="x-btn" :title="t('editing.chat.wholeVideo')" @click="attach = false">×</button>
110104 </div>
111- <textarea ref="box" v-model="input" rows="3" :disabled="chat.busy"
112- :placeholder="t(chat.busy ? 'editing.chat.busy' : shot ? 'editing.chat.onFrame' : target ? 'editing.chat.onSelection' : 'editing.chat.onVideo')" @keydown="onKey" />
105+ <textarea ref="box" v-model="input" rows="3" :disabled="busy"
106+ :placeholder="t(busy ? 'editing.chat.busy' : shot ? 'editing.chat.onFrame' : target ? 'editing.chat.onSelection' : task ? 'editing.chat.reply' : 'editing.chat.onVideo')" @keydown="onKey" />
113107 <div class="foot">
114- <span v-if="chat.usage" class="usage mono" :title="t('editing.chat.usage')">
115- <template v-for="(u, k) in chat.usage" :key="k">{{ k === "five_hour" ? t("editing.chat.fiveHour") : k === "seven_day" ? t("editing.chat.sevenDay") : k }} {{ Math.round(u * 100) }} % </template>
108+ <span v-if="usage" class="usage mono" :title="t('editing.chat.usage')">
109+ <template v-for="(u, k) in usage" :key="k">{{ k === "five_hour" ? t("editing.chat.fiveHour") : k === "seven_day" ? t("editing.chat.sevenDay") : k }} {{ Math.round(u * 100) }} % </template>
116110 </span>
117111 <span class="spacer" />
118- <button v-if="chat.busy" class="btn" @click="emit('stop')">{{ t("common.stop") }}</button>
112+ <button v-if="busy" class="btn" @click="emit('stop')">{{ t("common.stop") }}</button>
119113 <button v-else class="btn dark" :disabled="!input.trim()" @click="send()">{{ t("common.send") }}</button>
120114 </div>
121115 </div>
@@ -125,8 +119,7 @@ defineExpose({ compose, send, restart });
125119 <style scoped>
126120 .chat { flex: 1; min-height: 0; display: flex; flex-direction: column; }
127121 .thread { flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 12px; }
128-.empty { font-size: 13px; color: var(--faint); line-height: 1.55; display: flex; flex-direction: column; align-items: flex-start; gap: 6px; }
129-.empty .link-btn { font-size: 12.5px; }
122+.empty { font-size: 13px; color: var(--faint); line-height: 1.55; }
130123 .msg { display: flex; flex-direction: column; gap: 4px; max-width: 100%; }
131124 .msg.user { align-items: flex-end; }
132125 .msg.ai { align-items: flex-start; }
@@ -1,16 +1,18 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2 /**2 /**
3- * Le chat avec le monteur (Claude) : tu es le client, il travaille dans la timeline sous tes yeux.3+ * Une demande au monteur (Claude) et sa conversation, ou une nouvelle demande (`task` null), mise en file :
4- * Une demande porte sur la sélection en cours (élément ou plage), affichée au-dessus de la saisie.4+ * on peut en lancer d'autres sans attendre. Une demande porte sur la sélection en cours (élément ou plage),
5- * Discuter de l'image en pause ouvre une nouvelle discussion, avec son timecode et l'image joints.5+ * affichée au-dessus de la saisie, ou sur l'image en pause, avec son timecode et la capture jointes.
6 */6 */
7 import { computed, nextTick, ref, watch } from "vue";7 import { computed, nextTick, ref, watch } from "vue";
8 import { useI18n } from "vue-i18n";8 import { useI18n } from "vue-i18n";
9 import { store } from "../../store";9 import { store } from "../../store";
10-import { tcd, trackLabel, type MontageView, type Selection, type Shot } from "./types";10+import { tcd, trackLabel, type MontageTask, type Selection, type Shot } from "./types";
11 11
12-const props = defineProps<{ chat: MontageView["chat"]; selection: Selection | null; describe: (s: Selection) => string }>();12+const props = defineProps<{
13-const emit = defineEmits<{ send: [string, Selection | null, Shot | null, boolean]; stop: []; pick: [Selection]; seek: [number] }>();13+ task: MontageTask | null; usage: Record<string, number> | null; selection: Selection | null; describe: (s: Selection) => string;
14+}>();
15+const emit = defineEmits<{ send: [string, Selection | null, Shot | null]; stop: []; pick: [Selection]; seek: [number] }>();
14 16
15 const { t } = useI18n();17 const { t } = useI18n();
16 const input = ref("");18 const input = ref("");
@@ -22,25 +24,24 @@ watch(() => JSON.stringify(props.selection), () => { attach.value = true; });
22 const target = computed(() => (attach.value ? props.selection : null));24 const target = computed(() => (attach.value ? props.selection : null));
23 /** l'image en pause dont on parle (son temps et sa capture), jointe à la demande */25 /** l'image en pause dont on parle (son temps et sa capture), jointe à la demande */
24 const shot = ref<Shot | null>(null);26 const shot = ref<Shot | null>(null);
25-/** la prochaine demande ouvre une nouvelle discussion : l'ancien fil est masqué, puis effacé à l'envoi */27+const messages = computed(() => props.task?.messages ?? []);
26-const fresh = ref(false);28+/** Daryl traite la demande, ou elle attend son tour : on ne peut pas y répondre */
27-const messages = computed(() => (fresh.value ? [] : props.chat.messages));29+const busy = computed(() => props.task?.status === "queued" || props.task?.status === "running");
28 30
29 const CHIPS = computed(() => (shot.value ? [] : target.value31 const CHIPS = computed(() => (shot.value ? [] : target.value
30 ? ["motion", "broll", "zooms", "sound"].map((k) => t(`editing.chat.chipsSelection.${k}`))32 ? ["motion", "broll", "zooms", "sound"].map((k) => t(`editing.chat.chipsSelection.${k}`))
31 : ["chapters", "music", "zooms"].map((k) => t(`editing.chat.chipsVideo.${k}`))));33 : ["chapters", "music", "zooms"].map((k) => t(`editing.chat.chipsVideo.${k}`))));
32 34
33-watch(() => [props.chat.messages.length, props.chat.messages.at(-1)?.text.length, props.chat.busy, props.chat.activity], async () => {35+watch(() => [props.task?.id, messages.value.length, messages.value.at(-1)?.text.length, props.task?.status, props.task?.activity], async () => {
34 await nextTick();36 await nextTick();
35 thread.value?.scrollTo({ top: thread.value.scrollHeight });37 thread.value?.scrollTo({ top: thread.value.scrollHeight });
36 }, { immediate: true });38 }, { immediate: true });
37 39
38 function send(text = input.value) {40 function send(text = input.value) {
39- if (!text.trim() || props.chat.busy) return;41+ if (!text.trim() || busy.value) return;
40- emit("send", text.trim(), target.value, shot.value, fresh.value);42+ emit("send", text.trim(), target.value, shot.value);
41 input.value = "";43 input.value = "";
42 shot.value = null;44 shot.value = null;
43- fresh.value = false;
44 }45 }
45 function onKey(e: KeyboardEvent) {46 function onKey(e: KeyboardEvent) {
46 e.stopPropagation();47 e.stopPropagation();
@@ -62,29 +63,21 @@ async function compose(text = "") {
62 await nextTick();63 await nextTick();
63 box.value?.focus();64 box.value?.focus();
64 }65 }
65-/** Repart d'une discussion vierge ; `at` : l'image en pause dont le client veut parler. */66+/** Joint l'image en pause dont le client veut parler (à la place de la sélection). */
66-async function restart(at: Shot | null = null) {67+async function frame(at: Shot) {
67- fresh.value = true;
68 shot.value = at;68 shot.value = at;
69- if (at) attach.value = false;69+ attach.value = false;
70 await nextTick();70 await nextTick();
71 box.value?.focus();71 box.value?.focus();
72 }72 }
73-function resume() {
74- fresh.value = false;
75- shot.value = null;
76-}
77 const fileUrl = (rel: string) => `/api/projects/${store.project?.id}/files/${rel}`;73 const fileUrl = (rel: string) => `/api/projects/${store.project?.id}/files/${rel}`;
78-defineExpose({ compose, send, restart });74+defineExpose({ compose, send, frame });
79 </script>75 </script>
80 76
81 <template>77 <template>
82 <div class="chat">78 <div class="chat">
83 <div ref="thread" class="thread">79 <div ref="thread" class="thread">
84- <span v-if="!messages.length" class="empty">80+ <span v-if="!messages.length" class="empty">{{ t("editing.chat.empty") }}</span>
85- {{ t(fresh ? "editing.chat.fresh" : "editing.chat.empty") }}
86- <button v-if="fresh && chat.messages.length" class="link-btn" @click="resume">{{ t("editing.chat.resume") }}</button>
87- </span>
88 <div v-for="(m, i) in messages" :key="i" class="msg" :class="m.role">81 <div v-for="(m, i) in messages" :key="i" class="msg" :class="m.role">
89 <img v-if="m.role === 'user' && m.image" class="shot" :src="fileUrl(m.image)" alt="" @click="emit('seek', m.frame!)">82 <img v-if="m.role === 'user' && m.image" class="shot" :src="fileUrl(m.image)" alt="" @click="emit('seek', m.frame!)">
90 <span v-if="m.role === 'user' && m.frame != null" class="ref mono" @click="emit('seek', m.frame!)">{{ t("editing.chat.frameRef", { tc: tcd(m.frame) }) }}</span>83 <span v-if="m.role === 'user' && m.frame != null" class="ref mono" @click="emit('seek', m.frame!)">{{ t("editing.chat.frameRef", { tc: tcd(m.frame) }) }}</span>
@@ -92,12 +85,13 @@ defineExpose({ compose, send, restart });
92 <span v-if="m.text" class="text">{{ m.text }}</span>85 <span v-if="m.text" class="text">{{ m.text }}</span>
93 <span v-if="m.role === 'ai' && m.tools?.length" class="tools mono" :title="m.tools.join(' · ')">{{ t("editing.chat.actions", m.tools.length) }}{{ m.changed ? t("editing.chat.timelineChanged") : "" }}</span>86 <span v-if="m.role === 'ai' && m.tools?.length" class="tools mono" :title="m.tools.join(' · ')">{{ t("editing.chat.actions", m.tools.length) }}{{ m.changed ? t("editing.chat.timelineChanged") : "" }}</span>
94 </div>87 </div>
95- <span v-if="chat.busy && !fresh" class="busy mono">{{ chat.activity ? `${chat.activity}…` : "…" }}</span>88+ <span v-if="task?.status === 'queued'" class="busy mono">{{ t("editing.chat.queued") }}</span>
96- <span v-if="chat.error" class="error">{{ chat.error }}</span>89+ <span v-else-if="task?.status === 'running'" class="busy mono">{{ task.activity ? `${task.activity}…` : "…" }}</span>
90+ <span v-if="task?.error" class="error">{{ task.error }}</span>
97 </div>91 </div>
98 <div class="compose">92 <div class="compose">
99 <div class="chips">93 <div class="chips">
100- <button v-for="c in CHIPS" :key="c" class="chip" :disabled="chat.busy" @click="send(c)">{{ c }}</button>94+ <button v-for="c in CHIPS" :key="c" class="chip" :disabled="busy" @click="send(c)">{{ c }}</button>
101 </div>95 </div>
102 <div v-if="shot" class="ctx">96 <div v-if="shot" class="ctx">
103 <img v-if="shot.image" class="shot sm" :src="shot.image" alt="">97 <img v-if="shot.image" class="shot sm" :src="shot.image" alt="">
@@ -108,14 +102,14 @@ defineExpose({ compose, send, restart });
108 <span class="mono">{{ describe(target) }}</span>102 <span class="mono">{{ describe(target) }}</span>
109 <button class="x-btn" :title="t('editing.chat.wholeVideo')" @click="attach = false">×</button>103 <button class="x-btn" :title="t('editing.chat.wholeVideo')" @click="attach = false">×</button>
110 </div>104 </div>
111- <textarea ref="box" v-model="input" rows="3" :disabled="chat.busy"105+ <textarea ref="box" v-model="input" rows="3" :disabled="busy"
112- :placeholder="t(chat.busy ? 'editing.chat.busy' : shot ? 'editing.chat.onFrame' : target ? 'editing.chat.onSelection' : 'editing.chat.onVideo')" @keydown="onKey" />106+ :placeholder="t(busy ? 'editing.chat.busy' : shot ? 'editing.chat.onFrame' : target ? 'editing.chat.onSelection' : task ? 'editing.chat.reply' : 'editing.chat.onVideo')" @keydown="onKey" />
113 <div class="foot">107 <div class="foot">
114- <span v-if="chat.usage" class="usage mono" :title="t('editing.chat.usage')">108+ <span v-if="usage" class="usage mono" :title="t('editing.chat.usage')">
115- <template v-for="(u, k) in chat.usage" :key="k">{{ k === "five_hour" ? t("editing.chat.fiveHour") : k === "seven_day" ? t("editing.chat.sevenDay") : k }} {{ Math.round(u * 100) }} % </template>109+ <template v-for="(u, k) in usage" :key="k">{{ k === "five_hour" ? t("editing.chat.fiveHour") : k === "seven_day" ? t("editing.chat.sevenDay") : k }} {{ Math.round(u * 100) }} % </template>
116 </span>110 </span>
117 <span class="spacer" />111 <span class="spacer" />
118- <button v-if="chat.busy" class="btn" @click="emit('stop')">{{ t("common.stop") }}</button>112+ <button v-if="busy" class="btn" @click="emit('stop')">{{ t("common.stop") }}</button>
119 <button v-else class="btn dark" :disabled="!input.trim()" @click="send()">{{ t("common.send") }}</button>113 <button v-else class="btn dark" :disabled="!input.trim()" @click="send()">{{ t("common.send") }}</button>
120 </div>114 </div>
121 </div>115 </div>
@@ -125,8 +119,7 @@ defineExpose({ compose, send, restart });
125 <style scoped>119 <style scoped>
126 .chat { flex: 1; min-height: 0; display: flex; flex-direction: column; }120 .chat { flex: 1; min-height: 0; display: flex; flex-direction: column; }
127 .thread { flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 12px; }121 .thread { flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 12px; }
128-.empty { font-size: 13px; color: var(--faint); line-height: 1.55; display: flex; flex-direction: column; align-items: flex-start; gap: 6px; }122+.empty { font-size: 13px; color: var(--faint); line-height: 1.55; }
129-.empty .link-btn { font-size: 12.5px; }
130 .msg { display: flex; flex-direction: column; gap: 4px; max-width: 100%; }123 .msg { display: flex; flex-direction: column; gap: 4px; max-width: 100%; }
131 .msg.user { align-items: flex-end; }124 .msg.user { align-items: flex-end; }
132 .msg.ai { align-items: flex-start; }125 .msg.ai { align-items: flex-start; }
added web/src/project/steps/montage/TaskQueue.vue +46 -0
new file mode 100644
@@ -0,0 +1,46 @@
1+<script setup lang="ts">
2+/**
3+ * La file des demandes faites à Daryl, dans l'ordre : chacune avec son état et sa portée (sélection ou image en
4+ * pause). Ouvrir une demande montre sa conversation, où on peut la reprendre, et va à sa portée dans la timeline.
5+ */
6+import { useI18n } from "vue-i18n";
7+import { tcd, type MontageTask, type Selection } from "./types";
8+
9+defineProps<{ tasks: MontageTask[]; current: string | null; describe: (s: Selection) => string }>();
10+const emit = defineEmits<{ open: [MontageTask]; stop: [string]; close: [string] }>();
11+
12+const { t } = useI18n();
13+const first = (task: MontageTask) => task.messages.find((m) => m.role === "user");
14+const active = (task: MontageTask) => task.status === "queued" || task.status === "running";
15+</script>
16+
17+<template>
18+ <div class="queue">
19+ <div v-for="task in tasks" :key="task.id" class="row" :class="[task.status, { on: task.id === current }]" @click="emit('open', task)">
20+ <span class="st mono">{{ task.status === "running" && task.activity ? task.activity : t(`editing.tasks.${task.status}`) }}</span>
21+ <span class="title">{{ first(task)?.text }}</span>
22+ <span v-if="first(task)?.selection" class="scope mono">{{ describe(first(task)!.selection!) }}</span>
23+ <span v-else-if="first(task)?.frame != null" class="scope mono">{{ t("editing.chat.frameRef", { tc: tcd(first(task)!.frame!) }) }}</span>
24+ <button v-if="active(task)" class="x-btn" :title="t(task.status === 'queued' ? 'editing.tasks.unqueue' : 'common.stop')"
25+ @click.stop="emit('stop', task.id)">■</button>
26+ <button v-else class="x-btn" :title="t('editing.tasks.close')" @click.stop="emit('close', task.id)">×</button>
27+ </div>
28+ </div>
29+</template>
30+
31+<style scoped>
32+.queue { max-height: 34%; overflow-y: auto; border-bottom: 1px solid var(--line); display: flex; flex-direction: column; flex-shrink: 0; }
33+.row {
34+ display: grid; grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: "st title x" "st scope x";
35+ align-items: center; column-gap: 8px; padding: 6px 10px; cursor: pointer; border-left: 2px solid transparent;
36+}
37+.row + .row { border-top: 1px solid var(--line-soft); }
38+.row:hover { background: var(--bg-soft); }
39+.row.on { background: var(--bg-soft); border-left-color: var(--red); }
40+.st { grid-area: st; font-size: 10.5px; color: var(--faint); max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
41+.running .st { color: #7C5CE0; }
42+.error .st { color: var(--red-ink); }
43+.title { grid-area: title; font-size: 12.5px; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
44+.scope { grid-area: scope; font-size: 10.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
45+.x-btn { grid-area: x; }
46+</style>
new file mode 100644
@@ -0,0 +1,46 @@
1+<script setup lang="ts">
2+/**
3+ * La file des demandes faites à Daryl, dans l'ordre : chacune avec son état et sa portée (sélection ou image en
4+ * pause). Ouvrir une demande montre sa conversation, où on peut la reprendre, et va à sa portée dans la timeline.
5+ */
6+import { useI18n } from "vue-i18n";
7+import { tcd, type MontageTask, type Selection } from "./types";
8+
9+defineProps<{ tasks: MontageTask[]; current: string | null; describe: (s: Selection) => string }>();
10+const emit = defineEmits<{ open: [MontageTask]; stop: [string]; close: [string] }>();
11+
12+const { t } = useI18n();
13+const first = (task: MontageTask) => task.messages.find((m) => m.role === "user");
14+const active = (task: MontageTask) => task.status === "queued" || task.status === "running";
15+</script>
16+
17+<template>
18+ <div class="queue">
19+ <div v-for="task in tasks" :key="task.id" class="row" :class="[task.status, { on: task.id === current }]" @click="emit('open', task)">
20+ <span class="st mono">{{ task.status === "running" && task.activity ? task.activity : t(`editing.tasks.${task.status}`) }}</span>
21+ <span class="title">{{ first(task)?.text }}</span>
22+ <span v-if="first(task)?.selection" class="scope mono">{{ describe(first(task)!.selection!) }}</span>
23+ <span v-else-if="first(task)?.frame != null" class="scope mono">{{ t("editing.chat.frameRef", { tc: tcd(first(task)!.frame!) }) }}</span>
24+ <button v-if="active(task)" class="x-btn" :title="t(task.status === 'queued' ? 'editing.tasks.unqueue' : 'common.stop')"
25+ @click.stop="emit('stop', task.id)">■</button>
26+ <button v-else class="x-btn" :title="t('editing.tasks.close')" @click.stop="emit('close', task.id)">×</button>
27+ </div>
28+ </div>
29+</template>
30+
31+<style scoped>
32+.queue { max-height: 34%; overflow-y: auto; border-bottom: 1px solid var(--line); display: flex; flex-direction: column; flex-shrink: 0; }
33+.row {
34+ display: grid; grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: "st title x" "st scope x";
35+ align-items: center; column-gap: 8px; padding: 6px 10px; cursor: pointer; border-left: 2px solid transparent;
36+}
37+.row + .row { border-top: 1px solid var(--line-soft); }
38+.row:hover { background: var(--bg-soft); }
39+.row.on { background: var(--bg-soft); border-left-color: var(--red); }
40+.st { grid-area: st; font-size: 10.5px; color: var(--faint); max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
41+.running .st { color: #7C5CE0; }
42+.error .st { color: var(--red-ink); }
43+.title { grid-area: title; font-size: 12.5px; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
44+.scope { grid-area: scope; font-size: 10.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
45+.x-btn { grid-area: x; }
46+</style>
modified web/src/project/steps/montage/types.ts +12 -1
@@ -60,7 +60,18 @@ export interface MontageView {
6060 motions: Record<string, MotionState>;
6161 can_undo: boolean;
6262 export: { rel: string; name: string; exists: boolean };
63- chat: { messages: MontageMessage[]; busy: boolean; activity: string | null; usage: Record<string, number> | null; error: string | null };
63+ /** les demandes faites à Daryl, dans l'ordre : chacune a sa conversation ; plusieurs sont traitées à la fois */
64+ tasks: MontageTask[];
65+ /** Daryl traite une demande */
66+ busy: boolean;
67+ /** l'utilisation de l'abonnement Claude, la dernière connue */
68+ usage: Record<string, number> | null;
69+}
70+
71+/** Une demande à Daryl et sa conversation : en file, en cours, puis finie. */
72+export interface MontageTask {
73+ id: string; status: "queued" | "running" | "done" | "error" | "cancelled";
74+ messages: MontageMessage[]; activity: string | null; error: string | null;
6475 }
6576
6677 export type BeatType = "motion" | "zoom" | "broll" | "transition";
@@ -60,7 +60,18 @@ export interface MontageView {
60 motions: Record<string, MotionState>;60 motions: Record<string, MotionState>;
61 can_undo: boolean;61 can_undo: boolean;
62 export: { rel: string; name: string; exists: boolean };62 export: { rel: string; name: string; exists: boolean };
63- chat: { messages: MontageMessage[]; busy: boolean; activity: string | null; usage: Record<string, number> | null; error: string | null };63+ /** les demandes faites à Daryl, dans l'ordre : chacune a sa conversation ; plusieurs sont traitées à la fois */
64+ tasks: MontageTask[];
65+ /** Daryl traite une demande */
66+ busy: boolean;
67+ /** l'utilisation de l'abonnement Claude, la dernière connue */
68+ usage: Record<string, number> | null;
69+}
70+
71+/** Une demande à Daryl et sa conversation : en file, en cours, puis finie. */
72+export interface MontageTask {
73+ id: string; status: "queued" | "running" | "done" | "error" | "cancelled";
74+ messages: MontageMessage[]; activity: string | null; error: string | null;
64 }75 }
65 76
66 export type BeatType = "motion" | "zoom" | "broll" | "transition";77 export type BeatType = "motion" | "zoom" | "broll" | "transition";
modified web/src/project/steps/montage/useMontage.ts +24 -11
@@ -1,10 +1,10 @@
11 /**
22 * L'état du montage : la timeline (modifiée tout de suite à l'écran, enregistrée peu après),
3- * le chat avec le monteur, et les médias. Le monteur travaille sur sa copie : on continue de monter
3+ * les demandes au monteur, et les médias. Le monteur travaille sur sa copie : on continue de monter
44 * pendant ce temps, ses changements sont fusionnés à la fin de son tour (les nôtres l'emportent).
55 */
66 import { computed, onUnmounted, ref } from "vue";
7-import { api, post, putJson } from "../../../lib/api";
7+import { api, del, post, putJson } from "../../../lib/api";
88 import { toast } from "../../../lib/toast";
99 import { clone, totalOf, type AvailableMedia, type MontageView, type Selection, type Shot, type Timeline } from "./types";
1010
@@ -18,7 +18,7 @@ export function useMontage(pid: string) {
1818 /** un glisser en cours : on n'écrase rien */
1919 const holding = ref(false);
2020
21- const busy = computed(() => !!view.value?.chat.busy);
21+ const busy = computed(() => !!view.value?.busy);
2222 const total = computed(() => (tl.value ? totalOf(tl.value) : 0));
2323
2424 /** la version du serveur dont vient la timeline à l'écran : une retouche faite dessus y est fusionnée si elle a changé */
@@ -102,20 +102,33 @@ export function useMontage(pid: string) {
102102 }
103103 }
104104
105- /** `shot` : l'image en pause dont on parle ; `fresh` : dans une nouvelle discussion. */
106- async function send(text: string, selection: Selection | null, shot: Shot | null = null, fresh = false) {
105+ /** Une demande à Daryl : la suite de la conversation `task`, sinon une nouvelle demande mise en file. `shot` :
106+ * l'image en pause dont on parle. Rend la demande (null si elle n'est pas partie). */
107+ async function send(text: string, selection: Selection | null, shot: Shot | null, task: string | null): Promise<string | null> {
107108 await flush();
108109 try {
109- receive(await post<MontageView>(`${base}/chat`, { text, selection, frame: shot?.t ?? null, image: shot?.image ?? null, fresh }), true);
110- return true;
110+ const v = await post<MontageView & { task: string }>(`${base}/chat`,
111+ { text, selection, frame: shot?.t ?? null, image: shot?.image ?? null, task });
112+ receive(v, true);
113+ return v.task;
111114 } catch (e) {
112115 toast((e as Error).message, true);
113- return false;
116+ return null;
114117 }
115118 }
116119
117- async function stop() {
118- receive(await post<MontageView>(`${base}/stop`), true);
120+ /** Arrête une demande en cours, ou la retire de la file. */
121+ async function stop(task: string) {
122+ receive(await post<MontageView>(`${base}/stop`, { task }));
123+ }
124+
125+ /** Retire une demande finie de la liste. */
126+ async function close(task: string) {
127+ try {
128+ receive(await del<MontageView>(`${base}/tasks/${task}`));
129+ } catch (e) {
130+ toast((e as Error).message, true);
131+ }
119132 }
120133
121134 async function exportResolve(): Promise<{ rel: string; name: string; problems: string[] } | null> {
@@ -143,7 +156,7 @@ export function useMontage(pid: string) {
143156 window.removeEventListener("focus", loadAvailable);
144157 });
145158
146- return { view, tl, available, dirty, holding, busy, total, edit, shiftBeats, save, flush, undo, send, stop, exportResolve, load, loadAvailable };
159+ return { view, tl, available, dirty, holding, busy, total, edit, shiftBeats, save, flush, undo, send, stop, close, exportResolve, load, loadAvailable };
147160 }
148161
149162 export type MontageState = ReturnType<typeof useMontage>;
@@ -1,10 +1,10 @@
1 /**1 /**
2 * L'état du montage : la timeline (modifiée tout de suite à l'écran, enregistrée peu après),2 * L'état du montage : la timeline (modifiée tout de suite à l'écran, enregistrée peu après),
3- * le chat avec le monteur, et les médias. Le monteur travaille sur sa copie : on continue de monter3+ * les demandes au monteur, et les médias. Le monteur travaille sur sa copie : on continue de monter
4 * pendant ce temps, ses changements sont fusionnés à la fin de son tour (les nôtres l'emportent).4 * pendant ce temps, ses changements sont fusionnés à la fin de son tour (les nôtres l'emportent).
5 */5 */
6 import { computed, onUnmounted, ref } from "vue";6 import { computed, onUnmounted, ref } from "vue";
7-import { api, post, putJson } from "../../../lib/api";7+import { api, del, post, putJson } from "../../../lib/api";
8 import { toast } from "../../../lib/toast";8 import { toast } from "../../../lib/toast";
9 import { clone, totalOf, type AvailableMedia, type MontageView, type Selection, type Shot, type Timeline } from "./types";9 import { clone, totalOf, type AvailableMedia, type MontageView, type Selection, type Shot, type Timeline } from "./types";
10 10
@@ -18,7 +18,7 @@ export function useMontage(pid: string) {
18 /** un glisser en cours : on n'écrase rien */18 /** un glisser en cours : on n'écrase rien */
19 const holding = ref(false);19 const holding = ref(false);
20 20
21- const busy = computed(() => !!view.value?.chat.busy);21+ const busy = computed(() => !!view.value?.busy);
22 const total = computed(() => (tl.value ? totalOf(tl.value) : 0));22 const total = computed(() => (tl.value ? totalOf(tl.value) : 0));
23 23
24 /** la version du serveur dont vient la timeline à l'écran : une retouche faite dessus y est fusionnée si elle a changé */24 /** la version du serveur dont vient la timeline à l'écran : une retouche faite dessus y est fusionnée si elle a changé */
@@ -102,20 +102,33 @@ export function useMontage(pid: string) {
102 }102 }
103 }103 }
104 104
105- /** `shot` : l'image en pause dont on parle ; `fresh` : dans une nouvelle discussion. */105+ /** Une demande à Daryl : la suite de la conversation `task`, sinon une nouvelle demande mise en file. `shot` :
106- async function send(text: string, selection: Selection | null, shot: Shot | null = null, fresh = false) {106+ * l'image en pause dont on parle. Rend la demande (null si elle n'est pas partie). */
107+ async function send(text: string, selection: Selection | null, shot: Shot | null, task: string | null): Promise<string | null> {
107 await flush();108 await flush();
108 try {109 try {
109- receive(await post<MontageView>(`${base}/chat`, { text, selection, frame: shot?.t ?? null, image: shot?.image ?? null, fresh }), true);110+ const v = await post<MontageView & { task: string }>(`${base}/chat`,
110- return true;111+ { text, selection, frame: shot?.t ?? null, image: shot?.image ?? null, task });
112+ receive(v, true);
113+ return v.task;
111 } catch (e) {114 } catch (e) {
112 toast((e as Error).message, true);115 toast((e as Error).message, true);
113- return false;116+ return null;
114 }117 }
115 }118 }
116 119
117- async function stop() {120+ /** Arrête une demande en cours, ou la retire de la file. */
118- receive(await post<MontageView>(`${base}/stop`), true);121+ async function stop(task: string) {
122+ receive(await post<MontageView>(`${base}/stop`, { task }));
123+ }
124+
125+ /** Retire une demande finie de la liste. */
126+ async function close(task: string) {
127+ try {
128+ receive(await del<MontageView>(`${base}/tasks/${task}`));
129+ } catch (e) {
130+ toast((e as Error).message, true);
131+ }
119 }132 }
120 133
121 async function exportResolve(): Promise<{ rel: string; name: string; problems: string[] } | null> {134 async function exportResolve(): Promise<{ rel: string; name: string; problems: string[] } | null> {
@@ -143,7 +156,7 @@ export function useMontage(pid: string) {
143 window.removeEventListener("focus", loadAvailable);156 window.removeEventListener("focus", loadAvailable);
144 });157 });
145 158
146- return { view, tl, available, dirty, holding, busy, total, edit, shiftBeats, save, flush, undo, send, stop, exportResolve, load, loadAvailable };159+ return { view, tl, available, dirty, holding, busy, total, edit, shiftBeats, save, flush, undo, send, stop, close, exportResolve, load, loadAvailable };
147 }160 }
148 161
149 export type MontageState = ReturnType<typeof useMontage>;162 export type MontageState = ReturnType<typeof useMontage>;