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>
c637961 parent: 2847388 modified
backend/studio/modules/projects/api.py +55 -22 | @@ -1201,13 +1201,27 @@ def capture_sources(pid: str): | ||
| 1201 | 1201 | |
| 1202 | 1202 | # --- Montage de la vidéo longue ---------------------------------------------------------- |
| 1203 | 1203 | |
| 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" | |
| 1206 | 1220 | |
| 1207 | 1221 | |
| 1208 | 1222 | 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)) | |
| 1211 | 1225 | |
| 1212 | 1226 | |
| 1213 | 1227 | def _montage_view(pid: str) -> dict: |
| @@ -1215,8 +1229,9 @@ def _montage_view(pid: str) -> dict: | ||
| 1215 | 1229 | source = montage._source(project) |
| 1216 | 1230 | timeline = montage.load(pid) |
| 1217 | 1231 | 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) | |
| 1220 | 1235 | path = montage.export_path(pid) |
| 1221 | 1236 | return { |
| 1222 | 1237 | "timeline": timeline, |
| @@ -1231,9 +1246,10 @@ def _montage_view(pid: str) -> dict: | ||
| 1231 | 1246 | "can_undo": bool(montage.history_steps(pid)), |
| 1232 | 1247 | "export": {"rel": path.relative_to(service.project_dir(pid)).as_posix(), "name": path.name, |
| 1233 | 1248 | "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, | |
| 1237 | 1253 | } |
| 1238 | 1254 | |
| 1239 | 1255 | |
| @@ -1288,8 +1304,8 @@ class MontageChatRequest(BaseModel): | ||
| 1288 | 1304 | frame: float | None = None |
| 1289 | 1305 | # l'image telle que le client la voit dans l'aperçu (A-roll, B-roll, motion, beats) : data URL JPEG |
| 1290 | 1306 | 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 | |
| 1293 | 1309 | |
| 1294 | 1310 | |
| 1295 | 1311 | def _save_view(pid: str, data_url: str) -> str: |
| @@ -1313,35 +1329,52 @@ def montage_chat(pid: str, req: MontageChatRequest): | ||
| 1313 | 1329 | _check(pid) |
| 1314 | 1330 | if not req.text.strip(): |
| 1315 | 1331 | raise HTTPException(400, "message vide") |
| 1316 | - job = _montage_job(pid) | |
| 1332 | + job = _montage_task(pid, req.task) if req.task else None | |
| 1317 | 1333 | 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": | |
| 1323 | 1336 | state["messages"].pop() |
| 1324 | 1337 | state["messages"].append({"role": "user", "text": req.text.strip(), "selection": req.selection or None, |
| 1325 | 1338 | "frame": req.frame, "image": _save_view(pid, req.image) if req.image else None, |
| 1326 | 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 | 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 | 1346 | except ValueError as e: |
| 1330 | 1347 | raise HTTPException(409, str(e)) from e |
| 1331 | 1348 | with service.editing(pid) as p: |
| 1332 | 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 | 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 | 1360 | _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: | |
| 1341 | 1363 | jobs.cancel(job) |
| 1342 | 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 | 1378 | class StoryboardRequest(BaseModel): |
| 1346 | 1379 | density: str = storyboard.DEFAULT_DENSITY |
| 1347 | 1380 | # 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 = None | 1304 | 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 JPEG | 1305 | # 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 = None | 1306 | 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 = False | 1308 | + 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 e | 1347 | 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_DENSITY | 1379 | 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) : | ||
| 10 | 10 | montage/<titre>.fcpxml l'export pour Resolve, refait à chaque modification |
| 11 | 11 | montage/.history/ les états précédents de timeline.json, pour annuler |
| 12 | 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 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 | |
| 15 | 16 | montage/.cache/ forme d'onde et vignettes de la vidéo source |
| 16 | 17 | assets/<catégorie>/ B-roll, musique, sons : un son de la bibliothèque y est copié |
| 17 | 18 | dès qu'il entre dans la timeline |
| @@ -64,9 +65,16 @@ RENDERS_DIR = "renders" | ||
| 64 | 65 | HISTORY_DIR = ".history" |
| 65 | 66 | CACHE_DIR = ".cache" |
| 66 | 67 | 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 | |
| 68 | 70 | WORK_DIR = "daryl" |
| 69 | 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 | 78 | REVS_DIR = ".revs" |
| 71 | 79 | REVS_MAX = 30 |
| 72 | 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 | 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 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 | |
| 368 | 377 | 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 | 378 | au premier pour que les temps restent justes. Les beats du storyboard ne suivent qu'à la fin du tour, si son A-roll |
| 370 | 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_FILE | |
| 381 | + file = work_dir(pid, task) / INSERTIONS_FILE | |
| 373 | 382 | if not file.is_file(): |
| 374 | 383 | return [] |
| 375 | 384 | try: |
| @@ -382,7 +391,7 @@ def apply_insertions(pid: str) -> list[str]: | ||
| 382 | 391 | wanted = [wanted] |
| 383 | 392 | project = service.load(pid) |
| 384 | 393 | 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 | 395 | problems, shifts = [], [] |
| 387 | 396 | for w in sorted((w for w in wanted if isinstance(w, dict)), key=lambda w: -_num(w.get("at"))): |
| 388 | 397 | src = _resolve_src(pid, str(w.get("src") or "")) |
| @@ -397,8 +406,8 @@ def apply_insertions(pid: str) -> list[str]: | ||
| 397 | 406 | continue |
| 398 | 407 | done = insert_aroll(timeline, _num(w.get("at")), {"src": src, "in": a, "out": b, "gain_db": _num(w.get("gain_db"))}) |
| 399 | 408 | 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 | |
| 402 | 411 | before = json.loads(noted.read_text(encoding="utf-8")) if noted.is_file() else [] |
| 403 | 412 | noted.write_text(json.dumps(before + shifts), encoding="utf-8") |
| 404 | 413 | return problems |
| @@ -468,8 +477,8 @@ def _locked(pid: str) -> Iterator[None]: | ||
| 468 | 477 | path.unlink(missing_ok=True) |
| 469 | 478 | |
| 470 | 479 | |
| 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.""" | |
| 473 | 482 | path = root(pid) / TIMELINE_FILE |
| 474 | 483 | if not path.is_file(): |
| 475 | 484 | return |
| @@ -478,7 +487,7 @@ def snapshot(pid: str, motions: Path | None = None) -> None: | ||
| 478 | 487 | target = history / str(n) |
| 479 | 488 | target.mkdir(parents=True, exist_ok=True) |
| 480 | 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 | 491 | (target / MOTION_DIR).mkdir(exist_ok=True) |
| 483 | 492 | shutil.copy2(comp, target / MOTION_DIR / comp.name) |
| 484 | 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 | 545 | # --- La copie de travail de Daryl ------------------------------------------------------------ |
| 537 | 546 | |
| 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]) | |
| 540 | 554 | |
| 541 | 555 | |
| 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 | |
| 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 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). | |
| 560 | 602 | """ |
| 561 | - work = root(pid) / WORK_DIR | |
| 603 | + work = work_dir(pid, task) | |
| 562 | 604 | base, theirs = _read(work / WORK_BASE / TIMELINE_FILE), _read(work / TIMELINE_FILE) |
| 563 | 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 | 610 | project = service.load(pid) |
| 566 | 611 | duration = (_source(project) or {}).get("duration") or 0.0 |
| 567 | 612 | theirs = clean(pid, theirs, duration) |
| @@ -576,7 +621,8 @@ def finish_work(pid: str, motions_changed: bool) -> tuple[bool, list[str]]: | ||
| 576 | 621 | comp = motion_dir(pid) / f"{old}.html" |
| 577 | 622 | if comp.is_file(): |
| 578 | 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 | 626 | _write(pid, clean(pid, merged, duration)) |
| 581 | 627 | # ses insertions dans l'A-roll sont gardées : les beats du storyboard les suivent |
| 582 | 628 | 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 modification | 10 | montage/<titre>.fcpxml l'export pour Resolve, refait à chaque modification |
| 11 | montage/.history/ les états précédents de timeline.json, pour annuler | 11 | 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 tour | 13 | + 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 source | 16 | 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 timeline | 18 | 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 = 60 | 67 | 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 | ||
| 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 = 30 | 79 | 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 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 | ||
| 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 dernier | 377 | 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-roll | 378 | 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_FILE | 381 | + 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.0 | 393 | 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 | continue | 406 | 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_FILE | 410 | + 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 problems | 413 | 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_FILE | 482 | path = root(pid) / TIMELINE_FILE |
| 474 | if not path.is_file(): | 483 | if not path.is_file(): |
| 475 | return | 484 | 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_FILE | 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]) | ||
| 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_DIR | 559 | + |
| 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 temps | 598 | + 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 tour | 599 | + 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_DIR | 603 | + 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.0 | 611 | 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 suivent | 627 | # 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 | ||
| 11 | 11 | import time |
| 12 | 12 | from pathlib import Path |
| 13 | 13 | |
| 14 | -from ...core import claude | |
| 14 | +from ...core import claude, jobs | |
| 15 | 15 | from ...core.system import tool |
| 16 | 16 | from ...core.tasks import TaskContext |
| 17 | 17 | from ..animations import render as hyperframes |
| @@ -49,7 +49,9 @@ devient noire avant la fin.""" | ||
| 49 | 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 | 55 | return f"""\ |
| 54 | 56 | Tu es le monteur d'un créateur de vidéos YouTube. Il est ton client : il te demande des \ |
| 55 | 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 | 60 | éducative : rythmé, lisible, jamais gratuit. |
| 59 | 61 | |
| 60 | 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 | 64 | les chapitres, la liste exacte des médias utilisables (B-roll, musique, sons) avec leur durée, \ |
| 63 | 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 | 67 | préférence, ou Write du fichier entier). Le client continue de monter à la main pendant que tu \ |
| 66 | 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 \ | |
| 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. | |
| 69 | 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 | 74 | et l'efface aussitôt écrit. |
| 72 | 75 | |
| 73 | -# La timeline (montage/daryl/timeline.json) | |
| 76 | +# La timeline ({work}/timeline.json) | |
| 74 | 77 | Temps de la timeline en secondes (`at`, `dur`, `t`) ; `in`/`out` : temps dans le média. |
| 75 | 78 | {{ |
| 76 | 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 | 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 | 91 | transition avec lui. `gain_db` : le volume d'un clip (−20 à +12, absent : 0) ; tu peux le changer directement. |
| 89 | 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 | 94 | [{{"src": "<chemin listé dans contexte.md>", "at": <instant de la timeline, de préférence dans un silence \ |
| 92 | 95 | entre deux phrases>, "in": 0, "out": <durée du média>, "gain_db": 0}}]. Le studio coupe l'A-roll à `at`, \ |
| 93 | 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 | 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 | 99 | par exemple) en temps de la timeline à jour. |
| 97 | 100 | - zooms : recadrage progressif de l'A-roll (montée et descente de 0,4 s faites à l'export). \ |
| 98 | 101 | scale 1.1 à 1.4 ; x, y : décalage du centre en fraction de l'image (−0.5 à 0.5). |
| 99 | 102 | - broll : plan de coupe muet qui couvre l'A-roll (la voix continue). `src` : EXACTEMENT un \ |
| 100 | 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 | 106 | Durée = celle de la composition (data-duration). |
| 103 | 107 | - audio : sons (role "sfx", −8 à −14 dB) et musique (role "music", −20 à −26 dB sous la voix) ; \ |
| 104 | 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 | 135 | - Les motions d'ensemble viennent du storyboard que le client a validé ; leurs animations sont créées \ |
| 132 | 136 | par des tâches à part. Un motion en cours de création (listé dans contexte.md) : n'y touche pas. |
| 133 | 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 | 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 | 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 | 142 | et montage/motion/*.html. |
| 139 | 143 | - Si un média manque (pas de B-roll adapté, pas de son), dis-le dans ta réponse plutôt que \ |
| 140 | 144 | d'inventer un chemin. |
| @@ -156,8 +160,8 @@ def _item_scope(item: dict) -> str: | ||
| 156 | 160 | 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: | |
| 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.""" | |
| 161 | 165 | project = service.load(pid) |
| 162 | 166 | from . import runtime |
| 163 | 167 | |
| @@ -206,7 +210,6 @@ def write_context(pid: str, selection: dict | None) -> None: | ||
| 206 | 210 | for m in items) if items else "(aucun)")) |
| 207 | 211 | parts.append("\n## Transcript (temps de la timeline)\n" + "\n".join( |
| 208 | 212 | f"[{_mmss(l['start'])} → {_mmss(l['end'])}] {l['text']}" for l in lines)) |
| 209 | - path = montage.root(pid) / "contexte.md" | |
| 210 | 213 | path.parent.mkdir(parents=True, exist_ok=True) |
| 211 | 214 | path.write_text("\n".join(parts) + "\n", encoding="utf-8") |
| 212 | 215 | |
| @@ -219,20 +222,82 @@ def _label(block: dict) -> str: | ||
| 219 | 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 | 283 | def chat(ctx: TaskContext) -> None: |
| 223 | 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 | 287 | messages = state["messages"] |
| 225 | 288 | while messages and messages[-1]["role"] == "ai": |
| 226 | 289 | messages.pop() |
| 227 | 290 | request = messages[-1] |
| 291 | + state["span"] = _span(pid, request) | |
| 292 | + _wait_turn(ctx, pid) | |
| 228 | 293 | 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(): | |
| 231 | 295 | montage.save(pid, montage.load(pid), keep_history=False) |
| 232 | - write_context(pid, request.get("selection")) | |
| 233 | 296 | # 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() | |
| 236 | 301 | |
| 237 | 302 | reply = {"role": "ai", "text": "", "tools": [], "at": time.time()} |
| 238 | 303 | messages.append(reply) |
| @@ -250,16 +315,15 @@ def chat(ctx: TaskContext) -> None: | ||
| 250 | 315 | scope = f"Portée : {_item_scope(selection['item'])}.\n\n" |
| 251 | 316 | if request.get("frame") is not None: |
| 252 | 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 | 319 | 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 | |
| 257 | 321 | 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 | |
| 261 | 325 | if insertions.is_file(): |
| 262 | - _insert(pid, reply) | |
| 326 | + _insert(pid, task, reply) | |
| 263 | 327 | ctx.save(force=True) |
| 264 | 328 | kind = event.get("type") |
| 265 | 329 | if kind == "system" and event.get("subtype") == "init": |
| @@ -280,6 +344,8 @@ def chat(ctx: TaskContext) -> None: | ||
| 280 | 344 | elif kind == "assistant": |
| 281 | 345 | for block in event["message"]["content"]: |
| 282 | 346 | if block["type"] == "tool_use": |
| 347 | + if comp := _composition(pid, block): | |
| 348 | + touched.add(comp) | |
| 283 | 349 | label = _label(block) |
| 284 | 350 | reply["tools"].append(label) |
| 285 | 351 | state["activity"] = label |
| @@ -294,10 +360,9 @@ def chat(ctx: TaskContext) -> None: | ||
| 294 | 360 | finally: |
| 295 | 361 | state["activity"] = None |
| 296 | 362 | 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) | |
| 301 | 366 | if changed: |
| 302 | 367 | montage.after_change(pid) |
| 303 | 368 | problems = reply.pop("problems", []) + kept |
| @@ -329,25 +394,25 @@ def _frame_scope(pid: str, t: float, image: str | None) -> str: | ||
| 329 | 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 | 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 | 400 | reply["tools"].append("insère un clip dans l'A-roll") |
| 336 | 401 | if problems: |
| 337 | 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 | 406 | """Fin de tour : la copie écrite par Claude est nettoyée (ids, bornes, médias copiés). |
| 342 | 407 | Pas pendant le tour : réécrire le fichier ferait échouer ses Edit suivants.""" |
| 343 | 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 | 410 | except (OSError, json.JSONDecodeError): |
| 346 | 411 | return |
| 347 | 412 | project = service.load(pid) |
| 348 | 413 | cleaned = montage.clean(pid, raw, (montage._source(project) or {}).get("duration") or 0.0) |
| 349 | 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 | 418 | # --- Rendu d'un motion design -------------------------------------------------------------- |
| @@ -11,7 +11,7 @@ import subprocess | |||
| 11 | import time | 11 | import time |
| 12 | from pathlib import Path | 12 | from pathlib import Path |
| 13 | 13 | ||
| 14 | -from ...core import claude | 14 | +from ...core import claude, jobs |
| 15 | from ...core.system import tool | 15 | from ...core.system import tool |
| 16 | from ...core.tasks import TaskContext | 16 | from ...core.tasks import TaskContext |
| 17 | from ..animations import render as hyperframes | 17 | 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 what | 160 | 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 runtime | 166 | 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_FILE | 294 | + 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 fin | 296 | # 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_FILE | 320 | + 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 à jour | 324 | + # 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"] = label | 351 | state["activity"] = label |
| @@ -294,10 +360,9 @@ def chat(ctx: TaskContext) -> None: | |||
| 294 | finally: | 360 | finally: |
| 295 | state["activity"] = None | 361 | 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", []) + kept | 368 | 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 | return | 411 | 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: | ||
| 272 | 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: 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 | |
| 276 | 276 | |
| 277 | 277 | TASKS = { |
| 278 | 278 | # 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 | 287 | "project_copy": Task(queue="io", run=copy), |
| 288 | 288 | "project_render": Task(queue="render", run=render), |
| 289 | 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 | 292 | storyboard.KIND: Task(queue="storyboard", run=storyboard.run), |
| 292 | 293 | storyboard_chat.KIND: Task(queue="storyboard", run=storyboard_chat.run), |
| 293 | 294 | # 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: 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 |
| 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èle | 278 | # 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 fois | 294 | # 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 { | ||
| 231 | 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 | 243 | chat: { |
| 235 | 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 | 246 | chipsSelection: { |
| 238 | 247 | motion: "Add motion design here, with transitions and sound design", |
| 239 | 248 | broll: "Add relevant B-roll", |
| @@ -252,9 +261,9 @@ export default { | ||
| 252 | 261 | onSelection: "Ask about the selection…", |
| 253 | 262 | onVideo: "Ask about the whole video…", |
| 254 | 263 | 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.", | |
| 258 | 267 | frame: "Frame at {tc}", |
| 259 | 268 | frameRef: "⏸ {tc}", |
| 260 | 269 | 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 { | ||
| 231 | 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 | 243 | chat: { |
| 235 | 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 | 246 | chipsSelection: { |
| 238 | 247 | motion: "Ajoute du motion design ici, avec transitions et sound design", |
| 239 | 248 | broll: "Place des B-roll pertinents", |
| @@ -252,9 +261,9 @@ export default { | ||
| 252 | 261 | onSelection: "Demande sur la sélection…", |
| 253 | 262 | onVideo: "Demande sur toute la vidéo…", |
| 254 | 263 | 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.", | |
| 258 | 267 | frame: "Image à {tc}", |
| 259 | 268 | frameRef: "⏸ {tc}", |
| 260 | 269 | 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 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | 2 | /** |
| 3 | 3 | * 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 | 6 | * Tout s'exporte en FCPXML dans le dossier du projet, que DaVinci Resolve ouvre tel quel. |
| 6 | 7 | * |
| 7 | 8 | * Raccourcis (conventions de Resolve) : Espace lecture, J/K/L, ←/→ image par image (Maj : 1 s), |
| 8 | 9 | * ↑/↓ coupe précédente / suivante, A sélection, B lame, Ctrl+B couper à la tête, Suppr supprimer, |
| 9 | 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 | 13 | import { useI18n } from "vue-i18n"; |
| 13 | 14 | import { api, post } from "../../lib/api"; |
| 14 | 15 | import { toast } from "../../lib/toast"; |
| @@ -17,10 +18,11 @@ import { usePlaylist, type Piece } from "./derush/usePlaylist"; | ||
| 17 | 18 | import BeatPanel from "./montage/BeatPanel.vue"; |
| 18 | 19 | import Inspector from "./montage/Inspector.vue"; |
| 19 | 20 | import MonteurChat from "./montage/MonteurChat.vue"; |
| 21 | +import TaskQueue from "./montage/TaskQueue.vue"; | |
| 20 | 22 | import { addMedia, bladeAroll, bladeItem, insertAroll, isVideo, remove } from "./montage/ops"; |
| 21 | 23 | import Preview from "./montage/Preview.vue"; |
| 22 | 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 | 26 | import { useMontage } from "./montage/useMontage"; |
| 25 | 27 | import { useStoryboard } from "./montage/useStoryboard"; |
| 26 | 28 | |
| @@ -111,19 +113,43 @@ function pick(s: Selection) { | ||
| 111 | 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 | 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 | 143 | async function discussFrame() { |
| 121 | 144 | const at = t.value; |
| 122 | 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 | 153 | chat.value?.compose(text ?? ""); |
| 128 | 154 | } |
| 129 | 155 | |
| @@ -360,10 +386,12 @@ onUnmounted(() => window.removeEventListener("keydown", onKey)); | ||
| 360 | 386 | <div class="tabs"> |
| 361 | 387 | <span class="tab on" :title="tr('editing.chat.sub')">{{ tr("common.assistant") }}</span> |
| 362 | 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 | 390 | </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" /> | |
| 367 | 395 | </div> |
| 368 | 396 | </div> |
| 369 | 397 | </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 chat | 3 | * 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 @@ | ||
| 1 | 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. | |
| 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. | |
| 6 | 6 | */ |
| 7 | 7 | import { computed, nextTick, ref, watch } from "vue"; |
| 8 | 8 | import { useI18n } from "vue-i18n"; |
| 9 | 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 }>(); | |
| 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] }>(); | |
| 14 | 16 | |
| 15 | 17 | const { t } = useI18n(); |
| 16 | 18 | const input = ref(""); |
| @@ -22,25 +24,24 @@ watch(() => JSON.stringify(props.selection), () => { attach.value = true; }); | ||
| 22 | 24 | const target = computed(() => (attach.value ? props.selection : null)); |
| 23 | 25 | /** l'image en pause dont on parle (son temps et sa capture), jointe à la demande */ |
| 24 | 26 | 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"); | |
| 28 | 30 | |
| 29 | 31 | const CHIPS = computed(() => (shot.value ? [] : target.value |
| 30 | 32 | ? ["motion", "broll", "zooms", "sound"].map((k) => t(`editing.chat.chipsSelection.${k}`)) |
| 31 | 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 | 36 | await nextTick(); |
| 35 | 37 | thread.value?.scrollTo({ top: thread.value.scrollHeight }); |
| 36 | 38 | }, { immediate: true }); |
| 37 | 39 | |
| 38 | 40 | 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); | |
| 41 | 43 | input.value = ""; |
| 42 | 44 | shot.value = null; |
| 43 | - fresh.value = false; | |
| 44 | 45 | } |
| 45 | 46 | function onKey(e: KeyboardEvent) { |
| 46 | 47 | e.stopPropagation(); |
| @@ -62,29 +63,21 @@ async function compose(text = "") { | ||
| 62 | 63 | await nextTick(); |
| 63 | 64 | box.value?.focus(); |
| 64 | 65 | } |
| 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) { | |
| 68 | 68 | shot.value = at; |
| 69 | - if (at) attach.value = false; | |
| 69 | + attach.value = false; | |
| 70 | 70 | await nextTick(); |
| 71 | 71 | box.value?.focus(); |
| 72 | 72 | } |
| 73 | -function resume() { | |
| 74 | - fresh.value = false; | |
| 75 | - shot.value = null; | |
| 76 | -} | |
| 77 | 73 | const fileUrl = (rel: string) => `/api/projects/${store.project?.id}/files/${rel}`; |
| 78 | -defineExpose({ compose, send, restart }); | |
| 74 | +defineExpose({ compose, send, frame }); | |
| 79 | 75 | </script> |
| 80 | 76 | |
| 81 | 77 | <template> |
| 82 | 78 | <div class="chat"> |
| 83 | 79 | <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> | |
| 88 | 81 | <div v-for="(m, i) in messages" :key="i" class="msg" :class="m.role"> |
| 89 | 82 | <img v-if="m.role === 'user' && m.image" class="shot" :src="fileUrl(m.image)" alt="" @click="emit('seek', m.frame!)"> |
| 90 | 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 | 85 | <span v-if="m.text" class="text">{{ m.text }}</span> |
| 93 | 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 | 87 | </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> | |
| 97 | 91 | </div> |
| 98 | 92 | <div class="compose"> |
| 99 | 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 | 95 | </div> |
| 102 | 96 | <div v-if="shot" class="ctx"> |
| 103 | 97 | <img v-if="shot.image" class="shot sm" :src="shot.image" alt=""> |
| @@ -108,14 +102,14 @@ defineExpose({ compose, send, restart }); | ||
| 108 | 102 | <span class="mono">{{ describe(target) }}</span> |
| 109 | 103 | <button class="x-btn" :title="t('editing.chat.wholeVideo')" @click="attach = false">×</button> |
| 110 | 104 | </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" /> | |
| 113 | 107 | <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> | |
| 116 | 110 | </span> |
| 117 | 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 | 113 | <button v-else class="btn dark" :disabled="!input.trim()" @click="send()">{{ t("common.send") }}</button> |
| 120 | 114 | </div> |
| 121 | 115 | </div> |
| @@ -125,8 +119,7 @@ defineExpose({ compose, send, restart }); | ||
| 125 | 119 | <style scoped> |
| 126 | 120 | .chat { flex: 1; min-height: 0; display: flex; flex-direction: column; } |
| 127 | 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; } | |
| 129 | -.empty .link-btn { font-size: 12.5px; } | |
| 122 | +.empty { font-size: 13px; color: var(--faint); line-height: 1.55; } | |
| 130 | 123 | .msg { display: flex; flex-direction: column; gap: 4px; max-width: 100%; } |
| 131 | 124 | .msg.user { align-items: flex-end; } |
| 132 | 125 | .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.value | 31 | 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 { | ||
| 60 | 60 | motions: Record<string, MotionState>; |
| 61 | 61 | can_undo: boolean; |
| 62 | 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 | 77 | 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 @@ | ||
| 1 | 1 | /** |
| 2 | 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 monter | |
| 3 | + * les demandes au monteur, et les médias. Le monteur travaille sur sa copie : on continue de monter | |
| 4 | 4 | * pendant ce temps, ses changements sont fusionnés à la fin de son tour (les nôtres l'emportent). |
| 5 | 5 | */ |
| 6 | 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 | 8 | import { toast } from "../../../lib/toast"; |
| 9 | 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 | 18 | /** un glisser en cours : on n'écrase rien */ |
| 19 | 19 | const holding = ref(false); |
| 20 | 20 | |
| 21 | - const busy = computed(() => !!view.value?.chat.busy); | |
| 21 | + const busy = computed(() => !!view.value?.busy); | |
| 22 | 22 | const total = computed(() => (tl.value ? totalOf(tl.value) : 0)); |
| 23 | 23 | |
| 24 | 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. */ | |
| 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> { | |
| 107 | 108 | await flush(); |
| 108 | 109 | 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; | |
| 111 | 114 | } catch (e) { |
| 112 | 115 | toast((e as Error).message, true); |
| 113 | - return false; | |
| 116 | + return null; | |
| 114 | 117 | } |
| 115 | 118 | } |
| 116 | 119 | |
| 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 | + } | |
| 119 | 132 | } |
| 120 | 133 | |
| 121 | 134 | async function exportResolve(): Promise<{ rel: string; name: string; problems: string[] } | null> { |
| @@ -143,7 +156,7 @@ export function useMontage(pid: string) { | ||
| 143 | 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 | 162 | 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 monter | 3 | + * 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>; |