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>
48f232c parent: 22449ed modified
backend/studio/modules/projects/api.py +35 -19 | @@ -926,52 +926,68 @@ def start_auto_derush(pid: str, req: AutoDerushRequest): | ||
| 926 | 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 | 947 | @router.get("/{pid}/derush/export.xml") |
| 930 | -def export_xml(pid: str): | |
| 948 | +def export_xml(pid: str, tl: str | None = None): | |
| 931 | 949 | _check(pid) |
| 932 | 950 | 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" | |
| 938 | 954 | dest.write_text(body, encoding="utf-8") |
| 939 | 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 | 959 | @router.get("/{pid}/derush/export.fcpxml") |
| 944 | -def export_fcpxml(pid: str): | |
| 960 | +def export_fcpxml(pid: str, tl: str | None = None): | |
| 945 | 961 | _check(pid) |
| 946 | 962 | 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" | |
| 952 | 966 | dest.write_text(body, encoding="utf-8") |
| 953 | 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 | 971 | @router.post("/{pid}/derush/export.mp4") |
| 958 | -def export_mp4(pid: str): | |
| 972 | +def export_mp4(pid: str, tl: str | None = None): | |
| 959 | 973 | _check(pid) |
| 960 | 974 | with service.editing(pid) as project: |
| 961 | 975 | try: |
| 962 | - runtime.start_render(project) | |
| 976 | + runtime.start_render(project, tl) | |
| 963 | 977 | except ValueError as e: |
| 964 | 978 | raise HTTPException(400, str(e)) from e |
| 965 | 979 | return _view(pid) |
| 966 | 980 | |
| 967 | 981 | |
| 968 | 982 | @router.get("/{pid}/derush/export.mp4") |
| 969 | -def download_mp4(pid: str): | |
| 983 | +def download_mp4(pid: str, tl: str | None = None): | |
| 970 | 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 | 988 | if not path.is_file(): |
| 973 | 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 | 993 | # --- 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 e | 978 | 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: | ||
| 64 | 64 | add_rushes(project, paths) |
| 65 | 65 | else: |
| 66 | 66 | set_source(project, "import", paths[0]) |
| 67 | - render_job = jobs.get(project["jobs"].get("render") or "") | |
| 68 | 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 | 70 | if render_job and render_job.status == "done" and source and source.get("pending"): |
| 70 | 71 | dest = Path(render_job.payload["dest"]) |
| 71 | 72 | info = media.probe(dest) |
| @@ -351,16 +352,17 @@ def flatten(project: dict, tid: str, seen: frozenset = frozenset()) -> list[dict | ||
| 351 | 352 | return out |
| 352 | 353 | |
| 353 | 354 | |
| 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"]) | |
| 357 | 359 | |
| 358 | 360 | |
| 359 | -def ranges(project: dict) -> list[dict]: | |
| 361 | +def ranges(project: dict, tid: str | None = None) -> list[dict]: | |
| 360 | 362 | """Les plages lues, dans l'ordre, fusionnées quand deux bouts se suivent dans un même rush.""" |
| 361 | 363 | rushes = {r["id"]: r for r in project["rushes"]} |
| 362 | 364 | out: list[dict] = [] |
| 363 | - for c in clips(project): | |
| 365 | + for c in clips(project, tid): | |
| 364 | 366 | if c["out"] - c["in"] <= 0.01: |
| 365 | 367 | continue |
| 366 | 368 | last = out[-1] if out else None |
| @@ -381,28 +383,45 @@ def render_signature(found: list[dict]) -> str: | ||
| 381 | 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: | |
| 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) | |
| 386 | 403 | if not found: |
| 387 | 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 | 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 | 408 | jobs.cancel(current) |
| 391 | 409 | first = next(r for r in project["rushes"] if r["id"] == found[0]["rush"]) |
| 392 | 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 | 411 | 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)}, {}) | |
| 397 | 414 | project["jobs"]["render"] = job.id |
| 398 | 415 | return job |
| 399 | 416 | |
| 400 | 417 | |
| 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) ?""" | |
| 403 | 420 | 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)) | |
| 406 | 425 | |
| 407 | 426 | |
| 408 | 427 | # --- Vidéo source -------------------------------------------------------------------- |
| @@ -434,12 +453,13 @@ def set_source(project: dict, kind: str, path: Path | None) -> None: | ||
| 434 | 453 | service.start_step(project, "transcript") |
| 435 | 454 | has_derush = bool(project["rushes"]) and analysis(project)["ready"] |
| 436 | 455 | 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) | |
| 441 | 460 | duration = kept_duration(project) |
| 442 | 461 | project["source"] = {"kind": "derush", "path": str(dest), "name": dest.name, "pending": pending, |
| 462 | + "render": job.id if job else None, | |
| 443 | 463 | "duration": duration, "width": None, "height": None, "drift": False, |
| 444 | 464 | "derived": True, "transcript": derived_transcript(project, dest)} |
| 445 | 465 | if not pending: |
| @@ -506,6 +526,7 @@ def view(project: dict) -> dict: | ||
| 506 | 526 | job = jobs.get(rush.get("transcript") or "") |
| 507 | 527 | rush_jobs[rush["id"]] = ({"status": job.status, "progress": job.progress, "error": job.error} |
| 508 | 528 | if job else {"status": "none", "progress": 0, "error": None}) |
| 529 | + render_job = jobs.get(project["jobs"].get("render") or "") | |
| 509 | 530 | return { |
| 510 | 531 | **project, |
| 511 | 532 | "runtime": { |
| @@ -519,6 +540,7 @@ def view(project: dict) -> dict: | ||
| 519 | 540 | "plans": script.counts(pid), |
| 520 | 541 | "roast_score": roast.last_score(pid, project["jobs"].get("roast")), |
| 521 | 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 | 544 | "assets": assets.view(project), |
| 523 | 545 | "thumbs": thumbs.view(), |
| 524 | 546 | "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 out | 352 | 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 | continue | 367 | continue |
| 366 | last = out[-1] if out else None | 368 | 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.id | 414 | project["jobs"]["render"] = job.id |
| 398 | return job | 415 | 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 { | ||
| 22 | 22 | transcribing: "Transcribing · {p}%", |
| 23 | 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 | 24 | generate: "Generate transcript", |
| 25 | - rendering: "Rendering · {p}%", | |
| 25 | + rendering: "Rendering {name} · {p}%", | |
| 26 | 26 | renderFailed: "Render failed", |
| 27 | 27 | cutsChanged: " · cuts changed since", |
| 28 | 28 | 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 { | ||
| 22 | 22 | transcribing: "Transcription en cours · {p} %", |
| 23 | 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 | 24 | generate: "Générer le transcript", |
| 25 | - rendering: "Rendu · {p} %", | |
| 25 | + rendering: "Rendu de {name} · {p} %", | |
| 26 | 26 | renderFailed: "Rendu échoué", |
| 27 | 27 | cutsChanged: " · coupes modifiées depuis", |
| 28 | 28 | 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)); | ||
| 274 | 274 | |
| 275 | 275 | const render = computed(() => project.value.runtime.jobs.render ?? null); |
| 276 | 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 | 283 | function exportXml() { |
| 279 | 284 | exportOpen.value = false; |
| @@ -282,7 +287,7 @@ function exportXml() { | ||
| 282 | 287 | |
| 283 | 288 | async function exportMp4() { |
| 284 | 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 | 293 | async function validate() { |
| @@ -376,22 +381,22 @@ async function validate() { | ||
| 376 | 381 | <div class="footer"> |
| 377 | 382 | <span class="keys" :title="t('derush.shortcuts')">{{ t("derush.shortcuts") }}</span> |
| 378 | 383 | <span v-if="running('render')" class="render"> |
| 379 | - {{ t("derush.rendering", { p: renderPct }) }} | |
| 384 | + {{ t("derush.rendering", { name: renderName, p: renderPct }) }} | |
| 380 | 385 | <span class="progress bar-sm"><div :style="{ width: `${renderPct}%` }" /></span> |
| 381 | 386 | </span> |
| 382 | 387 | <span v-else-if="failed('render')" class="render error" :title="failed('render') ?? ''">{{ t("derush.renderFailed") }}</span> |
| 383 | 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 | 390 | <template v-if="!project.runtime.render_current">{{ t("derush.cutsChanged") }}</template> |
| 386 | 391 | </span> |
| 387 | 392 | <div ref="menu" class="menu-wrap"> |
| 388 | 393 | <button class="btn md export" @click="exportOpen = !exportOpen">{{ t("derush.export.button") }}</button> |
| 389 | 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 | 396 | <span class="opt-t">{{ t("derush.export.resolve") }}</span> |
| 392 | 397 | <span class="opt-d">{{ t("derush.export.resolveDesc") }}</span> |
| 393 | 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 | 400 | <span class="opt-t">{{ t("derush.export.premiere") }}</span> |
| 396 | 401 | <span class="opt-d">{{ t("derush.export.premiereDesc") }}</span> |
| 397 | 402 | </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 { | ||
| 83 | 83 | plans: { total: number; done: number }; |
| 84 | 84 | /** la note du dernier roast terminé */ |
| 85 | 85 | roast_score: number | null; |
| 86 | + /** le dernier export .mp4 du dérush correspond-il encore à sa timeline (`render_timeline`) ? */ | |
| 86 | 87 | render_current: boolean; |
| 88 | + render_timeline: string | null; | |
| 87 | 89 | /** capturing : les sources que le job de captures doit encore traiter (la première est en cours) */ |
| 88 | 90 | assets: { downloads: AssetDownload[]; capturing: string[] }; |
| 89 | 91 | /** 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 */ |