hlassiege/darylpublic Fork
⑂ 2932ab7
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(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.
hlassiege committed 2026-10-11T17:14:55+02:00 Browse files
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
4242 """
4343 import hashlib
4444 import json
45+import html
4546 import os
4647 import re
4748 import shutil
@@ -63,6 +64,8 @@ INSERTIONS_FILE = "insertions.json"
6364 MOTION_DIR = "motion"
6465 RENDERS_DIR = "renders"
6566 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"
6669 CACHE_DIR = ".cache"
6770 HISTORY_MAX = 60
6871 # 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:
782785 def motion_signature(pid: str, item: dict) -> str | None:
783786 comp = motion_dir(pid) / f"{item['id']}.html"
784787 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+
785830 return None
786831 body = comp.read_bytes() + json.dumps([item["dur"]]).encode()
787832 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 hashlib43 import hashlib
44 import json44 import json
45+import html
45 import os46 import os
46 import re47 import re
47 import shutil48 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 = 6070 HISTORY_MAX = 60
68 # les copies de travail de Daryl, une par demande en cours (daryl/<demande>/) ; dans chacune, WORK_BASE : la timeline71 # 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 None830 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
6161 environ 75 % de l'entrée. Pas d'oscillation décorative (ni repeat: -1 ni yoyo). Avant le moment fort, \
6262 0,3 à 0,75 s d'immobilité.
6363 - 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)."""
6569
6670 TRANSITIONS = "\n".join(transitions.prompt_lines())
6771
@@ -380,6 +384,9 @@ def chat(ctx: TaskContext) -> None:
380384 _insert(pid, task, reply)
381385 _apply(pid, task)
382386 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)
383390 if changed:
384391 montage.after_change(pid)
385392 problems = reply.pop("problems", []) + kept
@@ -467,6 +474,9 @@ def render_motion(ctx: TaskContext) -> None:
467474 pid, item_id, sig = p["project"], p["motion"], p["signature"]
468475 directory = montage.motion_dir(pid)
469476 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")
470480 _index(directory, item_id, p["duration"])
471481 ctx.set_status("rendering")
472482
@@ -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", []) + kept392 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