fix(montage): copy media used by motion compositions next to them
The HyperFrames render only reads montage/motion/, and Daryl cannot copy files, so a video or image placed in a composition was never available and the render failed with VIDEO_SOURCE_UNRENDERABLE. Compositions now declare the media's source with data-from; the studio copies it into montage/motion/media/ at the end of Daryl's turn and before each render, and reports any media it cannot find.
2932ab7 parent: 56b25b3 modified
backend/studio/modules/projects/montage.py +45 -0 | @@ -42,6 +42,7 @@ pour un média de la bibliothèque commune (copié dans le projet à l'enregistr | ||
| 42 | 42 | """ |
| 43 | 43 | import hashlib |
| 44 | 44 | import json |
| 45 | +import html | |
| 45 | 46 | import os |
| 46 | 47 | import re |
| 47 | 48 | import shutil |
| @@ -63,6 +64,8 @@ INSERTIONS_FILE = "insertions.json" | ||
| 63 | 64 | MOTION_DIR = "motion" |
| 64 | 65 | RENDERS_DIR = "renders" |
| 65 | 66 | HISTORY_DIR = ".history" |
| 67 | +# les médias posés dans les compositions, copiés à côté d'elles (le rendu ne lit que montage/motion/) | |
| 68 | +MEDIA_DIR = "media" | |
| 66 | 69 | CACHE_DIR = ".cache" |
| 67 | 70 | HISTORY_MAX = 60 |
| 68 | 71 | # les copies de travail de Daryl, une par demande en cours (daryl/<demande>/) ; dans chacune, WORK_BASE : la timeline |
| @@ -782,6 +785,48 @@ def after_change(pid: str) -> None: | ||
| 782 | 785 | def motion_signature(pid: str, item: dict) -> str | None: |
| 783 | 786 | comp = motion_dir(pid) / f"{item['id']}.html" |
| 784 | 787 | if not comp.is_file(): |
| 788 | +# un média posé dans une composition : <video src="media/m3-x.mp4" data-from="library/broll/x.mp4"> | |
| 789 | +_MEDIA_TAG = re.compile(r"<(?:video|img|audio|source)\b[^>]*>", re.I) | |
| 790 | + | |
| 791 | + | |
| 792 | +def _attr(tag: str, name: str) -> str: | |
| 793 | + found = re.search(rf"""\s{name}\s*=\s*(?:"([^"]*)"|'([^']*)')""", tag, re.I) | |
| 794 | + return html.unescape(found.group(1) or found.group(2) or "").strip() if found else "" | |
| 795 | + | |
| 796 | + | |
| 797 | +def _media_origin(pid: str, src: str) -> Path | None: | |
| 798 | + """Le fichier d'un média listé dans contexte.md (« library/… » ou un chemin du projet).""" | |
| 799 | + src = src.replace("\\", "/").strip().lstrip("/") | |
| 800 | + if not src or ".." in src.split("/"): | |
| 801 | + return None | |
| 802 | + if src.startswith("library/"): | |
| 803 | + path = library.LIBRARY_DIR / src[len("library/"):] | |
| 804 | + else: | |
| 805 | + path = service.project_dir(pid) / src | |
| 806 | + return path if path.is_file() else None | |
| 807 | + | |
| 808 | + | |
| 809 | +def bring_media(pid: str, comp: str) -> list[str]: | |
| 810 | + """Copie à côté de la composition (montage/motion/media/) les médias qu'elle pose, depuis le projet ou la | |
| 811 | + bibliothèque : le rendu ne lit que ce dossier. Rend ce qui manque toujours.""" | |
| 812 | + path = motion_dir(pid) / comp | |
| 813 | + if not path.is_file(): | |
| 814 | + return [] | |
| 815 | + missing = [] | |
| 816 | + for tag in _MEDIA_TAG.findall(path.read_text(encoding="utf-8")): | |
| 817 | + src = _attr(tag, "src").split("?")[0].split("#")[0] | |
| 818 | + if not src.startswith(f"{MEDIA_DIR}/") or ".." in src.split("/"): | |
| 819 | + continue | |
| 820 | + dest = motion_dir(pid) / src | |
| 821 | + origin = _media_origin(pid, _attr(tag, "data-from")) | |
| 822 | + if origin and (not dest.is_file() or dest.stat().st_size != origin.stat().st_size): | |
| 823 | + dest.parent.mkdir(parents=True, exist_ok=True) | |
| 824 | + shutil.copy2(origin, dest) | |
| 825 | + if not dest.is_file(): | |
| 826 | + missing.append(f"{path.stem} : média introuvable ({_attr(tag, 'data-from') or src})") | |
| 827 | + return missing | |
| 828 | + | |
| 829 | + | |
| 785 | 830 | return None |
| 786 | 831 | body = comp.read_bytes() + json.dumps([item["dur"]]).encode() |
| 787 | 832 | style = motion_dir(pid) / "style.css" |
| @@ -42,6 +42,7 @@ pour un média de la bibliothèque commune (copié dans le projet à l'enregistr | |||
| 42 | """ | 42 | """ |
| 43 | import hashlib | 43 | import hashlib |
| 44 | import json | 44 | import json |
| 45 | +import html | ||
| 45 | import os | 46 | import os |
| 46 | import re | 47 | import re |
| 47 | import shutil | 48 | import shutil |
| @@ -63,6 +64,8 @@ INSERTIONS_FILE = "insertions.json" | |||
| 63 | MOTION_DIR = "motion" | 64 | MOTION_DIR = "motion" |
| 64 | RENDERS_DIR = "renders" | 65 | RENDERS_DIR = "renders" |
| 65 | HISTORY_DIR = ".history" | 66 | HISTORY_DIR = ".history" |
| 67 | +# les médias posés dans les compositions, copiés à côté d'elles (le rendu ne lit que montage/motion/) | ||
| 68 | +MEDIA_DIR = "media" | ||
| 66 | CACHE_DIR = ".cache" | 69 | CACHE_DIR = ".cache" |
| 67 | HISTORY_MAX = 60 | 70 | HISTORY_MAX = 60 |
| 68 | # les copies de travail de Daryl, une par demande en cours (daryl/<demande>/) ; dans chacune, WORK_BASE : la timeline | 71 | # les copies de travail de Daryl, une par demande en cours (daryl/<demande>/) ; dans chacune, WORK_BASE : la timeline |
| @@ -782,6 +785,48 @@ def after_change(pid: str) -> None: | |||
| 782 | def motion_signature(pid: str, item: dict) -> str | None: | 785 | def motion_signature(pid: str, item: dict) -> str | None: |
| 783 | comp = motion_dir(pid) / f"{item['id']}.html" | 786 | comp = motion_dir(pid) / f"{item['id']}.html" |
| 784 | if not comp.is_file(): | 787 | if not comp.is_file(): |
| 788 | +# un média posé dans une composition : <video src="media/m3-x.mp4" data-from="library/broll/x.mp4"> | ||
| 789 | +_MEDIA_TAG = re.compile(r"<(?:video|img|audio|source)\b[^>]*>", re.I) | ||
| 790 | + | ||
| 791 | + | ||
| 792 | +def _attr(tag: str, name: str) -> str: | ||
| 793 | + found = re.search(rf"""\s{name}\s*=\s*(?:"([^"]*)"|'([^']*)')""", tag, re.I) | ||
| 794 | + return html.unescape(found.group(1) or found.group(2) or "").strip() if found else "" | ||
| 795 | + | ||
| 796 | + | ||
| 797 | +def _media_origin(pid: str, src: str) -> Path | None: | ||
| 798 | + """Le fichier d'un média listé dans contexte.md (« library/… » ou un chemin du projet).""" | ||
| 799 | + src = src.replace("\\", "/").strip().lstrip("/") | ||
| 800 | + if not src or ".." in src.split("/"): | ||
| 801 | + return None | ||
| 802 | + if src.startswith("library/"): | ||
| 803 | + path = library.LIBRARY_DIR / src[len("library/"):] | ||
| 804 | + else: | ||
| 805 | + path = service.project_dir(pid) / src | ||
| 806 | + return path if path.is_file() else None | ||
| 807 | + | ||
| 808 | + | ||
| 809 | +def bring_media(pid: str, comp: str) -> list[str]: | ||
| 810 | + """Copie à côté de la composition (montage/motion/media/) les médias qu'elle pose, depuis le projet ou la | ||
| 811 | + bibliothèque : le rendu ne lit que ce dossier. Rend ce qui manque toujours.""" | ||
| 812 | + path = motion_dir(pid) / comp | ||
| 813 | + if not path.is_file(): | ||
| 814 | + return [] | ||
| 815 | + missing = [] | ||
| 816 | + for tag in _MEDIA_TAG.findall(path.read_text(encoding="utf-8")): | ||
| 817 | + src = _attr(tag, "src").split("?")[0].split("#")[0] | ||
| 818 | + if not src.startswith(f"{MEDIA_DIR}/") or ".." in src.split("/"): | ||
| 819 | + continue | ||
| 820 | + dest = motion_dir(pid) / src | ||
| 821 | + origin = _media_origin(pid, _attr(tag, "data-from")) | ||
| 822 | + if origin and (not dest.is_file() or dest.stat().st_size != origin.stat().st_size): | ||
| 823 | + dest.parent.mkdir(parents=True, exist_ok=True) | ||
| 824 | + shutil.copy2(origin, dest) | ||
| 825 | + if not dest.is_file(): | ||
| 826 | + missing.append(f"{path.stem} : média introuvable ({_attr(tag, 'data-from') or src})") | ||
| 827 | + return missing | ||
| 828 | + | ||
| 829 | + | ||
| 785 | return None | 830 | return None |
| 786 | body = comp.read_bytes() + json.dumps([item["dur"]]).encode() | 831 | body = comp.read_bytes() + json.dumps([item["dur"]]).encode() |
| 787 | style = motion_dir(pid) / "style.css" | 832 | style = motion_dir(pid) / "style.css" |
modified
backend/studio/modules/projects/montage_tasks.py +11 -1 | @@ -61,7 +61,11 @@ pendant les phrases d'avant) : tant qu'il parle d'autre chose, le cadre reste su | ||
| 61 | 61 | environ 75 % de l'entrée. Pas d'oscillation décorative (ni repeat: -1 ni yoyo). Avant le moment fort, \ |
| 62 | 62 | 0,3 à 0,75 s d'immobilité. |
| 63 | 63 | - La timeline doit couvrir toute la durée : ajoute `tl.to({}, {duration: <dur>}, 0)`, sinon l'image \ |
| 64 | -devient noire avant la fin.""" | |
| 64 | +devient noire avant la fin. | |
| 65 | +- Une vidéo ou une image de contexte.md dans la composition : `src="media/<id>-<nom court sans espace>.<ext>"` \ | |
| 66 | +et `data-from="<son src exact listé dans contexte.md>"`, sur la balise elle-même (<video>, <img>). Le studio \ | |
| 67 | +copie le fichier à cet endroit ; tu ne peux pas copier de fichier toi-même. Une vidéo : `muted`, avec \ | |
| 68 | +data-start / data-duration (et data-media-start pour partir plus loin dans le média).""" | |
| 65 | 69 | |
| 66 | 70 | TRANSITIONS = "\n".join(transitions.prompt_lines()) |
| 67 | 71 | |
| @@ -380,6 +384,9 @@ def chat(ctx: TaskContext) -> None: | ||
| 380 | 384 | _insert(pid, task, reply) |
| 381 | 385 | _apply(pid, task) |
| 382 | 386 | changed, kept = montage.finish_work(pid, task, touched) |
| 387 | + # les médias de ses compositions, copiés à côté d'elles avant l'aperçu et le rendu | |
| 388 | + for comp in sorted(touched): | |
| 389 | + kept += montage.bring_media(pid, comp) | |
| 383 | 390 | if changed: |
| 384 | 391 | montage.after_change(pid) |
| 385 | 392 | problems = reply.pop("problems", []) + kept |
| @@ -467,6 +474,9 @@ def render_motion(ctx: TaskContext) -> None: | ||
| 467 | 474 | pid, item_id, sig = p["project"], p["motion"], p["signature"] |
| 468 | 475 | directory = montage.motion_dir(pid) |
| 469 | 476 | output = montage.motion_render_file(pid, item_id, sig) |
| 477 | + missing = montage.bring_media(pid, f"{item_id}.html") | |
| 478 | + if missing: | |
| 479 | + raise RuntimeError("; ".join(missing) + " : indique son src dans data-from") | |
| 470 | 480 | _index(directory, item_id, p["duration"]) |
| 471 | 481 | ctx.set_status("rendering") |
| 472 | 482 | |
| @@ -61,7 +61,11 @@ pendant les phrases d'avant) : tant qu'il parle d'autre chose, le cadre reste su | |||
| 61 | environ 75 % de l'entrée. Pas d'oscillation décorative (ni repeat: -1 ni yoyo). Avant le moment fort, \ | 61 | environ 75 % de l'entrée. Pas d'oscillation décorative (ni repeat: -1 ni yoyo). Avant le moment fort, \ |
| 62 | 0,3 à 0,75 s d'immobilité. | 62 | 0,3 à 0,75 s d'immobilité. |
| 63 | - La timeline doit couvrir toute la durée : ajoute `tl.to({}, {duration: <dur>}, 0)`, sinon l'image \ | 63 | - La timeline doit couvrir toute la durée : ajoute `tl.to({}, {duration: <dur>}, 0)`, sinon l'image \ |
| 64 | -devient noire avant la fin.""" | 64 | +devient noire avant la fin. |
| 65 | +- Une vidéo ou une image de contexte.md dans la composition : `src="media/<id>-<nom court sans espace>.<ext>"` \ | ||
| 66 | +et `data-from="<son src exact listé dans contexte.md>"`, sur la balise elle-même (<video>, <img>). Le studio \ | ||
| 67 | +copie le fichier à cet endroit ; tu ne peux pas copier de fichier toi-même. Une vidéo : `muted`, avec \ | ||
| 68 | +data-start / data-duration (et data-media-start pour partir plus loin dans le média).""" | ||
| 65 | 69 | ||
| 66 | TRANSITIONS = "\n".join(transitions.prompt_lines()) | 70 | TRANSITIONS = "\n".join(transitions.prompt_lines()) |
| 67 | 71 | ||
| @@ -380,6 +384,9 @@ def chat(ctx: TaskContext) -> None: | |||
| 380 | _insert(pid, task, reply) | 384 | _insert(pid, task, reply) |
| 381 | _apply(pid, task) | 385 | _apply(pid, task) |
| 382 | changed, kept = montage.finish_work(pid, task, touched) | 386 | changed, kept = montage.finish_work(pid, task, touched) |
| 387 | + # les médias de ses compositions, copiés à côté d'elles avant l'aperçu et le rendu | ||
| 388 | + for comp in sorted(touched): | ||
| 389 | + kept += montage.bring_media(pid, comp) | ||
| 383 | if changed: | 390 | if changed: |
| 384 | montage.after_change(pid) | 391 | montage.after_change(pid) |
| 385 | problems = reply.pop("problems", []) + kept | 392 | problems = reply.pop("problems", []) + kept |
| @@ -467,6 +474,9 @@ def render_motion(ctx: TaskContext) -> None: | |||
| 467 | pid, item_id, sig = p["project"], p["motion"], p["signature"] | 474 | pid, item_id, sig = p["project"], p["motion"], p["signature"] |
| 468 | directory = montage.motion_dir(pid) | 475 | directory = montage.motion_dir(pid) |
| 469 | output = montage.motion_render_file(pid, item_id, sig) | 476 | output = montage.motion_render_file(pid, item_id, sig) |
| 477 | + missing = montage.bring_media(pid, f"{item_id}.html") | ||
| 478 | + if missing: | ||
| 479 | + raise RuntimeError("; ".join(missing) + " : indique son src dans data-from") | ||
| 470 | _index(directory, item_id, p["duration"]) | 480 | _index(directory, item_id, p["duration"]) |
| 471 | ctx.set_status("rendering") | 481 | ctx.set_status("rendering") |
| 472 | 482 | ||