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

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

Download ZIP

fix(derush): export the open timeline instead of always the main one

The MP4, Premiere and Resolve exports always rendered the main timeline,
whatever timeline was open. Exports now take the timeline id (?tl=) and
render only it. Other timelines get their own file so derush.mp4, the
source of the next steps, is never overwritten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hlassiege committed 2026-10-12T17:18:30+02:00 Browse files
48f232c parent: 22449ed
modified backend/studio/modules/projects/api.py +35 -19
@@ -926,52 +926,68 @@ def start_auto_derush(pid: str, req: AutoDerushRequest):
926926 return _view(pid)
927927
928928
929+def _export_name(project: dict, tid: str) -> str:
930+ """Le nom des fichiers exportés d'une timeline : « derush » pour la globale, son nom pour les autres."""
931+ if tid == project["derush"]["main"]:
932+ return "derush"
933+ return service.safe_name((runtime.timeline(project, tid) or {}).get("name") or tid)
934+
935+
936+def _export_timeline(project: dict, tl: str | None) -> tuple[list[dict], str]:
937+ """Les plages de la timeline à exporter (la globale par défaut), et son nom de fichier."""
938+ tid = tl or project["derush"]["main"]
939+ if runtime.timeline(project, tid) is None:
940+ raise HTTPException(404, "timeline inconnue")
941+ found = runtime.ranges(project, tid)
942+ if not found:
943+ raise HTTPException(400, "rien à exporter")
944+ return found, _export_name(project, tid)
945+
946+
929947 @router.get("/{pid}/derush/export.xml")
930-def export_xml(pid: str):
948+def export_xml(pid: str, tl: str | None = None):
931949 _check(pid)
932950 project = service.load(pid)
933- found = runtime.ranges(project)
934- if not found:
935- raise HTTPException(400, "rien à exporter")
936- body = derush.timeline_xml(project, found, f"{project['title']} — dérush")
937- dest = service.project_dir(pid) / service.DERUSH_DIR / "derush.xml"
951+ found, name = _export_timeline(project, tl)
952+ body = derush.timeline_xml(project, found, f"{project['title']} — {name}")
953+ dest = service.project_dir(pid) / service.DERUSH_DIR / f"{name}.xml"
938954 dest.write_text(body, encoding="utf-8")
939955 return Response(body, media_type="application/xml", headers={
940- "Content-Disposition": f"attachment; filename*=UTF-8''{quote(service.safe_name(project['title']) + '-derush.xml')}"})
956+ "Content-Disposition": f"attachment; filename*=UTF-8''{quote(service.safe_name(project['title']) + f'-{name}.xml')}"})
941957
942958
943959 @router.get("/{pid}/derush/export.fcpxml")
944-def export_fcpxml(pid: str):
960+def export_fcpxml(pid: str, tl: str | None = None):
945961 _check(pid)
946962 project = service.load(pid)
947- found = runtime.ranges(project)
948- if not found:
949- raise HTTPException(400, "rien à exporter")
950- body = timeline.fcpxml(timeline.from_ranges(project, found), f"{project['title']} — dérush")
951- dest = service.project_dir(pid) / service.DERUSH_DIR / "derush.fcpxml"
963+ found, name = _export_timeline(project, tl)
964+ body = timeline.fcpxml(timeline.from_ranges(project, found), f"{project['title']} — {name}")
965+ dest = service.project_dir(pid) / service.DERUSH_DIR / f"{name}.fcpxml"
952966 dest.write_text(body, encoding="utf-8")
953967 return Response(body, media_type="application/xml", headers={
954- "Content-Disposition": f"attachment; filename*=UTF-8''{quote(service.safe_name(project['title']) + '-derush.fcpxml')}"})
968+ "Content-Disposition": f"attachment; filename*=UTF-8''{quote(service.safe_name(project['title']) + f'-{name}.fcpxml')}"})
955969
956970
957971 @router.post("/{pid}/derush/export.mp4")
958-def export_mp4(pid: str):
972+def export_mp4(pid: str, tl: str | None = None):
959973 _check(pid)
960974 with service.editing(pid) as project:
961975 try:
962- runtime.start_render(project)
976+ runtime.start_render(project, tl)
963977 except ValueError as e:
964978 raise HTTPException(400, str(e)) from e
965979 return _view(pid)
966980
967981
968982 @router.get("/{pid}/derush/export.mp4")
969-def download_mp4(pid: str):
983+def download_mp4(pid: str, tl: str | None = None):
970984 _check(pid)
971- path = service.project_dir(pid) / service.DERUSH_DIR / "derush.mp4"
985+ project = service.load(pid)
986+ tid = tl or project["derush"]["main"]
987+ path = runtime.render_path(project, tid)
972988 if not path.is_file():
973989 raise HTTPException(404, "pas encore exporté")
974- return FileResponse(path, filename="derush.mp4")
990+ return FileResponse(path, filename=f"{_export_name(project, tid)}.mp4")
975991
976992
977993 # --- Vidéo source ---------------------------------------------------------------------
@@ -926,52 +926,68 @@ def start_auto_derush(pid: str, req: AutoDerushRequest):
926 return _view(pid)926 return _view(pid)
927 927
928 928
929+def _export_name(project: dict, tid: str) -> str:
930+ """Le nom des fichiers exportés d'une timeline : « derush » pour la globale, son nom pour les autres."""
931+ if tid == project["derush"]["main"]:
932+ return "derush"
933+ return service.safe_name((runtime.timeline(project, tid) or {}).get("name") or tid)
934+
935+
936+def _export_timeline(project: dict, tl: str | None) -> tuple[list[dict], str]:
937+ """Les plages de la timeline à exporter (la globale par défaut), et son nom de fichier."""
938+ tid = tl or project["derush"]["main"]
939+ if runtime.timeline(project, tid) is None:
940+ raise HTTPException(404, "timeline inconnue")
941+ found = runtime.ranges(project, tid)
942+ if not found:
943+ raise HTTPException(400, "rien à exporter")
944+ return found, _export_name(project, tid)
945+
946+
929 @router.get("/{pid}/derush/export.xml")947 @router.get("/{pid}/derush/export.xml")
930-def export_xml(pid: str):948+def export_xml(pid: str, tl: str | None = None):
931 _check(pid)949 _check(pid)
932 project = service.load(pid)950 project = service.load(pid)
933- found = runtime.ranges(project)951+ found, name = _export_timeline(project, tl)
934- if not found:952+ body = derush.timeline_xml(project, found, f"{project['title']} — {name}")
935- raise HTTPException(400, "rien à exporter")953+ dest = service.project_dir(pid) / service.DERUSH_DIR / f"{name}.xml"
936- body = derush.timeline_xml(project, found, f"{project['title']} — dérush")
937- dest = service.project_dir(pid) / service.DERUSH_DIR / "derush.xml"
938 dest.write_text(body, encoding="utf-8")954 dest.write_text(body, encoding="utf-8")
939 return Response(body, media_type="application/xml", headers={955 return Response(body, media_type="application/xml", headers={
940- "Content-Disposition": f"attachment; filename*=UTF-8''{quote(service.safe_name(project['title']) + '-derush.xml')}"})956+ "Content-Disposition": f"attachment; filename*=UTF-8''{quote(service.safe_name(project['title']) + f'-{name}.xml')}"})
941 957
942 958
943 @router.get("/{pid}/derush/export.fcpxml")959 @router.get("/{pid}/derush/export.fcpxml")
944-def export_fcpxml(pid: str):960+def export_fcpxml(pid: str, tl: str | None = None):
945 _check(pid)961 _check(pid)
946 project = service.load(pid)962 project = service.load(pid)
947- found = runtime.ranges(project)963+ found, name = _export_timeline(project, tl)
948- if not found:964+ body = timeline.fcpxml(timeline.from_ranges(project, found), f"{project['title']} — {name}")
949- raise HTTPException(400, "rien à exporter")965+ dest = service.project_dir(pid) / service.DERUSH_DIR / f"{name}.fcpxml"
950- body = timeline.fcpxml(timeline.from_ranges(project, found), f"{project['title']} — dérush")
951- dest = service.project_dir(pid) / service.DERUSH_DIR / "derush.fcpxml"
952 dest.write_text(body, encoding="utf-8")966 dest.write_text(body, encoding="utf-8")
953 return Response(body, media_type="application/xml", headers={967 return Response(body, media_type="application/xml", headers={
954- "Content-Disposition": f"attachment; filename*=UTF-8''{quote(service.safe_name(project['title']) + '-derush.fcpxml')}"})968+ "Content-Disposition": f"attachment; filename*=UTF-8''{quote(service.safe_name(project['title']) + f'-{name}.fcpxml')}"})
955 969
956 970
957 @router.post("/{pid}/derush/export.mp4")971 @router.post("/{pid}/derush/export.mp4")
958-def export_mp4(pid: str):972+def export_mp4(pid: str, tl: str | None = None):
959 _check(pid)973 _check(pid)
960 with service.editing(pid) as project:974 with service.editing(pid) as project:
961 try:975 try:
962- runtime.start_render(project)976+ runtime.start_render(project, tl)
963 except ValueError as e:977 except ValueError as e:
964 raise HTTPException(400, str(e)) from e978 raise HTTPException(400, str(e)) from e
965 return _view(pid)979 return _view(pid)
966 980
967 981
968 @router.get("/{pid}/derush/export.mp4")982 @router.get("/{pid}/derush/export.mp4")
969-def download_mp4(pid: str):983+def download_mp4(pid: str, tl: str | None = None):
970 _check(pid)984 _check(pid)
971- path = service.project_dir(pid) / service.DERUSH_DIR / "derush.mp4"985+ project = service.load(pid)
986+ tid = tl or project["derush"]["main"]
987+ path = runtime.render_path(project, tid)
972 if not path.is_file():988 if not path.is_file():
973 raise HTTPException(404, "pas encore exporté")989 raise HTTPException(404, "pas encore exporté")
974- return FileResponse(path, filename="derush.mp4")990+ return FileResponse(path, filename=f"{_export_name(project, tid)}.mp4")
975 991
976 992
977 # --- Vidéo source ---------------------------------------------------------------------993 # --- Vidéo source ---------------------------------------------------------------------
modified backend/studio/modules/projects/runtime.py +42 -20
@@ -64,8 +64,9 @@ def sync(project: dict) -> bool:
6464 add_rushes(project, paths)
6565 else:
6666 set_source(project, "import", paths[0])
67- render_job = jobs.get(project["jobs"].get("render") or "")
6867 source = project.get("source")
68+ # le rendu de la globale lancé pour la source : le dernier export peut en être un autre, d'une autre timeline
69+ render_job = jobs.get((source or {}).get("render") or project["jobs"].get("render") or "")
6970 if render_job and render_job.status == "done" and source and source.get("pending"):
7071 dest = Path(render_job.payload["dest"])
7172 info = media.probe(dest)
@@ -351,16 +352,17 @@ def flatten(project: dict, tid: str, seen: frozenset = frozenset()) -> list[dict
351352 return out
352353
353354
354-def clips(project: dict) -> list[dict]:
355- """La timeline globale du dérush, dépliée : des bouts de rushes {id, rush, in, out}, dans l'ordre de lecture."""
356- return flatten(project, project["derush"]["main"])
355+def clips(project: dict, tid: str | None = None) -> list[dict]:
356+ """Une timeline du dérush (la globale par défaut), dépliée : des bouts de rushes {id, rush, in, out},
357+ dans l'ordre de lecture."""
358+ return flatten(project, tid or project["derush"]["main"])
357359
358360
359-def ranges(project: dict) -> list[dict]:
361+def ranges(project: dict, tid: str | None = None) -> list[dict]:
360362 """Les plages lues, dans l'ordre, fusionnées quand deux bouts se suivent dans un même rush."""
361363 rushes = {r["id"]: r for r in project["rushes"]}
362364 out: list[dict] = []
363- for c in clips(project):
365+ for c in clips(project, tid):
364366 if c["out"] - c["in"] <= 0.01:
365367 continue
366368 last = out[-1] if out else None
@@ -381,28 +383,45 @@ def render_signature(found: list[dict]) -> str:
381383 return hashlib.sha1(json.dumps([[r["path"], r["start"], r["end"]] for r in found]).encode()).hexdigest()[:12]
382384
383385
384-def start_render(project: dict) -> jobs.Job:
385- found = ranges(project)
386+def render_timeline(job: jobs.Job, project: dict) -> str:
387+ """La timeline rendue par un job d'export (les anciens n'ont rendu que la globale)."""
388+ return job.payload.get("timeline") or project["derush"]["main"]
389+
390+
391+def render_path(project: dict, tid: str) -> Path:
392+ """Le .mp4 d'une timeline : derush.mp4 pour la globale, la source des étapes suivantes ; un fichier à part
393+ pour les autres, pour ne pas l'écraser."""
394+ name = "derush.mp4" if tid == project["derush"]["main"] else f"derush-{tid}.mp4"
395+ return project_dir(project["id"]) / service.DERUSH_DIR / name
396+
397+
398+def start_render(project: dict, tid: str | None = None) -> jobs.Job:
399+ tid = tid or project["derush"]["main"]
400+ if timeline(project, tid) is None:
401+ raise ValueError("timeline inconnue")
402+ found = ranges(project, tid)
386403 if not found:
387404 raise ValueError("rien à exporter : tout est coupé")
405+ # un nouvel export de la même timeline remplace celui en cours ; celui d'une autre timeline va jusqu'au bout
388406 current = jobs.get(project["jobs"].get("render") or "")
389- if current and not current.finished:
407+ if current and not current.finished and render_timeline(current, project) == tid:
390408 jobs.cancel(current)
391409 first = next(r for r in project["rushes"] if r["id"] == found[0]["rush"])
392410 fmt = {"width": first.get("width") or 1920, "height": first.get("height") or 1080, "fps": first.get("fps") or 25}
393- dest = project_dir(project["id"]) / service.DERUSH_DIR / "derush.mp4"
394411 job = jobs.create(service.MODULE, "project_render",
395- {"project": project["id"], "ranges": found, "format": fmt, "dest": str(dest),
396- "signature": render_signature(found)}, {})
412+ {"project": project["id"], "timeline": tid, "ranges": found, "format": fmt,
413+ "dest": str(render_path(project, tid)), "signature": render_signature(found)}, {})
397414 project["jobs"]["render"] = job.id
398415 return job
399416
400417
401-def render_current(project: dict) -> bool:
402- """Le .mp4 exporté correspond-il encore au dérush ?"""
418+def render_current(project: dict, tid: str | None = None) -> bool:
419+ """Le dernier .mp4 exporté correspond-il encore à sa timeline (ou à `tid`, si donnée) ?"""
403420 job = jobs.get(project["jobs"].get("render") or "")
404- return bool(job and job.status == "done" and Path(job.payload["dest"]).is_file()
405- and job.payload["signature"] == render_signature(ranges(project)))
421+ if not (job and job.status == "done" and Path(job.payload["dest"]).is_file()):
422+ return False
423+ rendered = render_timeline(job, project)
424+ return (tid or rendered) == rendered and job.payload["signature"] == render_signature(ranges(project, rendered))
406425
407426
408427 # --- Vidéo source --------------------------------------------------------------------
@@ -434,12 +453,13 @@ def set_source(project: dict, kind: str, path: Path | None) -> None:
434453 service.start_step(project, "transcript")
435454 has_derush = bool(project["rushes"]) and analysis(project)["ready"]
436455 if kind == "derush":
437- pending = not render_current(project)
438- if pending:
439- start_render(project)
440- dest = project_dir(project["id"]) / service.DERUSH_DIR / "derush.mp4"
456+ main = project["derush"]["main"]
457+ pending = not render_current(project, main)
458+ job = start_render(project, main) if pending else None
459+ dest = render_path(project, main)
441460 duration = kept_duration(project)
442461 project["source"] = {"kind": "derush", "path": str(dest), "name": dest.name, "pending": pending,
462+ "render": job.id if job else None,
443463 "duration": duration, "width": None, "height": None, "drift": False,
444464 "derived": True, "transcript": derived_transcript(project, dest)}
445465 if not pending:
@@ -506,6 +526,7 @@ def view(project: dict) -> dict:
506526 job = jobs.get(rush.get("transcript") or "")
507527 rush_jobs[rush["id"]] = ({"status": job.status, "progress": job.progress, "error": job.error}
508528 if job else {"status": "none", "progress": 0, "error": None})
529+ render_job = jobs.get(project["jobs"].get("render") or "")
509530 return {
510531 **project,
511532 "runtime": {
@@ -519,6 +540,7 @@ def view(project: dict) -> dict:
519540 "plans": script.counts(pid),
520541 "roast_score": roast.last_score(pid, project["jobs"].get("roast")),
521542 "render_current": render_current(project) if project["rushes"] else False,
543+ "render_timeline": render_timeline(render_job, project) if render_job else None,
522544 "assets": assets.view(project),
523545 "thumbs": thumbs.view(),
524546 "shorts": shorts_view(project),
@@ -64,8 +64,9 @@ def sync(project: dict) -> bool:
64 add_rushes(project, paths)64 add_rushes(project, paths)
65 else:65 else:
66 set_source(project, "import", paths[0])66 set_source(project, "import", paths[0])
67- render_job = jobs.get(project["jobs"].get("render") or "")
68 source = project.get("source")67 source = project.get("source")
68+ # le rendu de la globale lancé pour la source : le dernier export peut en être un autre, d'une autre timeline
69+ render_job = jobs.get((source or {}).get("render") or project["jobs"].get("render") or "")
69 if render_job and render_job.status == "done" and source and source.get("pending"):70 if render_job and render_job.status == "done" and source and source.get("pending"):
70 dest = Path(render_job.payload["dest"])71 dest = Path(render_job.payload["dest"])
71 info = media.probe(dest)72 info = media.probe(dest)
@@ -351,16 +352,17 @@ def flatten(project: dict, tid: str, seen: frozenset = frozenset()) -> list[dict
351 return out352 return out
352 353
353 354
354-def clips(project: dict) -> list[dict]:355+def clips(project: dict, tid: str | None = None) -> list[dict]:
355- """La timeline globale du dérush, dépliée : des bouts de rushes {id, rush, in, out}, dans l'ordre de lecture."""356+ """Une timeline du dérush (la globale par défaut), dépliée : des bouts de rushes {id, rush, in, out},
356- return flatten(project, project["derush"]["main"])357+ dans l'ordre de lecture."""
358+ return flatten(project, tid or project["derush"]["main"])
357 359
358 360
359-def ranges(project: dict) -> list[dict]:361+def ranges(project: dict, tid: str | None = None) -> list[dict]:
360 """Les plages lues, dans l'ordre, fusionnées quand deux bouts se suivent dans un même rush."""362 """Les plages lues, dans l'ordre, fusionnées quand deux bouts se suivent dans un même rush."""
361 rushes = {r["id"]: r for r in project["rushes"]}363 rushes = {r["id"]: r for r in project["rushes"]}
362 out: list[dict] = []364 out: list[dict] = []
363- for c in clips(project):365+ for c in clips(project, tid):
364 if c["out"] - c["in"] <= 0.01:366 if c["out"] - c["in"] <= 0.01:
365 continue367 continue
366 last = out[-1] if out else None368 last = out[-1] if out else None
@@ -381,28 +383,45 @@ def render_signature(found: list[dict]) -> str:
381 return hashlib.sha1(json.dumps([[r["path"], r["start"], r["end"]] for r in found]).encode()).hexdigest()[:12]383 return hashlib.sha1(json.dumps([[r["path"], r["start"], r["end"]] for r in found]).encode()).hexdigest()[:12]
382 384
383 385
384-def start_render(project: dict) -> jobs.Job:386+def render_timeline(job: jobs.Job, project: dict) -> str:
385- found = ranges(project)387+ """La timeline rendue par un job d'export (les anciens n'ont rendu que la globale)."""
388+ return job.payload.get("timeline") or project["derush"]["main"]
389+
390+
391+def render_path(project: dict, tid: str) -> Path:
392+ """Le .mp4 d'une timeline : derush.mp4 pour la globale, la source des étapes suivantes ; un fichier à part
393+ pour les autres, pour ne pas l'écraser."""
394+ name = "derush.mp4" if tid == project["derush"]["main"] else f"derush-{tid}.mp4"
395+ return project_dir(project["id"]) / service.DERUSH_DIR / name
396+
397+
398+def start_render(project: dict, tid: str | None = None) -> jobs.Job:
399+ tid = tid or project["derush"]["main"]
400+ if timeline(project, tid) is None:
401+ raise ValueError("timeline inconnue")
402+ found = ranges(project, tid)
386 if not found:403 if not found:
387 raise ValueError("rien à exporter : tout est coupé")404 raise ValueError("rien à exporter : tout est coupé")
405+ # un nouvel export de la même timeline remplace celui en cours ; celui d'une autre timeline va jusqu'au bout
388 current = jobs.get(project["jobs"].get("render") or "")406 current = jobs.get(project["jobs"].get("render") or "")
389- if current and not current.finished:407+ if current and not current.finished and render_timeline(current, project) == tid:
390 jobs.cancel(current)408 jobs.cancel(current)
391 first = next(r for r in project["rushes"] if r["id"] == found[0]["rush"])409 first = next(r for r in project["rushes"] if r["id"] == found[0]["rush"])
392 fmt = {"width": first.get("width") or 1920, "height": first.get("height") or 1080, "fps": first.get("fps") or 25}410 fmt = {"width": first.get("width") or 1920, "height": first.get("height") or 1080, "fps": first.get("fps") or 25}
393- dest = project_dir(project["id"]) / service.DERUSH_DIR / "derush.mp4"
394 job = jobs.create(service.MODULE, "project_render",411 job = jobs.create(service.MODULE, "project_render",
395- {"project": project["id"], "ranges": found, "format": fmt, "dest": str(dest),412+ {"project": project["id"], "timeline": tid, "ranges": found, "format": fmt,
396- "signature": render_signature(found)}, {})413+ "dest": str(render_path(project, tid)), "signature": render_signature(found)}, {})
397 project["jobs"]["render"] = job.id414 project["jobs"]["render"] = job.id
398 return job415 return job
399 416
400 417
401-def render_current(project: dict) -> bool:418+def render_current(project: dict, tid: str | None = None) -> bool:
402- """Le .mp4 exporté correspond-il encore au dérush ?"""419+ """Le dernier .mp4 exporté correspond-il encore à sa timeline (ou à `tid`, si donnée) ?"""
403 job = jobs.get(project["jobs"].get("render") or "")420 job = jobs.get(project["jobs"].get("render") or "")
404- return bool(job and job.status == "done" and Path(job.payload["dest"]).is_file()421+ if not (job and job.status == "done" and Path(job.payload["dest"]).is_file()):
405- and job.payload["signature"] == render_signature(ranges(project)))422+ return False
423+ rendered = render_timeline(job, project)
424+ return (tid or rendered) == rendered and job.payload["signature"] == render_signature(ranges(project, rendered))
406 425
407 426
408 # --- Vidéo source --------------------------------------------------------------------427 # --- Vidéo source --------------------------------------------------------------------
@@ -434,12 +453,13 @@ def set_source(project: dict, kind: str, path: Path | None) -> None:
434 service.start_step(project, "transcript")453 service.start_step(project, "transcript")
435 has_derush = bool(project["rushes"]) and analysis(project)["ready"]454 has_derush = bool(project["rushes"]) and analysis(project)["ready"]
436 if kind == "derush":455 if kind == "derush":
437- pending = not render_current(project)456+ main = project["derush"]["main"]
438- if pending:457+ pending = not render_current(project, main)
439- start_render(project)458+ job = start_render(project, main) if pending else None
440- dest = project_dir(project["id"]) / service.DERUSH_DIR / "derush.mp4"459+ dest = render_path(project, main)
441 duration = kept_duration(project)460 duration = kept_duration(project)
442 project["source"] = {"kind": "derush", "path": str(dest), "name": dest.name, "pending": pending,461 project["source"] = {"kind": "derush", "path": str(dest), "name": dest.name, "pending": pending,
462+ "render": job.id if job else None,
443 "duration": duration, "width": None, "height": None, "drift": False,463 "duration": duration, "width": None, "height": None, "drift": False,
444 "derived": True, "transcript": derived_transcript(project, dest)}464 "derived": True, "transcript": derived_transcript(project, dest)}
445 if not pending:465 if not pending:
@@ -506,6 +526,7 @@ def view(project: dict) -> dict:
506 job = jobs.get(rush.get("transcript") or "")526 job = jobs.get(rush.get("transcript") or "")
507 rush_jobs[rush["id"]] = ({"status": job.status, "progress": job.progress, "error": job.error}527 rush_jobs[rush["id"]] = ({"status": job.status, "progress": job.progress, "error": job.error}
508 if job else {"status": "none", "progress": 0, "error": None})528 if job else {"status": "none", "progress": 0, "error": None})
529+ render_job = jobs.get(project["jobs"].get("render") or "")
509 return {530 return {
510 **project,531 **project,
511 "runtime": {532 "runtime": {
@@ -519,6 +540,7 @@ def view(project: dict) -> dict:
519 "plans": script.counts(pid),540 "plans": script.counts(pid),
520 "roast_score": roast.last_score(pid, project["jobs"].get("roast")),541 "roast_score": roast.last_score(pid, project["jobs"].get("roast")),
521 "render_current": render_current(project) if project["rushes"] else False,542 "render_current": render_current(project) if project["rushes"] else False,
543+ "render_timeline": render_timeline(render_job, project) if render_job else None,
522 "assets": assets.view(project),544 "assets": assets.view(project),
523 "thumbs": thumbs.view(),545 "thumbs": thumbs.view(),
524 "shorts": shorts_view(project),546 "shorts": shorts_view(project),
modified web/src/i18n/locales/en/derush.ts +1 -1
@@ -22,7 +22,7 @@ export default {
2222 transcribing: "Transcribing · {p}%",
2323 intro: "The clips play back to back in the timeline. Generate the transcript to show the text and spot gaps and bad takes.",
2424 generate: "Generate transcript",
25- rendering: "Rendering · {p}%",
25+ rendering: "Rendering {name} · {p}%",
2626 renderFailed: "Render failed",
2727 cutsChanged: " · cuts changed since",
2828 export: {
@@ -22,7 +22,7 @@ export default {
22 transcribing: "Transcribing · {p}%",22 transcribing: "Transcribing · {p}%",
23 intro: "The clips play back to back in the timeline. Generate the transcript to show the text and spot gaps and bad takes.",23 intro: "The clips play back to back in the timeline. Generate the transcript to show the text and spot gaps and bad takes.",
24 generate: "Generate transcript",24 generate: "Generate transcript",
25- rendering: "Rendering · {p}%",25+ rendering: "Rendering {name} · {p}%",
26 renderFailed: "Render failed",26 renderFailed: "Render failed",
27 cutsChanged: " · cuts changed since",27 cutsChanged: " · cuts changed since",
28 export: {28 export: {
modified web/src/i18n/locales/fr/derush.ts +1 -1
@@ -22,7 +22,7 @@ export default {
2222 transcribing: "Transcription en cours · {p} %",
2323 intro: "Les rushes sont lus bout à bout dans la timeline. Génère le transcript pour afficher le texte, repérer les vides et les prises ratées.",
2424 generate: "Générer le transcript",
25- rendering: "Rendu · {p} %",
25+ rendering: "Rendu de {name} · {p} %",
2626 renderFailed: "Rendu échoué",
2727 cutsChanged: " · coupes modifiées depuis",
2828 export: {
@@ -22,7 +22,7 @@ export default {
22 transcribing: "Transcription en cours · {p} %",22 transcribing: "Transcription en cours · {p} %",
23 intro: "Les rushes sont lus bout à bout dans la timeline. Génère le transcript pour afficher le texte, repérer les vides et les prises ratées.",23 intro: "Les rushes sont lus bout à bout dans la timeline. Génère le transcript pour afficher le texte, repérer les vides et les prises ratées.",
24 generate: "Générer le transcript",24 generate: "Générer le transcript",
25- rendering: "Rendu · {p} %",25+ rendering: "Rendu de {name} · {p} %",
26 renderFailed: "Rendu échoué",26 renderFailed: "Rendu échoué",
27 cutsChanged: " · coupes modifiées depuis",27 cutsChanged: " · coupes modifiées depuis",
28 export: {28 export: {
modified web/src/project/steps/DerushStep.vue +10 -5
@@ -274,6 +274,11 @@ onUnmounted(() => document.removeEventListener("pointerdown", closeMenu));
274274
275275 const render = computed(() => project.value.runtime.jobs.render ?? null);
276276 const renderPct = computed(() => Math.round((render.value?.progress ?? 0) * 100));
277+/** La timeline du dernier export .mp4 : pas forcément celle qui est ouverte. */
278+const renderTl = computed(() => project.value.runtime.render_timeline ?? derush.mainId.value);
279+const renderName = computed(() => (renderTl.value === derush.mainId.value ? "derush" : derush.nameOf(renderTl.value)));
280+/** Les exports portent sur la timeline ouverte. */
281+const tlQuery = computed(() => `?tl=${encodeURIComponent(derush.current.value)}`);
277282
278283 function exportXml() {
279284 exportOpen.value = false;
@@ -282,7 +287,7 @@ function exportXml() {
282287
283288 async function exportMp4() {
284289 exportOpen.value = false;
285- if (await act("/derush/export.mp4")) toast(t("derush.export.mp4Started", { d: mmss(kept.value) }));
290+ if (await act(`/derush/export.mp4${tlQuery.value}`)) toast(t("derush.export.mp4Started", { d: mmss(kept.value) }));
286291 }
287292
288293 async function validate() {
@@ -376,22 +381,22 @@ async function validate() {
376381 <div class="footer">
377382 <span class="keys" :title="t('derush.shortcuts')">{{ t("derush.shortcuts") }}</span>
378383 <span v-if="running('render')" class="render">
379- {{ t("derush.rendering", { p: renderPct }) }}
384+ {{ t("derush.rendering", { name: renderName, p: renderPct }) }}
380385 <span class="progress bar-sm"><div :style="{ width: `${renderPct}%` }" /></span>
381386 </span>
382387 <span v-else-if="failed('render')" class="render error" :title="failed('render') ?? ''">{{ t("derush.renderFailed") }}</span>
383388 <span v-else-if="render?.status === 'done'" class="render">
384- <a :href="`${base}/derush/export.mp4`" download>derush.mp4</a>
389+ <a :href="`${base}/derush/export.mp4?tl=${encodeURIComponent(renderTl)}`" download>{{ renderName }}.mp4</a>
385390 <template v-if="!project.runtime.render_current">{{ t("derush.cutsChanged") }}</template>
386391 </span>
387392 <div ref="menu" class="menu-wrap">
388393 <button class="btn md export" @click="exportOpen = !exportOpen">{{ t("derush.export.button") }}</button>
389394 <div v-if="exportOpen" class="menu">
390- <a class="opt" :href="`${base}/derush/export.fcpxml`" download @click="exportXml">
395+ <a class="opt" :href="`${base}/derush/export.fcpxml${tlQuery}`" download @click="exportXml">
391396 <span class="opt-t">{{ t("derush.export.resolve") }}</span>
392397 <span class="opt-d">{{ t("derush.export.resolveDesc") }}</span>
393398 </a>
394- <a class="opt" :href="`${base}/derush/export.xml`" download @click="exportXml">
399+ <a class="opt" :href="`${base}/derush/export.xml${tlQuery}`" download @click="exportXml">
395400 <span class="opt-t">{{ t("derush.export.premiere") }}</span>
396401 <span class="opt-d">{{ t("derush.export.premiereDesc") }}</span>
397402 </a>
@@ -274,6 +274,11 @@ onUnmounted(() => document.removeEventListener("pointerdown", closeMenu));
274 274
275 const render = computed(() => project.value.runtime.jobs.render ?? null);275 const render = computed(() => project.value.runtime.jobs.render ?? null);
276 const renderPct = computed(() => Math.round((render.value?.progress ?? 0) * 100));276 const renderPct = computed(() => Math.round((render.value?.progress ?? 0) * 100));
277+/** La timeline du dernier export .mp4 : pas forcément celle qui est ouverte. */
278+const renderTl = computed(() => project.value.runtime.render_timeline ?? derush.mainId.value);
279+const renderName = computed(() => (renderTl.value === derush.mainId.value ? "derush" : derush.nameOf(renderTl.value)));
280+/** Les exports portent sur la timeline ouverte. */
281+const tlQuery = computed(() => `?tl=${encodeURIComponent(derush.current.value)}`);
277 282
278 function exportXml() {283 function exportXml() {
279 exportOpen.value = false;284 exportOpen.value = false;
@@ -282,7 +287,7 @@ function exportXml() {
282 287
283 async function exportMp4() {288 async function exportMp4() {
284 exportOpen.value = false;289 exportOpen.value = false;
285- if (await act("/derush/export.mp4")) toast(t("derush.export.mp4Started", { d: mmss(kept.value) }));290+ if (await act(`/derush/export.mp4${tlQuery.value}`)) toast(t("derush.export.mp4Started", { d: mmss(kept.value) }));
286 }291 }
287 292
288 async function validate() {293 async function validate() {
@@ -376,22 +381,22 @@ async function validate() {
376 <div class="footer">381 <div class="footer">
377 <span class="keys" :title="t('derush.shortcuts')">{{ t("derush.shortcuts") }}</span>382 <span class="keys" :title="t('derush.shortcuts')">{{ t("derush.shortcuts") }}</span>
378 <span v-if="running('render')" class="render">383 <span v-if="running('render')" class="render">
379- {{ t("derush.rendering", { p: renderPct }) }}384+ {{ t("derush.rendering", { name: renderName, p: renderPct }) }}
380 <span class="progress bar-sm"><div :style="{ width: `${renderPct}%` }" /></span>385 <span class="progress bar-sm"><div :style="{ width: `${renderPct}%` }" /></span>
381 </span>386 </span>
382 <span v-else-if="failed('render')" class="render error" :title="failed('render') ?? ''">{{ t("derush.renderFailed") }}</span>387 <span v-else-if="failed('render')" class="render error" :title="failed('render') ?? ''">{{ t("derush.renderFailed") }}</span>
383 <span v-else-if="render?.status === 'done'" class="render">388 <span v-else-if="render?.status === 'done'" class="render">
384- <a :href="`${base}/derush/export.mp4`" download>derush.mp4</a>389+ <a :href="`${base}/derush/export.mp4?tl=${encodeURIComponent(renderTl)}`" download>{{ renderName }}.mp4</a>
385 <template v-if="!project.runtime.render_current">{{ t("derush.cutsChanged") }}</template>390 <template v-if="!project.runtime.render_current">{{ t("derush.cutsChanged") }}</template>
386 </span>391 </span>
387 <div ref="menu" class="menu-wrap">392 <div ref="menu" class="menu-wrap">
388 <button class="btn md export" @click="exportOpen = !exportOpen">{{ t("derush.export.button") }}</button>393 <button class="btn md export" @click="exportOpen = !exportOpen">{{ t("derush.export.button") }}</button>
389 <div v-if="exportOpen" class="menu">394 <div v-if="exportOpen" class="menu">
390- <a class="opt" :href="`${base}/derush/export.fcpxml`" download @click="exportXml">395+ <a class="opt" :href="`${base}/derush/export.fcpxml${tlQuery}`" download @click="exportXml">
391 <span class="opt-t">{{ t("derush.export.resolve") }}</span>396 <span class="opt-t">{{ t("derush.export.resolve") }}</span>
392 <span class="opt-d">{{ t("derush.export.resolveDesc") }}</span>397 <span class="opt-d">{{ t("derush.export.resolveDesc") }}</span>
393 </a>398 </a>
394- <a class="opt" :href="`${base}/derush/export.xml`" download @click="exportXml">399+ <a class="opt" :href="`${base}/derush/export.xml${tlQuery}`" download @click="exportXml">
395 <span class="opt-t">{{ t("derush.export.premiere") }}</span>400 <span class="opt-t">{{ t("derush.export.premiere") }}</span>
396 <span class="opt-d">{{ t("derush.export.premiereDesc") }}</span>401 <span class="opt-d">{{ t("derush.export.premiereDesc") }}</span>
397 </a>402 </a>
modified web/src/project/types.ts +2 -0
@@ -83,7 +83,9 @@ export interface Runtime {
8383 plans: { total: number; done: number };
8484 /** la note du dernier roast terminé */
8585 roast_score: number | null;
86+ /** le dernier export .mp4 du dérush correspond-il encore à sa timeline (`render_timeline`) ? */
8687 render_current: boolean;
88+ render_timeline: string | null;
8789 /** capturing : les sources que le job de captures doit encore traiter (la première est en cours) */
8890 assets: { downloads: AssetDownload[]; capturing: string[] };
8991 /** les modèles d'image de fal.ai ; `ready` : la clé FAL_KEY est définie */
@@ -83,7 +83,9 @@ export interface Runtime {
83 plans: { total: number; done: number };83 plans: { total: number; done: number };
84 /** la note du dernier roast terminé */84 /** la note du dernier roast terminé */
85 roast_score: number | null;85 roast_score: number | null;
86+ /** le dernier export .mp4 du dérush correspond-il encore à sa timeline (`render_timeline`) ? */
86 render_current: boolean;87 render_current: boolean;
88+ render_timeline: string | null;
87 /** capturing : les sources que le job de captures doit encore traiter (la première est en cours) */89 /** capturing : les sources que le job de captures doit encore traiter (la première est en cours) */
88 assets: { downloads: AssetDownload[]; capturing: string[] };90 assets: { downloads: AssetDownload[]; capturing: string[] };
89 /** les modèles d'image de fal.ai ; `ready` : la clé FAL_KEY est définie */91 /** les modèles d'image de fal.ai ; `ready` : la clé FAL_KEY est définie */