hlassiege/darylpublic Fork
⑂ 764ddcf
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

feat(montage): let a motion beat take reference videos

A motion now takes videos as well as images: from the project or the
library, imported, or clipped from YouTube in the beat media popup. They
are copied next to the composition like the images; the brief gives
Daryl each video's size and duration, a still to look at, and asks to
play it muted in a <video> tag rather than imitate it. The beat strip
shows video thumbnails and the preview slide keeps the first image.
hlassiege committed 2026-10-22T16:24:05+02:00 Browse files
764ddcf parent: 18b8800
modified backend/studio/modules/projects/storyboard.py +56 -25
@@ -27,18 +27,20 @@ Fichier : montage/storyboard.json
2727 "motion": {"enter", "hold", "exit"}, "sfx", "ref", "why", "on",
2828 "zoom": {"scale", "x", "y"}, # zoom
2929 "src", "search", # broll : le média (vide : à trouver), la recherche
30- "images"}]} # motion : les images à y intégrer (facultatives)
30+ "images"}]} # motion : les images et vidéos à y intégrer (facultatives)
3131 `at` / `dur` en secondes de la timeline. Les contrôles (`check`) sont refaits à chaque lecture :
3232 le montage de l'A-roll peut avoir bougé depuis la génération.
3333 """
3434 import json
3535 import re
3636 import shutil
37+import subprocess
3738 import time
3839 from pathlib import Path
3940 from urllib.parse import quote, unquote
4041
4142 from ...core import jobs
43+from ...core.system import tool
4244 from ...core.tasks import TaskContext
4345 from ..settings.service import language_name
4446 from ..transcript import cues
@@ -927,8 +929,8 @@ def retype(b: dict, kind) -> None:
927929 """Change le type d'un beat, avec ce qu'il faut au nouveau type (sa durée ne bouge pas)."""
928930 if kind not in TYPES or kind == b.get("type"):
929931 return
930- # les images suivent : celle d'un B-roll devient la référence du motion, la première du motion le média du B-roll
931- images = motion_images(b) if b.get("type") == "motion" else [b["src"]] if media_kind(b.get("src", "")) == "image" else []
932+ # les médias suivent : celui d'un B-roll devient la référence du motion, la première du motion le média du B-roll
933+ images = motion_media(b) if b.get("type") == "motion" else [b["src"]] if media_kind(b.get("src", "")) else []
932934 b["type"] = kind
933935 # ce qui ne sert qu'à l'ancien type s'en va
934936 for key, owner in (("zoom", "zoom"), ("src", "broll"), ("search", "broll"), ("start", "broll"), ("images", "motion")):
@@ -985,7 +987,7 @@ def edit(pid: str, changes: list[dict]) -> None:
985987 if b["type"] == "motion" and isinstance(c.get("images"), list):
986988 b.pop("src", None)
987989 images = [resolve_media(x, media) for x in c["images"] if isinstance(x, str)]
988- images = list(dict.fromkeys(x for x in images if media_kind(x) == "image"))
990+ images = list(dict.fromkeys(x for x in images if media_kind(x)))
989991 if images:
990992 b["images"] = images
991993 else:
@@ -1111,13 +1113,13 @@ def animated(pid: str, timeline: dict) -> dict[str, str]:
11111113
11121114
11131115 def image_file(beat: dict, src: str | None = None) -> str:
1114- """Le nom, dans montage/motion/, de l'image d'un B-roll animé (ou de `src`, une image d'un motion)."""
1116+ """Le nom, dans montage/motion/, de l'image d'un B-roll animé (ou de `src`, une image ou une vidéo d'un motion)."""
11151117 return f"{MEDIA_DIR}/{beat['id']}-{Path(src or beat['src']).name}"
11161118
11171119
1118-def motion_images(beat: dict) -> list[str]:
1119- """Les images à intégrer à un motion (`src` : l'ancienne forme, une seule image)."""
1120- images = [x for x in beat.get("images") or [] if media_kind(x) == "image"]
1120+def motion_media(beat: dict) -> list[str]:
1121+ """Les images et vidéos à intégrer à un motion (`src` : l'ancienne forme, une seule image)."""
1122+ images = [x for x in beat.get("images") or [] if media_kind(x)]
11211123 if media_kind(beat.get("src", "")) == "image" and beat["src"] not in images:
11221124 images.insert(0, beat["src"])
11231125 return images
@@ -1175,9 +1177,9 @@ def compose(pid: str, only: list[str] | None = None) -> dict:
11751177 if not b.get("on") or (only is not None and b["id"] not in only) or b["id"] in written:
11761178 continue
11771179 if b["id"] in done:
1178- # déjà posé, composition jamais écrite : on la redemande sans le reposer (avec les images choisies depuis)
1180+ # déjà posé, composition jamais écrite : on la redemande sans le reposer (avec les médias choisis depuis)
11791181 if b.get("type") == "motion":
1180- for src in motion_images(b):
1182+ for src in motion_media(b):
11811183 copy_image(pid, b, src)
11821184 out["motions"].append(done[b["id"]])
11831185 continue
@@ -1213,8 +1215,8 @@ def compose(pid: str, only: list[str] | None = None) -> dict:
12131215 out["missing"].append(b["id"])
12141216 continue
12151217 elif kind == "motion":
1216- # les images de référence aussi ; une introuvable est passée (la consigne reste)
1217- for src in motion_images(b):
1218+ # les images et vidéos de référence aussi ; une introuvable est passée (la consigne reste)
1219+ for src in motion_media(b):
12181220 copy_image(pid, b, src)
12191221 mid = next_id("motion")
12201222 timeline["motion"].append({"id": mid, "at": b["at"], "dur": b["dur"], "title": b["show"][:60],
@@ -1272,22 +1274,51 @@ def image_brief(pid: str, b: dict) -> list[str]:
12721274 return lines
12731275
12741276
1275-def motion_images_brief(pid: str, b: dict) -> list[str]:
1276- """La fiche des images fournies pour un motion : à intégrer à l'animation décrite, pas à mettre en scène seules."""
1277- files = [image_file(b, src) for src in motion_images(b)]
1278- files = [f for f in files if (montage.motion_dir(pid) / f).is_file()]
1277+def video_still(path: Path) -> Path | None:
1278+ """Une image d'une vidéo, prise au tiers (à côté d'elle, en .jpg) : ce que Daryl peut regarder."""
1279+ dest = path.with_name(path.name + ".jpg")
1280+ if dest.is_file():
1281+ return dest
1282+ at = (media.probe(path).get("duration") or 0) / 3
1283+ proc = subprocess.run([tool("ffmpeg"), "-y", "-v", "error", "-ss", f"{at:.2f}", "-i", str(path), "-frames:v", "1",
1284+ "-vf", "scale=960:-2", "-q:v", "3", str(dest)], capture_output=True, timeout=60)
1285+ return dest if proc.returncode == 0 and dest.is_file() else None
1286+
1287+
1288+def motion_media_brief(pid: str, b: dict) -> list[str]:
1289+ """La fiche des images et vidéos fournies pour un motion : à intégrer à l'animation décrite, pas à mettre en
1290+ scène seules."""
1291+ folder = montage.motion_dir(pid)
1292+ files = [image_file(b, src) for src in motion_media(b)]
1293+ files = [f for f in files if (folder / f).is_file()]
12791294 if not files:
12801295 return []
1281- lines = [f"- {'Image fournie' if len(files) == 1 else 'Images fournies'} par l'auteur : regarde chacune (Read) "
1282- "avant de composer et intègre-les à l'animation là où la consigne en parle (ce sont les visuels dont elle "
1283- "parle : ne les remplace pas par des imitations en HTML)."]
1284- for f in files:
1285- info = media.probe(montage.motion_dir(pid) / f)
1296+ images = [f for f in files if media_kind(f) == "image"]
1297+ videos = [f for f in files if media_kind(f) == "video"]
1298+ lines = [f"- {'Média fourni' if len(files) == 1 else 'Médias fournis'} par l'auteur : regarde chacun (Read ; une "
1299+ "vidéo, par l'image tirée d'elle) avant de composer et intègre-les à l'animation là où la consigne en "
1300+ "parle (ce sont les visuels dont elle parle : ne les remplace pas par des imitations en HTML)."]
1301+ for f in images:
1302+ info = media.probe(folder / f)
12861303 w, h = info.get("width"), info.get("height")
12871304 sharp = f", {w}×{h} px : à l'écran jamais plus de {round(w * MAX_UPSCALE)} × {round(h * MAX_UPSCALE)} px" if w and h else ""
1288- lines.append(f" - src=\"{f}\"{sharp}")
1289- lines.append(f"- Netteté : au-delà de ×{MAX_UPSCALE} sa taille (zooms compris), une image est floue. Pour lire un "
1290- "passage de près, on l'isole (le reste s'assombrit ou se floute) plutôt que de zoomer dans les pixels.")
1305+ lines.append(f" - image src=\"{f}\"{sharp}")
1306+ for f in videos:
1307+ info = media.probe(folder / f)
1308+ w, h, d = info.get("width"), info.get("height"), info.get("duration")
1309+ size = f", {w}×{h} px" if w and h else ""
1310+ length = f", {d:.1f} s" if d else ""
1311+ still = video_still(folder / f)
1312+ seen = f" ; une image tirée d'elle : {still.relative_to(folder).as_posix()}" if still else ""
1313+ lines.append(f" - vidéo src=\"{f}\"{size}{length}{seen}")
1314+ if images:
1315+ lines.append(f"- Netteté : au-delà de ×{MAX_UPSCALE} sa taille (zooms compris), une image est floue. Pour lire "
1316+ "un passage de près, on l'isole (le reste s'assombrit ou se floute) plutôt que de zoomer dans les "
1317+ "pixels.")
1318+ if videos:
1319+ lines.append("- Une vidéo se pose dans une balise <video> avec ce src, `muted`, data-start / data-duration (et "
1320+ "data-media-start pour partir plus loin dans le média) ; elle joue dans son cadre (fenêtre, "
1321+ "écran, carte), à sa taille nette, pas en image figée.")
12911322 return lines
12921323
12931324
@@ -1318,7 +1349,7 @@ def brief(pid: str, ids: list[str]) -> str:
13181349 lines.append(f"- {FULL_SCREEN}")
13191350 if b.get("anim"):
13201351 lines.append(f"- Base : l'animation du kit montage/motion/exemples/{b['anim']} (adapte-la, garde sa mécanique)")
1321- lines += motion_images_brief(pid, b)
1352+ lines += motion_media_brief(pid, b)
13221353 if b.get("enter") or b.get("exit"):
13231354 lines.append("- Le montage ajoute " + " et ".join(
13241355 f"{'en entrée' if k == 'enter' else 'en sortie'} la transition « {transitions.label(b[k]['fx'])} » "
@@ -27,18 +27,20 @@ Fichier : montage/storyboard.json
27 "motion": {"enter", "hold", "exit"}, "sfx", "ref", "why", "on",27 "motion": {"enter", "hold", "exit"}, "sfx", "ref", "why", "on",
28 "zoom": {"scale", "x", "y"}, # zoom28 "zoom": {"scale", "x", "y"}, # zoom
29 "src", "search", # broll : le média (vide : à trouver), la recherche29 "src", "search", # broll : le média (vide : à trouver), la recherche
30- "images"}]} # motion : les images à y intégrer (facultatives)30+ "images"}]} # motion : les images et vidéos à y intégrer (facultatives)
31 `at` / `dur` en secondes de la timeline. Les contrôles (`check`) sont refaits à chaque lecture :31 `at` / `dur` en secondes de la timeline. Les contrôles (`check`) sont refaits à chaque lecture :
32 le montage de l'A-roll peut avoir bougé depuis la génération.32 le montage de l'A-roll peut avoir bougé depuis la génération.
33 """33 """
34 import json34 import json
35 import re35 import re
36 import shutil36 import shutil
37+import subprocess
37 import time38 import time
38 from pathlib import Path39 from pathlib import Path
39 from urllib.parse import quote, unquote40 from urllib.parse import quote, unquote
40 41
41 from ...core import jobs42 from ...core import jobs
43+from ...core.system import tool
42 from ...core.tasks import TaskContext44 from ...core.tasks import TaskContext
43 from ..settings.service import language_name45 from ..settings.service import language_name
44 from ..transcript import cues46 from ..transcript import cues
@@ -927,8 +929,8 @@ def retype(b: dict, kind) -> None:
927 """Change le type d'un beat, avec ce qu'il faut au nouveau type (sa durée ne bouge pas)."""929 """Change le type d'un beat, avec ce qu'il faut au nouveau type (sa durée ne bouge pas)."""
928 if kind not in TYPES or kind == b.get("type"):930 if kind not in TYPES or kind == b.get("type"):
929 return931 return
930- # les images suivent : celle d'un B-roll devient la référence du motion, la première du motion le média du B-roll932+ # les médias suivent : celui d'un B-roll devient la référence du motion, la première du motion le média du B-roll
931- images = motion_images(b) if b.get("type") == "motion" else [b["src"]] if media_kind(b.get("src", "")) == "image" else []933+ images = motion_media(b) if b.get("type") == "motion" else [b["src"]] if media_kind(b.get("src", "")) else []
932 b["type"] = kind934 b["type"] = kind
933 # ce qui ne sert qu'à l'ancien type s'en va935 # ce qui ne sert qu'à l'ancien type s'en va
934 for key, owner in (("zoom", "zoom"), ("src", "broll"), ("search", "broll"), ("start", "broll"), ("images", "motion")):936 for key, owner in (("zoom", "zoom"), ("src", "broll"), ("search", "broll"), ("start", "broll"), ("images", "motion")):
@@ -985,7 +987,7 @@ def edit(pid: str, changes: list[dict]) -> None:
985 if b["type"] == "motion" and isinstance(c.get("images"), list):987 if b["type"] == "motion" and isinstance(c.get("images"), list):
986 b.pop("src", None)988 b.pop("src", None)
987 images = [resolve_media(x, media) for x in c["images"] if isinstance(x, str)]989 images = [resolve_media(x, media) for x in c["images"] if isinstance(x, str)]
988- images = list(dict.fromkeys(x for x in images if media_kind(x) == "image"))990+ images = list(dict.fromkeys(x for x in images if media_kind(x)))
989 if images:991 if images:
990 b["images"] = images992 b["images"] = images
991 else:993 else:
@@ -1111,13 +1113,13 @@ def animated(pid: str, timeline: dict) -> dict[str, str]:
1111 1113
1112 1114
1113 def image_file(beat: dict, src: str | None = None) -> str:1115 def image_file(beat: dict, src: str | None = None) -> str:
1114- """Le nom, dans montage/motion/, de l'image d'un B-roll animé (ou de `src`, une image d'un motion)."""1116+ """Le nom, dans montage/motion/, de l'image d'un B-roll animé (ou de `src`, une image ou une vidéo d'un motion)."""
1115 return f"{MEDIA_DIR}/{beat['id']}-{Path(src or beat['src']).name}"1117 return f"{MEDIA_DIR}/{beat['id']}-{Path(src or beat['src']).name}"
1116 1118
1117 1119
1118-def motion_images(beat: dict) -> list[str]:1120+def motion_media(beat: dict) -> list[str]:
1119- """Les images à intégrer à un motion (`src` : l'ancienne forme, une seule image)."""1121+ """Les images et vidéos à intégrer à un motion (`src` : l'ancienne forme, une seule image)."""
1120- images = [x for x in beat.get("images") or [] if media_kind(x) == "image"]1122+ images = [x for x in beat.get("images") or [] if media_kind(x)]
1121 if media_kind(beat.get("src", "")) == "image" and beat["src"] not in images:1123 if media_kind(beat.get("src", "")) == "image" and beat["src"] not in images:
1122 images.insert(0, beat["src"])1124 images.insert(0, beat["src"])
1123 return images1125 return images
@@ -1175,9 +1177,9 @@ def compose(pid: str, only: list[str] | None = None) -> dict:
1175 if not b.get("on") or (only is not None and b["id"] not in only) or b["id"] in written:1177 if not b.get("on") or (only is not None and b["id"] not in only) or b["id"] in written:
1176 continue1178 continue
1177 if b["id"] in done:1179 if b["id"] in done:
1178- # déjà posé, composition jamais écrite : on la redemande sans le reposer (avec les images choisies depuis)1180+ # déjà posé, composition jamais écrite : on la redemande sans le reposer (avec les médias choisis depuis)
1179 if b.get("type") == "motion":1181 if b.get("type") == "motion":
1180- for src in motion_images(b):1182+ for src in motion_media(b):
1181 copy_image(pid, b, src)1183 copy_image(pid, b, src)
1182 out["motions"].append(done[b["id"]])1184 out["motions"].append(done[b["id"]])
1183 continue1185 continue
@@ -1213,8 +1215,8 @@ def compose(pid: str, only: list[str] | None = None) -> dict:
1213 out["missing"].append(b["id"])1215 out["missing"].append(b["id"])
1214 continue1216 continue
1215 elif kind == "motion":1217 elif kind == "motion":
1216- # les images de référence aussi ; une introuvable est passée (la consigne reste)1218+ # les images et vidéos de référence aussi ; une introuvable est passée (la consigne reste)
1217- for src in motion_images(b):1219+ for src in motion_media(b):
1218 copy_image(pid, b, src)1220 copy_image(pid, b, src)
1219 mid = next_id("motion")1221 mid = next_id("motion")
1220 timeline["motion"].append({"id": mid, "at": b["at"], "dur": b["dur"], "title": b["show"][:60],1222 timeline["motion"].append({"id": mid, "at": b["at"], "dur": b["dur"], "title": b["show"][:60],
@@ -1272,22 +1274,51 @@ def image_brief(pid: str, b: dict) -> list[str]:
1272 return lines1274 return lines
1273 1275
1274 1276
1275-def motion_images_brief(pid: str, b: dict) -> list[str]:1277+def video_still(path: Path) -> Path | None:
1276- """La fiche des images fournies pour un motion : à intégrer à l'animation décrite, pas à mettre en scène seules."""1278+ """Une image d'une vidéo, prise au tiers (à côté d'elle, en .jpg) : ce que Daryl peut regarder."""
1277- files = [image_file(b, src) for src in motion_images(b)]1279+ dest = path.with_name(path.name + ".jpg")
1278- files = [f for f in files if (montage.motion_dir(pid) / f).is_file()]1280+ if dest.is_file():
1281+ return dest
1282+ at = (media.probe(path).get("duration") or 0) / 3
1283+ proc = subprocess.run([tool("ffmpeg"), "-y", "-v", "error", "-ss", f"{at:.2f}", "-i", str(path), "-frames:v", "1",
1284+ "-vf", "scale=960:-2", "-q:v", "3", str(dest)], capture_output=True, timeout=60)
1285+ return dest if proc.returncode == 0 and dest.is_file() else None
1286+
1287+
1288+def motion_media_brief(pid: str, b: dict) -> list[str]:
1289+ """La fiche des images et vidéos fournies pour un motion : à intégrer à l'animation décrite, pas à mettre en
1290+ scène seules."""
1291+ folder = montage.motion_dir(pid)
1292+ files = [image_file(b, src) for src in motion_media(b)]
1293+ files = [f for f in files if (folder / f).is_file()]
1279 if not files:1294 if not files:
1280 return []1295 return []
1281- lines = [f"- {'Image fournie' if len(files) == 1 else 'Images fournies'} par l'auteur : regarde chacune (Read) "1296+ images = [f for f in files if media_kind(f) == "image"]
1282- "avant de composer et intègre-les à l'animation là où la consigne en parle (ce sont les visuels dont elle "1297+ videos = [f for f in files if media_kind(f) == "video"]
1283- "parle : ne les remplace pas par des imitations en HTML)."]1298+ lines = [f"- {'Média fourni' if len(files) == 1 else 'Médias fournis'} par l'auteur : regarde chacun (Read ; une "
1284- for f in files:1299+ "vidéo, par l'image tirée d'elle) avant de composer et intègre-les à l'animation là où la consigne en "
1285- info = media.probe(montage.motion_dir(pid) / f)1300+ "parle (ce sont les visuels dont elle parle : ne les remplace pas par des imitations en HTML)."]
1301+ for f in images:
1302+ info = media.probe(folder / f)
1286 w, h = info.get("width"), info.get("height")1303 w, h = info.get("width"), info.get("height")
1287 sharp = f", {w}×{h} px : à l'écran jamais plus de {round(w * MAX_UPSCALE)} × {round(h * MAX_UPSCALE)} px" if w and h else ""1304 sharp = f", {w}×{h} px : à l'écran jamais plus de {round(w * MAX_UPSCALE)} × {round(h * MAX_UPSCALE)} px" if w and h else ""
1288- lines.append(f" - src=\"{f}\"{sharp}")1305+ lines.append(f" - image src=\"{f}\"{sharp}")
1289- lines.append(f"- Netteté : au-delà de ×{MAX_UPSCALE} sa taille (zooms compris), une image est floue. Pour lire un "1306+ for f in videos:
1290- "passage de près, on l'isole (le reste s'assombrit ou se floute) plutôt que de zoomer dans les pixels.")1307+ info = media.probe(folder / f)
1308+ w, h, d = info.get("width"), info.get("height"), info.get("duration")
1309+ size = f", {w}×{h} px" if w and h else ""
1310+ length = f", {d:.1f} s" if d else ""
1311+ still = video_still(folder / f)
1312+ seen = f" ; une image tirée d'elle : {still.relative_to(folder).as_posix()}" if still else ""
1313+ lines.append(f" - vidéo src=\"{f}\"{size}{length}{seen}")
1314+ if images:
1315+ lines.append(f"- Netteté : au-delà de ×{MAX_UPSCALE} sa taille (zooms compris), une image est floue. Pour lire "
1316+ "un passage de près, on l'isole (le reste s'assombrit ou se floute) plutôt que de zoomer dans les "
1317+ "pixels.")
1318+ if videos:
1319+ lines.append("- Une vidéo se pose dans une balise <video> avec ce src, `muted`, data-start / data-duration (et "
1320+ "data-media-start pour partir plus loin dans le média) ; elle joue dans son cadre (fenêtre, "
1321+ "écran, carte), à sa taille nette, pas en image figée.")
1291 return lines1322 return lines
1292 1323
1293 1324
@@ -1318,7 +1349,7 @@ def brief(pid: str, ids: list[str]) -> str:
1318 lines.append(f"- {FULL_SCREEN}")1349 lines.append(f"- {FULL_SCREEN}")
1319 if b.get("anim"):1350 if b.get("anim"):
1320 lines.append(f"- Base : l'animation du kit montage/motion/exemples/{b['anim']} (adapte-la, garde sa mécanique)")1351 lines.append(f"- Base : l'animation du kit montage/motion/exemples/{b['anim']} (adapte-la, garde sa mécanique)")
1321- lines += motion_images_brief(pid, b)1352+ lines += motion_media_brief(pid, b)
1322 if b.get("enter") or b.get("exit"):1353 if b.get("enter") or b.get("exit"):
1323 lines.append("- Le montage ajoute " + " et ".join(1354 lines.append("- Le montage ajoute " + " et ".join(
1324 f"{'en entrée' if k == 'enter' else 'en sortie'} la transition « {transitions.label(b[k]['fx'])} » "1355 f"{'en entrée' if k == 'enter' else 'en sortie'} la transition « {transitions.label(b[k]['fx'])} » "
modified backend/studio/modules/projects/storyboard_chat.py +2 -2
@@ -152,9 +152,9 @@ def _beat_line(b: dict, composed: dict) -> str:
152152 elif kind == "transition":
153153 what = "transition"
154154 else:
155- images = storyboard.motion_images(b)
155+ images = storyboard.motion_media(b)
156156 what = (f"motion · {b['kind']} · {b['place']}{' · base ' + b['anim'] if b.get('anim') else ''}"
157- + (f" · images de l'auteur : {', '.join(images)}" if images else ""))
157+ + (f" · médias de l'auteur : {', '.join(images)}" if images else ""))
158158 ends = "".join(f" · {k} {b[k]['fx']}" for k in ("enter", "exit") if b.get(k))
159159 return (f"- {b['id']}{state} [{storyboard._clock(b['at'])} → {storyboard._clock(b['at'] + b['dur'])}] "
160160 f"{what}{ends}{' · son ' + b['sfx'] if b.get('sfx') else ''} · il dit « {b['says'][:160]} » · on montre : {b['show']}")
@@ -152,9 +152,9 @@ def _beat_line(b: dict, composed: dict) -> str:
152 elif kind == "transition":152 elif kind == "transition":
153 what = "transition"153 what = "transition"
154 else:154 else:
155- images = storyboard.motion_images(b)155+ images = storyboard.motion_media(b)
156 what = (f"motion · {b['kind']} · {b['place']}{' · base ' + b['anim'] if b.get('anim') else ''}"156 what = (f"motion · {b['kind']} · {b['place']}{' · base ' + b['anim'] if b.get('anim') else ''}"
157- + (f" · images de l'auteur : {', '.join(images)}" if images else ""))157+ + (f" · médias de l'auteur : {', '.join(images)}" if images else ""))
158 ends = "".join(f" · {k} {b[k]['fx']}" for k in ("enter", "exit") if b.get(k))158 ends = "".join(f" · {k} {b[k]['fx']}" for k in ("enter", "exit") if b.get(k))
159 return (f"- {b['id']}{state} [{storyboard._clock(b['at'])} → {storyboard._clock(b['at'] + b['dur'])}] "159 return (f"- {b['id']}{state} [{storyboard._clock(b['at'])} → {storyboard._clock(b['at'] + b['dur'])}] "
160 f"{what}{ends}{' · son ' + b['sfx'] if b.get('sfx') else ''} · il dit « {b['says'][:160]} » · on montre : {b['show']}")160 f"{what}{ends}{' · son ' + b['sfx'] if b.get('sfx') else ''} · il dit « {b['says'][:160]} » · on montre : {b['show']}")
modified web/src/i18n/locales/en/editing.ts +9 -11
@@ -143,12 +143,12 @@ export default {
143143 askText: "About beat {id} ({tc}): ",
144144 hint: "Drag its edges on the timeline to cover its passage: they snap to words. Fine settings in the Storyboard step.",
145145 image: {
146- label: "Images to include",
147- title: "Beat images",
146+ label: "Images and videos to include",
147+ title: "Beat media",
148148 titleBroll: "B-roll media",
149- addImages: "Add images",
150- added: "Image added to the beat",
151- chosen: "No image chosen | 1 image chosen | {n} images chosen",
149+ addImages: "Add images or videos",
150+ added: "Media added to the beat",
151+ chosen: "No media chosen | 1 media chosen | {n} media chosen",
152152 done: "Done",
153153 addBroll: "Choose the media",
154154 change: "Change",
@@ -160,18 +160,16 @@ export default {
160160 video: "A clip or the whole video, from a link",
161161 search: "Daryl searches from the beat’s brief",
162162 },
163- foot: "An imported image is saved in the project’s images.",
164- footBroll: "An imported image is saved in the project’s images, a video in its B-roll.",
165- noMedia: "No media yet: import an image or have one searched.",
166- drop: "Drop an image here or click to choose one — or paste a screenshot (Ctrl+V)",
167- dropBroll: "Drop an image or a video here or click to choose one — or paste a screenshot (Ctrl+V)",
163+ foot: "An imported image is saved in the project’s images, a video in its B-roll.",
164+ noMedia: "No media yet: import an image or a video, or have some searched.",
165+ drop: "Drop an image or a video here or click to choose one — or paste a screenshot (Ctrl+V)",
168166 url: "From a URL",
169167 urlPh: "https://… (direct link to the image)",
170168 add: "Add",
171169 importing: "Importing…",
172- notImage: "This file is not an image.",
173170 notMedia: "This file is neither an image nor a video.",
174171 downloaded: "Video downloaded: set on the B-roll",
172+ downloadedMotion: "Video downloaded: added to the beat",
175173 },
176174 },
177175 storyboard: {
@@ -143,12 +143,12 @@ export default {
143 askText: "About beat {id} ({tc}): ",143 askText: "About beat {id} ({tc}): ",
144 hint: "Drag its edges on the timeline to cover its passage: they snap to words. Fine settings in the Storyboard step.",144 hint: "Drag its edges on the timeline to cover its passage: they snap to words. Fine settings in the Storyboard step.",
145 image: {145 image: {
146- label: "Images to include",146+ label: "Images and videos to include",
147- title: "Beat images",147+ title: "Beat media",
148 titleBroll: "B-roll media",148 titleBroll: "B-roll media",
149- addImages: "Add images",149+ addImages: "Add images or videos",
150- added: "Image added to the beat",150+ added: "Media added to the beat",
151- chosen: "No image chosen | 1 image chosen | {n} images chosen",151+ chosen: "No media chosen | 1 media chosen | {n} media chosen",
152 done: "Done",152 done: "Done",
153 addBroll: "Choose the media",153 addBroll: "Choose the media",
154 change: "Change",154 change: "Change",
@@ -160,18 +160,16 @@ export default {
160 video: "A clip or the whole video, from a link",160 video: "A clip or the whole video, from a link",
161 search: "Daryl searches from the beat’s brief",161 search: "Daryl searches from the beat’s brief",
162 },162 },
163- foot: "An imported image is saved in the project’s images.",163+ foot: "An imported image is saved in the project’s images, a video in its B-roll.",
164- footBroll: "An imported image is saved in the project’s images, a video in its B-roll.",164+ noMedia: "No media yet: import an image or a video, or have some searched.",
165- noMedia: "No media yet: import an image or have one searched.",165+ drop: "Drop an image or a video here or click to choose one — or paste a screenshot (Ctrl+V)",
166- drop: "Drop an image here or click to choose one — or paste a screenshot (Ctrl+V)",
167- dropBroll: "Drop an image or a video here or click to choose one — or paste a screenshot (Ctrl+V)",
168 url: "From a URL",166 url: "From a URL",
169 urlPh: "https://… (direct link to the image)",167 urlPh: "https://… (direct link to the image)",
170 add: "Add",168 add: "Add",
171 importing: "Importing…",169 importing: "Importing…",
172- notImage: "This file is not an image.",
173 notMedia: "This file is neither an image nor a video.",170 notMedia: "This file is neither an image nor a video.",
174 downloaded: "Video downloaded: set on the B-roll",171 downloaded: "Video downloaded: set on the B-roll",
172+ downloadedMotion: "Video downloaded: added to the beat",
175 },173 },
176 },174 },
177 storyboard: {175 storyboard: {
modified web/src/i18n/locales/fr/editing.ts +9 -11
@@ -143,12 +143,12 @@ export default {
143143 askText: "À propos du beat {id} ({tc}) : ",
144144 hint: "Tire ses bords sur la timeline pour couvrir son passage : ils s’aimantent aux mots. Réglages fins à l’étape Storyboard.",
145145 image: {
146- label: "Images à intégrer",
147- title: "Images du beat",
146+ label: "Images et vidéos à intégrer",
147+ title: "Médias du beat",
148148 titleBroll: "Média du B-roll",
149- addImages: "Ajouter des images",
150- added: "Image ajoutée au beat",
151- chosen: "Aucune image choisie | 1 image choisie | {n} images choisies",
149+ addImages: "Ajouter des images ou des vidéos",
150+ added: "Média ajouté au beat",
151+ chosen: "Aucun média choisi | 1 média choisi | {n} médias choisis",
152152 done: "Terminé",
153153 addBroll: "Choisir le média",
154154 change: "Changer",
@@ -160,18 +160,16 @@ export default {
160160 video: "Un extrait ou la vidéo entière, depuis un lien",
161161 search: "Daryl cherche à partir de la consigne du beat",
162162 },
163- foot: "Une image importée est rangée dans les images du projet.",
164- footBroll: "Une image importée est rangée dans les images du projet, une vidéo dans ses B-roll.",
165- noMedia: "Aucun média pour l’instant : importe une image ou fais-la chercher.",
166- drop: "Glisse une image ici ou clique pour en choisir une — ou colle une capture (Ctrl+V)",
167- dropBroll: "Glisse une image ou une vidéo ici ou clique pour en choisir une — ou colle une capture (Ctrl+V)",
163+ foot: "Une image importée est rangée dans les images du projet, une vidéo dans ses B-roll.",
164+ noMedia: "Aucun média pour l’instant : importe une image ou une vidéo, ou fais-en chercher.",
165+ drop: "Glisse une image ou une vidéo ici ou clique pour en choisir une — ou colle une capture (Ctrl+V)",
168166 url: "Depuis une adresse",
169167 urlPh: "https://… (lien direct vers l’image)",
170168 add: "Ajouter",
171169 importing: "Import…",
172- notImage: "Ce fichier n’est pas une image.",
173170 notMedia: "Ce fichier n’est ni une image ni une vidéo.",
174171 downloaded: "Vidéo téléchargée : posée sur le B-roll",
172+ downloadedMotion: "Vidéo téléchargée : ajoutée au beat",
175173 },
176174 },
177175 storyboard: {
@@ -143,12 +143,12 @@ export default {
143 askText: "À propos du beat {id} ({tc}) : ",143 askText: "À propos du beat {id} ({tc}) : ",
144 hint: "Tire ses bords sur la timeline pour couvrir son passage : ils s’aimantent aux mots. Réglages fins à l’étape Storyboard.",144 hint: "Tire ses bords sur la timeline pour couvrir son passage : ils s’aimantent aux mots. Réglages fins à l’étape Storyboard.",
145 image: {145 image: {
146- label: "Images à intégrer",146+ label: "Images et vidéos à intégrer",
147- title: "Images du beat",147+ title: "Médias du beat",
148 titleBroll: "Média du B-roll",148 titleBroll: "Média du B-roll",
149- addImages: "Ajouter des images",149+ addImages: "Ajouter des images ou des vidéos",
150- added: "Image ajoutée au beat",150+ added: "Média ajouté au beat",
151- chosen: "Aucune image choisie | 1 image choisie | {n} images choisies",151+ chosen: "Aucun média choisi | 1 média choisi | {n} médias choisis",
152 done: "Terminé",152 done: "Terminé",
153 addBroll: "Choisir le média",153 addBroll: "Choisir le média",
154 change: "Changer",154 change: "Changer",
@@ -160,18 +160,16 @@ export default {
160 video: "Un extrait ou la vidéo entière, depuis un lien",160 video: "Un extrait ou la vidéo entière, depuis un lien",
161 search: "Daryl cherche à partir de la consigne du beat",161 search: "Daryl cherche à partir de la consigne du beat",
162 },162 },
163- foot: "Une image importée est rangée dans les images du projet.",163+ foot: "Une image importée est rangée dans les images du projet, une vidéo dans ses B-roll.",
164- footBroll: "Une image importée est rangée dans les images du projet, une vidéo dans ses B-roll.",164+ noMedia: "Aucun média pour l’instant : importe une image ou une vidéo, ou fais-en chercher.",
165- noMedia: "Aucun média pour l’instant : importe une image ou fais-la chercher.",165+ drop: "Glisse une image ou une vidéo ici ou clique pour en choisir une — ou colle une capture (Ctrl+V)",
166- drop: "Glisse une image ici ou clique pour en choisir une — ou colle une capture (Ctrl+V)",
167- dropBroll: "Glisse une image ou une vidéo ici ou clique pour en choisir une — ou colle une capture (Ctrl+V)",
168 url: "Depuis une adresse",166 url: "Depuis une adresse",
169 urlPh: "https://… (lien direct vers l’image)",167 urlPh: "https://… (lien direct vers l’image)",
170 add: "Ajouter",168 add: "Ajouter",
171 importing: "Import…",169 importing: "Import…",
172- notImage: "Ce fichier n’est pas une image.",
173 notMedia: "Ce fichier n’est ni une image ni une vidéo.",170 notMedia: "Ce fichier n’est ni une image ni une vidéo.",
174 downloaded: "Vidéo téléchargée : posée sur le B-roll",171 downloaded: "Vidéo téléchargée : posée sur le B-roll",
172+ downloadedMotion: "Vidéo téléchargée : ajoutée au beat",
175 },173 },
176 },174 },
177 storyboard: {175 storyboard: {
modified web/src/project/steps/MontageStep.vue +1 -1
@@ -225,7 +225,7 @@ async function pickMedia(id: string, src: string) {
225225 if (isImage(src) && !["full", "left", "right"].includes(b.place)) b.place = "full";
226226 }, 150);
227227 }
228-/** les images de référence d'un motion */
228+/** les images et vidéos de référence d'un motion */
229229 async function setImages(id: string, srcs: string[]) {
230230 await knowMedia(srcs);
231231 SB.edit((list) => {
@@ -225,7 +225,7 @@ async function pickMedia(id: string, src: string) {
225 if (isImage(src) && !["full", "left", "right"].includes(b.place)) b.place = "full";225 if (isImage(src) && !["full", "left", "right"].includes(b.place)) b.place = "full";
226 }, 150);226 }, 150);
227 }227 }
228-/** les images de référence d'un motion */228+/** les images et vidéos de référence d'un motion */
229 async function setImages(id: string, srcs: string[]) {229 async function setImages(id: string, srcs: string[]) {
230 await knowMedia(srcs);230 await knowMedia(srcs);
231 SB.edit((list) => {231 SB.edit((list) => {
modified web/src/project/steps/montage/BeatImage.vue +11 -6
@@ -1,8 +1,8 @@
11 <script setup lang="ts">
22 /**
3- * Les images d'un beat, sur une ligne : un motion en intègre autant qu'il faut (une capture à surligner, un portrait,
4- * des logos…), en vignettes côte à côte qui défilent en largeur ; un B-roll montre son média. Le choix (médias,
5- * import, recherche par Daryl) se fait dans une popup.
3+ * Les médias d'un beat, sur une ligne : un motion intègre autant d'images et de vidéos qu'il faut (une capture à
4+ * surligner, un portrait, des logos, un extrait…), en vignettes côte à côte qui défilent en largeur ; un B-roll montre
5+ * son média. Le choix (médias, import, vidéo YouTube, recherche par Daryl) se fait dans une popup.
66 */
77 import { computed } from "vue";
88 import { useI18n } from "vue-i18n";
@@ -22,7 +22,7 @@ const emit = defineEmits<{
2222 const { t: tr } = useI18n();
2323
2424 /** la popup s'ouvre sur les médias s'il y en a à choisir, sinon sur l'import */
25-const firstTab = (): ImageTab => props.visuals.some((v) => !multi.value || v.kind === "image") ? "media" : "import";
25+const firstTab = (): ImageTab => props.visuals.length ? "media" : "import";
2626 const open = computed({ get: () => !!props.popup, set: (v: boolean) => emit("popup", v ? (props.popup ?? firstTab()) : null) });
2727 const multi = computed(() => props.beat.type === "motion");
2828 const images = computed(() => beatImages(props.beat));
@@ -53,7 +53,11 @@ function add(srcs: string[]) {
5353 <template v-if="multi">
5454 <div v-if="images.length" class="strip">
5555 <div v-for="src in images" :key="src" class="tile hatch" :title="nameOf(src)">
56- <img :src="visual(src)?.url" :alt="nameOf(src)" loading="lazy">
56+ <img v-if="visual(src)?.kind !== 'video'" :src="visual(src)?.url" :alt="nameOf(src)" loading="lazy">
57+ <template v-else>
58+ <video :src="`${visual(src)!.url}#t=1`" preload="metadata" muted playsinline />
59+ <span class="mono play">▶</span>
60+ </template>
5761 <button class="rm" :title="tr('editing.beat.image.remove')" @click="toggle(src)">×</button>
5862 </div>
5963 <button class="tile add" :title="tr('editing.beat.image.addImages')" @click="open = true">+</button>
@@ -84,7 +88,8 @@ function add(srcs: string[]) {
8488 /* les vignettes côte à côte : au-delà de la largeur du panneau, elles défilent */
8589 .strip { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 2px; scrollbar-width: thin; }
8690 .tile { position: relative; flex: 0 0 64px; height: 44px; border-radius: 3px; overflow: hidden; display: flex; align-items: center; justify-content: center; }
87-.tile img { width: 100%; height: 100%; object-fit: cover; }
91+.tile img, .tile video { width: 100%; height: 100%; object-fit: cover; }
92+.play { position: absolute; font-size: 13px; color: #fff; text-shadow: 0 1px 3px rgba(0, 0, 0, .6); pointer-events: none; }
8893 .rm {
8994 position: absolute; top: 2px; right: 2px; width: 16px; height: 16px; padding: 0; border: none; border-radius: 50%;
9095 background: rgba(24, 25, 27, .7); color: #fff; font-size: 12px; line-height: 16px; cursor: pointer; opacity: 0;
@@ -1,8 +1,8 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2 /**2 /**
3- * Les images d'un beat, sur une ligne : un motion en intègre autant qu'il faut (une capture à surligner, un portrait,3+ * Les médias d'un beat, sur une ligne : un motion intègre autant d'images et de vidéos qu'il faut (une capture à
4- * des logos…), en vignettes côte à côte qui défilent en largeur ; un B-roll montre son média. Le choix (médias,4+ * surligner, un portrait, des logos, un extrait…), en vignettes côte à côte qui défilent en largeur ; un B-roll montre
5- * import, recherche par Daryl) se fait dans une popup.5+ * son média. Le choix (médias, import, vidéo YouTube, recherche par Daryl) se fait dans une popup.
6 */6 */
7 import { computed } from "vue";7 import { computed } from "vue";
8 import { useI18n } from "vue-i18n";8 import { useI18n } from "vue-i18n";
@@ -22,7 +22,7 @@ const emit = defineEmits<{
22 const { t: tr } = useI18n();22 const { t: tr } = useI18n();
23 23
24 /** la popup s'ouvre sur les médias s'il y en a à choisir, sinon sur l'import */24 /** la popup s'ouvre sur les médias s'il y en a à choisir, sinon sur l'import */
25-const firstTab = (): ImageTab => props.visuals.some((v) => !multi.value || v.kind === "image") ? "media" : "import";25+const firstTab = (): ImageTab => props.visuals.length ? "media" : "import";
26 const open = computed({ get: () => !!props.popup, set: (v: boolean) => emit("popup", v ? (props.popup ?? firstTab()) : null) });26 const open = computed({ get: () => !!props.popup, set: (v: boolean) => emit("popup", v ? (props.popup ?? firstTab()) : null) });
27 const multi = computed(() => props.beat.type === "motion");27 const multi = computed(() => props.beat.type === "motion");
28 const images = computed(() => beatImages(props.beat));28 const images = computed(() => beatImages(props.beat));
@@ -53,7 +53,11 @@ function add(srcs: string[]) {
53 <template v-if="multi">53 <template v-if="multi">
54 <div v-if="images.length" class="strip">54 <div v-if="images.length" class="strip">
55 <div v-for="src in images" :key="src" class="tile hatch" :title="nameOf(src)">55 <div v-for="src in images" :key="src" class="tile hatch" :title="nameOf(src)">
56- <img :src="visual(src)?.url" :alt="nameOf(src)" loading="lazy">56+ <img v-if="visual(src)?.kind !== 'video'" :src="visual(src)?.url" :alt="nameOf(src)" loading="lazy">
57+ <template v-else>
58+ <video :src="`${visual(src)!.url}#t=1`" preload="metadata" muted playsinline />
59+ <span class="mono play">▶</span>
60+ </template>
57 <button class="rm" :title="tr('editing.beat.image.remove')" @click="toggle(src)">×</button>61 <button class="rm" :title="tr('editing.beat.image.remove')" @click="toggle(src)">×</button>
58 </div>62 </div>
59 <button class="tile add" :title="tr('editing.beat.image.addImages')" @click="open = true">+</button>63 <button class="tile add" :title="tr('editing.beat.image.addImages')" @click="open = true">+</button>
@@ -84,7 +88,8 @@ function add(srcs: string[]) {
84 /* les vignettes côte à côte : au-delà de la largeur du panneau, elles défilent */88 /* les vignettes côte à côte : au-delà de la largeur du panneau, elles défilent */
85 .strip { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 2px; scrollbar-width: thin; }89 .strip { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 2px; scrollbar-width: thin; }
86 .tile { position: relative; flex: 0 0 64px; height: 44px; border-radius: 3px; overflow: hidden; display: flex; align-items: center; justify-content: center; }90 .tile { position: relative; flex: 0 0 64px; height: 44px; border-radius: 3px; overflow: hidden; display: flex; align-items: center; justify-content: center; }
87-.tile img { width: 100%; height: 100%; object-fit: cover; }91+.tile img, .tile video { width: 100%; height: 100%; object-fit: cover; }
92+.play { position: absolute; font-size: 13px; color: #fff; text-shadow: 0 1px 3px rgba(0, 0, 0, .6); pointer-events: none; }
88 .rm {93 .rm {
89 position: absolute; top: 2px; right: 2px; width: 16px; height: 16px; padding: 0; border: none; border-radius: 50%;94 position: absolute; top: 2px; right: 2px; width: 16px; height: 16px; padding: 0; border: none; border-radius: 50%;
90 background: rgba(24, 25, 27, .7); color: #fff; font-size: 12px; line-height: 16px; cursor: pointer; opacity: 0;95 background: rgba(24, 25, 27, .7); color: #fff; font-size: 12px; line-height: 16px; cursor: pointer; opacity: 0;
modified web/src/project/steps/montage/BeatImageModal.vue +11 -15
@@ -3,8 +3,8 @@
33 * Choisir les images d'un beat sans passer par Médias : parmi les médias déjà là, en les important (fichier,
44 * glisser-déposer, capture collée, adresse web), ou en les faisant chercher par Daryl à partir de la consigne du beat.
55 * Une image importée est rangée dans les images du projet. Un motion en prend plusieurs (`multi` : la popup reste
6- * ouverte, chaque choix s'ajoute) ; un B-roll un seul média (le choix ferme la popup), qui peut aussi être une vidéo :
7- * un fichier importé, ou un extrait (ou la vidéo entière) d'un lien YouTube, posé dès son téléchargement fini.
6+ * ouverte, chaque choix s'ajoute) ; un B-roll un seul média (le choix ferme la popup). Les deux prennent aussi des
7+ * vidéos : un fichier importé, ou un extrait (ou la vidéo entière) d'un lien YouTube, ajouté dès son téléchargement fini.
88 */
99 import { computed, onMounted, onUnmounted, ref, watch } from "vue";
1010 import { useI18n } from "vue-i18n";
@@ -29,10 +29,8 @@ const emit = defineEmits<{
2929 }>();
3030 const { t: tr } = useI18n();
3131
32-/** un motion n'intègre qu'une image ; un B-roll montre aussi une vidéo */
33-const broll = computed(() => props.beat.type === "broll");
34-const tabs = computed<ImageTab[]>(() => broll.value ? ["media", "import", "video", "search"] : ["media", "import", "search"]);
35-const choices = computed(() => props.visuals.filter((v) => broll.value || v.kind === "image"));
32+const TABS: ImageTab[] = ["media", "import", "video", "search"];
33+const choices = computed(() => props.visuals);
3634 const tab = ref<ImageTab>(props.tab ?? (choices.value.length ? "media" : "import"));
3735 const shortName = (name: string) => name.replace(/\.[^.]+$/, "");
3836
@@ -45,9 +43,7 @@ const fileInput = ref<HTMLInputElement>();
4543 const VIDEO = /\.(mov|mp4|mxf|mkv|avi|webm|m4v|mts)$/i;
4644 async function importImage(file: File | null, url = "") {
4745 if (busy.value) return;
48- if (file && !isImage(file.name) && !(broll.value && VIDEO.test(file.name))) {
49- return toast(tr(broll.value ? "editing.beat.image.notMedia" : "editing.beat.image.notImage"), true);
50- }
46+ if (file && !isImage(file.name) && !VIDEO.test(file.name)) return toast(tr("editing.beat.image.notMedia"), true);
5147 busy.value = true;
5248 try {
5349 const { src } = await upload<{ src: string }>(`/api/projects/${store.project!.id}/assets/image`,
@@ -100,14 +96,14 @@ function click(src: string) {
10096 else emit("pick", src);
10197 }
10298
103-/** une vidéo téléchargée depuis la popup est posée sur le B-roll dès qu'elle est là (pas celles d'avant) */
99+/** une vidéo téléchargée depuis la popup est ajoutée au beat dès qu'elle est là (pas celles d'avant) */
104100 const downloads = computed(() => store.project?.runtime.assets.downloads ?? []);
105101 const before = new Set(downloads.value.map((d) => d.id));
106102 watch(downloads, (list) => {
107103 const got = list.find((d) => !before.has(d.id) && d.status === "done" && d.mode === "video" && d.src);
108104 if (!got) return;
109105 before.add(got.id);
110- toast(tr("editing.beat.image.downloaded"));
106+ toast(tr(props.multi ? "editing.beat.image.downloadedMotion" : "editing.beat.image.downloaded"));
111107 choose([got.src!]);
112108 });
113109 </script>
@@ -116,7 +112,7 @@ watch(downloads, (list) => {
116112 <Modal :title="tr(`editing.beat.image.tabs.${tab}`)" @close="emit('close')">
117113 <template #nav>
118114 <span class="nav-title">{{ beat.type === "broll" ? tr("editing.beat.image.titleBroll") : tr("editing.beat.image.title") }}</span>
119- <button v-for="k in tabs" :key="k" class="src" :class="{ on: k === tab }" @click="tab = k">
115+ <button v-for="k in TABS" :key="k" class="src" :class="{ on: k === tab }" @click="tab = k">
120116 <span class="label">{{ tr(`editing.beat.image.tabs.${k}`) }}</span>
121117 <span class="desc">{{ tr(`editing.beat.image.tabsDesc.${k}`) }}</span>
122118 </button>
@@ -125,7 +121,7 @@ watch(downloads, (list) => {
125121 <span class="mono count">{{ tr("editing.beat.image.chosen", selected.length) }}</span>
126122 <button class="btn dark md done" @click="emit('close')">{{ tr("editing.beat.image.done") }}</button>
127123 </template>
128- <span class="foot">{{ tr(broll ? "editing.beat.image.footBroll" : "editing.beat.image.foot") }}</span>
124+ <span class="foot">{{ tr("editing.beat.image.foot") }}</span>
129125 </template>
130126
131127 <template v-if="tab === 'media'">
@@ -154,9 +150,9 @@ watch(downloads, (list) => {
154150 <div v-else-if="tab === 'import'" class="import">
155151 <div class="drop-zone" :class="{ over }" @click="fileInput?.click()"
156152 @dragover.prevent="over = true" @dragleave="over = false" @drop.prevent="onDrop">
157- {{ busy ? tr("editing.beat.image.importing") : tr(broll ? "editing.beat.image.dropBroll" : "editing.beat.image.drop") }}
153+ {{ busy ? tr("editing.beat.image.importing") : tr("editing.beat.image.drop") }}
158154 </div>
159- <input ref="fileInput" type="file" :accept="broll ? 'image/*,video/*' : 'image/*'" hidden @change="onPick">
155+ <input ref="fileInput" type="file" accept="image/*,video/*" hidden @change="onPick">
160156 <label class="f">
161157 {{ tr("editing.beat.image.url") }}
162158 <span class="row">
@@ -3,8 +3,8 @@
3 * Choisir les images d'un beat sans passer par Médias : parmi les médias déjà là, en les important (fichier,3 * Choisir les images d'un beat sans passer par Médias : parmi les médias déjà là, en les important (fichier,
4 * glisser-déposer, capture collée, adresse web), ou en les faisant chercher par Daryl à partir de la consigne du beat.4 * glisser-déposer, capture collée, adresse web), ou en les faisant chercher par Daryl à partir de la consigne du beat.
5 * Une image importée est rangée dans les images du projet. Un motion en prend plusieurs (`multi` : la popup reste5 * Une image importée est rangée dans les images du projet. Un motion en prend plusieurs (`multi` : la popup reste
6- * ouverte, chaque choix s'ajoute) ; un B-roll un seul média (le choix ferme la popup), qui peut aussi être une vidéo :6+ * ouverte, chaque choix s'ajoute) ; un B-roll un seul média (le choix ferme la popup). Les deux prennent aussi des
7- * un fichier importé, ou un extrait (ou la vidéo entière) d'un lien YouTube, posé dès son téléchargement fini.7+ * vidéos : un fichier importé, ou un extrait (ou la vidéo entière) d'un lien YouTube, ajouté dès son téléchargement fini.
8 */8 */
9 import { computed, onMounted, onUnmounted, ref, watch } from "vue";9 import { computed, onMounted, onUnmounted, ref, watch } from "vue";
10 import { useI18n } from "vue-i18n";10 import { useI18n } from "vue-i18n";
@@ -29,10 +29,8 @@ const emit = defineEmits<{
29 }>();29 }>();
30 const { t: tr } = useI18n();30 const { t: tr } = useI18n();
31 31
32-/** un motion n'intègre qu'une image ; un B-roll montre aussi une vidéo */32+const TABS: ImageTab[] = ["media", "import", "video", "search"];
33-const broll = computed(() => props.beat.type === "broll");33+const choices = computed(() => props.visuals);
34-const tabs = computed<ImageTab[]>(() => broll.value ? ["media", "import", "video", "search"] : ["media", "import", "search"]);
35-const choices = computed(() => props.visuals.filter((v) => broll.value || v.kind === "image"));
36 const tab = ref<ImageTab>(props.tab ?? (choices.value.length ? "media" : "import"));34 const tab = ref<ImageTab>(props.tab ?? (choices.value.length ? "media" : "import"));
37 const shortName = (name: string) => name.replace(/\.[^.]+$/, "");35 const shortName = (name: string) => name.replace(/\.[^.]+$/, "");
38 36
@@ -45,9 +43,7 @@ const fileInput = ref<HTMLInputElement>();
45 const VIDEO = /\.(mov|mp4|mxf|mkv|avi|webm|m4v|mts)$/i;43 const VIDEO = /\.(mov|mp4|mxf|mkv|avi|webm|m4v|mts)$/i;
46 async function importImage(file: File | null, url = "") {44 async function importImage(file: File | null, url = "") {
47 if (busy.value) return;45 if (busy.value) return;
48- if (file && !isImage(file.name) && !(broll.value && VIDEO.test(file.name))) {46+ if (file && !isImage(file.name) && !VIDEO.test(file.name)) return toast(tr("editing.beat.image.notMedia"), true);
49- return toast(tr(broll.value ? "editing.beat.image.notMedia" : "editing.beat.image.notImage"), true);
50- }
51 busy.value = true;47 busy.value = true;
52 try {48 try {
53 const { src } = await upload<{ src: string }>(`/api/projects/${store.project!.id}/assets/image`,49 const { src } = await upload<{ src: string }>(`/api/projects/${store.project!.id}/assets/image`,
@@ -100,14 +96,14 @@ function click(src: string) {
100 else emit("pick", src);96 else emit("pick", src);
101 }97 }
102 98
103-/** une vidéo téléchargée depuis la popup est posée sur le B-roll dès qu'elle est là (pas celles d'avant) */99+/** une vidéo téléchargée depuis la popup est ajoutée au beat dès qu'elle est là (pas celles d'avant) */
104 const downloads = computed(() => store.project?.runtime.assets.downloads ?? []);100 const downloads = computed(() => store.project?.runtime.assets.downloads ?? []);
105 const before = new Set(downloads.value.map((d) => d.id));101 const before = new Set(downloads.value.map((d) => d.id));
106 watch(downloads, (list) => {102 watch(downloads, (list) => {
107 const got = list.find((d) => !before.has(d.id) && d.status === "done" && d.mode === "video" && d.src);103 const got = list.find((d) => !before.has(d.id) && d.status === "done" && d.mode === "video" && d.src);
108 if (!got) return;104 if (!got) return;
109 before.add(got.id);105 before.add(got.id);
110- toast(tr("editing.beat.image.downloaded"));106+ toast(tr(props.multi ? "editing.beat.image.downloadedMotion" : "editing.beat.image.downloaded"));
111 choose([got.src!]);107 choose([got.src!]);
112 });108 });
113 </script>109 </script>
@@ -116,7 +112,7 @@ watch(downloads, (list) => {
116 <Modal :title="tr(`editing.beat.image.tabs.${tab}`)" @close="emit('close')">112 <Modal :title="tr(`editing.beat.image.tabs.${tab}`)" @close="emit('close')">
117 <template #nav>113 <template #nav>
118 <span class="nav-title">{{ beat.type === "broll" ? tr("editing.beat.image.titleBroll") : tr("editing.beat.image.title") }}</span>114 <span class="nav-title">{{ beat.type === "broll" ? tr("editing.beat.image.titleBroll") : tr("editing.beat.image.title") }}</span>
119- <button v-for="k in tabs" :key="k" class="src" :class="{ on: k === tab }" @click="tab = k">115+ <button v-for="k in TABS" :key="k" class="src" :class="{ on: k === tab }" @click="tab = k">
120 <span class="label">{{ tr(`editing.beat.image.tabs.${k}`) }}</span>116 <span class="label">{{ tr(`editing.beat.image.tabs.${k}`) }}</span>
121 <span class="desc">{{ tr(`editing.beat.image.tabsDesc.${k}`) }}</span>117 <span class="desc">{{ tr(`editing.beat.image.tabsDesc.${k}`) }}</span>
122 </button>118 </button>
@@ -125,7 +121,7 @@ watch(downloads, (list) => {
125 <span class="mono count">{{ tr("editing.beat.image.chosen", selected.length) }}</span>121 <span class="mono count">{{ tr("editing.beat.image.chosen", selected.length) }}</span>
126 <button class="btn dark md done" @click="emit('close')">{{ tr("editing.beat.image.done") }}</button>122 <button class="btn dark md done" @click="emit('close')">{{ tr("editing.beat.image.done") }}</button>
127 </template>123 </template>
128- <span class="foot">{{ tr(broll ? "editing.beat.image.footBroll" : "editing.beat.image.foot") }}</span>124+ <span class="foot">{{ tr("editing.beat.image.foot") }}</span>
129 </template>125 </template>
130 126
131 <template v-if="tab === 'media'">127 <template v-if="tab === 'media'">
@@ -154,9 +150,9 @@ watch(downloads, (list) => {
154 <div v-else-if="tab === 'import'" class="import">150 <div v-else-if="tab === 'import'" class="import">
155 <div class="drop-zone" :class="{ over }" @click="fileInput?.click()"151 <div class="drop-zone" :class="{ over }" @click="fileInput?.click()"
156 @dragover.prevent="over = true" @dragleave="over = false" @drop.prevent="onDrop">152 @dragover.prevent="over = true" @dragleave="over = false" @drop.prevent="onDrop">
157- {{ busy ? tr("editing.beat.image.importing") : tr(broll ? "editing.beat.image.dropBroll" : "editing.beat.image.drop") }}153+ {{ busy ? tr("editing.beat.image.importing") : tr("editing.beat.image.drop") }}
158 </div>154 </div>
159- <input ref="fileInput" type="file" :accept="broll ? 'image/*,video/*' : 'image/*'" hidden @change="onPick">155+ <input ref="fileInput" type="file" accept="image/*,video/*" hidden @change="onPick">
160 <label class="f">156 <label class="f">
161 {{ tr("editing.beat.image.url") }}157 {{ tr("editing.beat.image.url") }}
162 <span class="row">158 <span class="row">
modified web/src/project/steps/montage/types.ts +3 -3
@@ -90,17 +90,17 @@ export interface Beat {
9090 zoom?: { scale: number; x: number; y: number };
9191 /** broll : le média (vide : à trouver), la recherche toute prête, et pour une vidéo la seconde où le plan commence */
9292 src?: string; search?: string; start?: number;
93- /** motion : les images à intégrer à l'animation (facultatives) */
93+ /** motion : les images et vidéos à intégrer à l'animation (facultatives) */
9494 images?: string[];
9595 /** motion et broll : les transitions d'entrée et de sortie au montage */
9696 enter?: Fx | null; exit?: Fx | null;
9797 }
9898 const IMAGE = /\.(png|jpe?g|webp|gif|svg)$/i;
9999 export const isImage = (src?: string) => !!src && IMAGE.test(src);
100-/** Les images d'un motion (`src` : l'ancienne forme, une seule image). */
100+/** Les images et vidéos d'un motion (`src` : l'ancienne forme, une seule image). */
101101 export const beatImages = (b: Beat): string[] => b.images ?? (b.type === "motion" && isImage(b.src) ? [b.src!] : []);
102102 /** L'image montrée sur la slide d'un beat : celle d'un B-roll, la première d'un motion. */
103-export const slideImage = (b: Beat): string | undefined => b.type === "motion" ? beatImages(b)[0] : isImage(b.src) ? b.src : undefined;
103+export const slideImage = (b: Beat): string | undefined => b.type === "motion" ? beatImages(b).find(isImage) : isImage(b.src) ? b.src : undefined;
104104 /** Le beat passe par une animation écrite par Daryl ; sinon (zoom, B-roll vidéo) il est posé tel quel. */
105105 export const needsAnim = (b: Beat) => b.type === "motion" || b.type === "transition" || (b.type === "broll" && isImage(b.src));
106106 /** La couleur de chaque type de beat (bande de rythme, cartes, timeline). */
@@ -90,17 +90,17 @@ export interface Beat {
90 zoom?: { scale: number; x: number; y: number };90 zoom?: { scale: number; x: number; y: number };
91 /** broll : le média (vide : à trouver), la recherche toute prête, et pour une vidéo la seconde où le plan commence */91 /** broll : le média (vide : à trouver), la recherche toute prête, et pour une vidéo la seconde où le plan commence */
92 src?: string; search?: string; start?: number;92 src?: string; search?: string; start?: number;
93- /** motion : les images à intégrer à l'animation (facultatives) */93+ /** motion : les images et vidéos à intégrer à l'animation (facultatives) */
94 images?: string[];94 images?: string[];
95 /** motion et broll : les transitions d'entrée et de sortie au montage */95 /** motion et broll : les transitions d'entrée et de sortie au montage */
96 enter?: Fx | null; exit?: Fx | null;96 enter?: Fx | null; exit?: Fx | null;
97 }97 }
98 const IMAGE = /\.(png|jpe?g|webp|gif|svg)$/i;98 const IMAGE = /\.(png|jpe?g|webp|gif|svg)$/i;
99 export const isImage = (src?: string) => !!src && IMAGE.test(src);99 export const isImage = (src?: string) => !!src && IMAGE.test(src);
100-/** Les images d'un motion (`src` : l'ancienne forme, une seule image). */100+/** Les images et vidéos d'un motion (`src` : l'ancienne forme, une seule image). */
101 export const beatImages = (b: Beat): string[] => b.images ?? (b.type === "motion" && isImage(b.src) ? [b.src!] : []);101 export const beatImages = (b: Beat): string[] => b.images ?? (b.type === "motion" && isImage(b.src) ? [b.src!] : []);
102 /** L'image montrée sur la slide d'un beat : celle d'un B-roll, la première d'un motion. */102 /** L'image montrée sur la slide d'un beat : celle d'un B-roll, la première d'un motion. */
103-export const slideImage = (b: Beat): string | undefined => b.type === "motion" ? beatImages(b)[0] : isImage(b.src) ? b.src : undefined;103+export const slideImage = (b: Beat): string | undefined => b.type === "motion" ? beatImages(b).find(isImage) : isImage(b.src) ? b.src : undefined;
104 /** Le beat passe par une animation écrite par Daryl ; sinon (zoom, B-roll vidéo) il est posé tel quel. */104 /** Le beat passe par une animation écrite par Daryl ; sinon (zoom, B-roll vidéo) il est posé tel quel. */
105 export const needsAnim = (b: Beat) => b.type === "motion" || b.type === "transition" || (b.type === "broll" && isImage(b.src));105 export const needsAnim = (b: Beat) => b.type === "motion" || b.type === "transition" || (b.type === "broll" && isImage(b.src));
106 /** La couleur de chaque type de beat (bande de rythme, cartes, timeline). */106 /** La couleur de chaque type de beat (bande de rythme, cartes, timeline). */