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.
764ddcf parent: 18b8800 modified
backend/studio/modules/projects/storyboard.py +56 -25 | @@ -27,18 +27,20 @@ Fichier : montage/storyboard.json | ||
| 27 | 27 | "motion": {"enter", "hold", "exit"}, "sfx", "ref", "why", "on", |
| 28 | 28 | "zoom": {"scale", "x", "y"}, # zoom |
| 29 | 29 | "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 | 31 | `at` / `dur` en secondes de la timeline. Les contrôles (`check`) sont refaits à chaque lecture : |
| 32 | 32 | le montage de l'A-roll peut avoir bougé depuis la génération. |
| 33 | 33 | """ |
| 34 | 34 | import json |
| 35 | 35 | import re |
| 36 | 36 | import shutil |
| 37 | +import subprocess | |
| 37 | 38 | import time |
| 38 | 39 | from pathlib import Path |
| 39 | 40 | from urllib.parse import quote, unquote |
| 40 | 41 | |
| 41 | 42 | from ...core import jobs |
| 43 | +from ...core.system import tool | |
| 42 | 44 | from ...core.tasks import TaskContext |
| 43 | 45 | from ..settings.service import language_name |
| 44 | 46 | from ..transcript import cues |
| @@ -927,8 +929,8 @@ def retype(b: dict, kind) -> None: | ||
| 927 | 929 | """Change le type d'un beat, avec ce qu'il faut au nouveau type (sa durée ne bouge pas).""" |
| 928 | 930 | if kind not in TYPES or kind == b.get("type"): |
| 929 | 931 | 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 [] | |
| 932 | 934 | b["type"] = kind |
| 933 | 935 | # ce qui ne sert qu'à l'ancien type s'en va |
| 934 | 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 | 987 | if b["type"] == "motion" and isinstance(c.get("images"), list): |
| 986 | 988 | b.pop("src", None) |
| 987 | 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 | 991 | if images: |
| 990 | 992 | b["images"] = images |
| 991 | 993 | else: |
| @@ -1111,13 +1113,13 @@ def animated(pid: str, timeline: dict) -> dict[str, str]: | ||
| 1111 | 1113 | |
| 1112 | 1114 | |
| 1113 | 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 | 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]: | |
| 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)] | |
| 1121 | 1123 | if media_kind(beat.get("src", "")) == "image" and beat["src"] not in images: |
| 1122 | 1124 | images.insert(0, beat["src"]) |
| 1123 | 1125 | return images |
| @@ -1175,9 +1177,9 @@ def compose(pid: str, only: list[str] | None = None) -> dict: | ||
| 1175 | 1177 | if not b.get("on") or (only is not None and b["id"] not in only) or b["id"] in written: |
| 1176 | 1178 | continue |
| 1177 | 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 | 1181 | if b.get("type") == "motion": |
| 1180 | - for src in motion_images(b): | |
| 1182 | + for src in motion_media(b): | |
| 1181 | 1183 | copy_image(pid, b, src) |
| 1182 | 1184 | out["motions"].append(done[b["id"]]) |
| 1183 | 1185 | continue |
| @@ -1213,8 +1215,8 @@ def compose(pid: str, only: list[str] | None = None) -> dict: | ||
| 1213 | 1215 | out["missing"].append(b["id"]) |
| 1214 | 1216 | continue |
| 1215 | 1217 | 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): | |
| 1218 | 1220 | copy_image(pid, b, src) |
| 1219 | 1221 | mid = next_id("motion") |
| 1220 | 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 | 1274 | return lines |
| 1273 | 1275 | |
| 1274 | 1276 | |
| 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()] | |
| 1279 | 1294 | if not files: |
| 1280 | 1295 | 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) | |
| 1286 | 1303 | w, h = info.get("width"), info.get("height") |
| 1287 | 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}") | |
| 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.") | |
| 1291 | 1322 | return lines |
| 1292 | 1323 | |
| 1293 | 1324 | |
| @@ -1318,7 +1349,7 @@ def brief(pid: str, ids: list[str]) -> str: | ||
| 1318 | 1349 | lines.append(f"- {FULL_SCREEN}") |
| 1319 | 1350 | if b.get("anim"): |
| 1320 | 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 | 1353 | if b.get("enter") or b.get("exit"): |
| 1323 | 1354 | lines.append("- Le montage ajoute " + " et ".join( |
| 1324 | 1355 | 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"}, # zoom | 28 | "zoom": {"scale", "x", "y"}, # zoom |
| 29 | "src", "search", # broll : le média (vide : à trouver), la recherche | 29 | "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 json | 34 | import json |
| 35 | import re | 35 | import re |
| 36 | import shutil | 36 | import shutil |
| 37 | +import subprocess | ||
| 37 | import time | 38 | import time |
| 38 | from pathlib import Path | 39 | from pathlib import Path |
| 39 | from urllib.parse import quote, unquote | 40 | from urllib.parse import quote, unquote |
| 40 | 41 | ||
| 41 | from ...core import jobs | 42 | from ...core import jobs |
| 43 | +from ...core.system import tool | ||
| 42 | from ...core.tasks import TaskContext | 44 | from ...core.tasks import TaskContext |
| 43 | from ..settings.service import language_name | 45 | from ..settings.service import language_name |
| 44 | from ..transcript import cues | 46 | 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 | return | 931 | 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 | 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 |
| 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"] = kind | 934 | b["type"] = kind |
| 933 | # ce qui ne sert qu'à l'ancien type s'en va | 935 | # 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"] = images | 992 | 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 images | 1125 | 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 | continue | 1178 | 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 | continue | 1185 | 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 | continue | 1216 | 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 lines | 1274 | 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 lines | 1322 | 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: | ||
| 152 | 152 | elif kind == "transition": |
| 153 | 153 | what = "transition" |
| 154 | 154 | else: |
| 155 | - images = storyboard.motion_images(b) | |
| 155 | + images = storyboard.motion_media(b) | |
| 156 | 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 | 158 | ends = "".join(f" · {k} {b[k]['fx']}" for k in ("enter", "exit") if b.get(k)) |
| 159 | 159 | return (f"- {b['id']}{state} [{storyboard._clock(b['at'])} → {storyboard._clock(b['at'] + b['dur'])}] " |
| 160 | 160 | 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 { | ||
| 143 | 143 | askText: "About beat {id} ({tc}): ", |
| 144 | 144 | hint: "Drag its edges on the timeline to cover its passage: they snap to words. Fine settings in the Storyboard step.", |
| 145 | 145 | image: { |
| 146 | - label: "Images to include", | |
| 147 | - title: "Beat images", | |
| 146 | + label: "Images and videos to include", | |
| 147 | + title: "Beat media", | |
| 148 | 148 | 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", | |
| 152 | 152 | done: "Done", |
| 153 | 153 | addBroll: "Choose the media", |
| 154 | 154 | change: "Change", |
| @@ -160,18 +160,16 @@ export default { | ||
| 160 | 160 | video: "A clip or the whole video, from a link", |
| 161 | 161 | search: "Daryl searches from the beat’s brief", |
| 162 | 162 | }, |
| 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)", | |
| 168 | 166 | url: "From a URL", |
| 169 | 167 | urlPh: "https://… (direct link to the image)", |
| 170 | 168 | add: "Add", |
| 171 | 169 | importing: "Importing…", |
| 172 | - notImage: "This file is not an image.", | |
| 173 | 170 | notMedia: "This file is neither an image nor a video.", |
| 174 | 171 | downloaded: "Video downloaded: set on the B-roll", |
| 172 | + downloadedMotion: "Video downloaded: added to the beat", | |
| 175 | 173 | }, |
| 176 | 174 | }, |
| 177 | 175 | 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 { | ||
| 143 | 143 | askText: "À propos du beat {id} ({tc}) : ", |
| 144 | 144 | hint: "Tire ses bords sur la timeline pour couvrir son passage : ils s’aimantent aux mots. Réglages fins à l’étape Storyboard.", |
| 145 | 145 | 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", | |
| 148 | 148 | 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", | |
| 152 | 152 | done: "Terminé", |
| 153 | 153 | addBroll: "Choisir le média", |
| 154 | 154 | change: "Changer", |
| @@ -160,18 +160,16 @@ export default { | ||
| 160 | 160 | video: "Un extrait ou la vidéo entière, depuis un lien", |
| 161 | 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.", | |
| 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)", | |
| 168 | 166 | url: "Depuis une adresse", |
| 169 | 167 | urlPh: "https://… (lien direct vers l’image)", |
| 170 | 168 | add: "Ajouter", |
| 171 | 169 | importing: "Import…", |
| 172 | - notImage: "Ce fichier n’est pas une image.", | |
| 173 | 170 | notMedia: "Ce fichier n’est ni une image ni une vidéo.", |
| 174 | 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 | 175 | 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) { | ||
| 225 | 225 | if (isImage(src) && !["full", "left", "right"].includes(b.place)) b.place = "full"; |
| 226 | 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 | 229 | async function setImages(id: string, srcs: string[]) { |
| 230 | 230 | await knowMedia(srcs); |
| 231 | 231 | 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 @@ | ||
| 1 | 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, | |
| 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. | |
| 6 | 6 | */ |
| 7 | 7 | import { computed } from "vue"; |
| 8 | 8 | import { useI18n } from "vue-i18n"; |
| @@ -22,7 +22,7 @@ const emit = defineEmits<{ | ||
| 22 | 22 | const { t: tr } = useI18n(); |
| 23 | 23 | |
| 24 | 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 | 26 | const open = computed({ get: () => !!props.popup, set: (v: boolean) => emit("popup", v ? (props.popup ?? firstTab()) : null) }); |
| 27 | 27 | const multi = computed(() => props.beat.type === "motion"); |
| 28 | 28 | const images = computed(() => beatImages(props.beat)); |
| @@ -53,7 +53,11 @@ function add(srcs: string[]) { | ||
| 53 | 53 | <template v-if="multi"> |
| 54 | 54 | <div v-if="images.length" class="strip"> |
| 55 | 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 | 61 | <button class="rm" :title="tr('editing.beat.image.remove')" @click="toggle(src)">×</button> |
| 58 | 62 | </div> |
| 59 | 63 | <button class="tile add" :title="tr('editing.beat.image.addImages')" @click="open = true">+</button> |
| @@ -84,7 +88,8 @@ function add(srcs: string[]) { | ||
| 84 | 88 | /* les vignettes côte à côte : au-delà de la largeur du panneau, elles défilent */ |
| 85 | 89 | .strip { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 2px; scrollbar-width: thin; } |
| 86 | 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 | 93 | .rm { |
| 89 | 94 | position: absolute; top: 2px; right: 2px; width: 16px; height: 16px; padding: 0; border: none; border-radius: 50%; |
| 90 | 95 | 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 @@ | ||
| 3 | 3 | * Choisir les images d'un beat sans passer par Médias : parmi les médias déjà là, en les important (fichier, |
| 4 | 4 | * glisser-déposer, capture collée, adresse web), ou en les faisant chercher par Daryl à partir de la consigne du beat. |
| 5 | 5 | * 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. | |
| 8 | 8 | */ |
| 9 | 9 | import { computed, onMounted, onUnmounted, ref, watch } from "vue"; |
| 10 | 10 | import { useI18n } from "vue-i18n"; |
| @@ -29,10 +29,8 @@ const emit = defineEmits<{ | ||
| 29 | 29 | }>(); |
| 30 | 30 | const { t: tr } = useI18n(); |
| 31 | 31 | |
| 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); | |
| 36 | 34 | const tab = ref<ImageTab>(props.tab ?? (choices.value.length ? "media" : "import")); |
| 37 | 35 | const shortName = (name: string) => name.replace(/\.[^.]+$/, ""); |
| 38 | 36 | |
| @@ -45,9 +43,7 @@ const fileInput = ref<HTMLInputElement>(); | ||
| 45 | 43 | const VIDEO = /\.(mov|mp4|mxf|mkv|avi|webm|m4v|mts)$/i; |
| 46 | 44 | async function importImage(file: File | null, url = "") { |
| 47 | 45 | 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); | |
| 51 | 47 | busy.value = true; |
| 52 | 48 | try { |
| 53 | 49 | const { src } = await upload<{ src: string }>(`/api/projects/${store.project!.id}/assets/image`, |
| @@ -100,14 +96,14 @@ function click(src: string) { | ||
| 100 | 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 | 100 | const downloads = computed(() => store.project?.runtime.assets.downloads ?? []); |
| 105 | 101 | const before = new Set(downloads.value.map((d) => d.id)); |
| 106 | 102 | watch(downloads, (list) => { |
| 107 | 103 | const got = list.find((d) => !before.has(d.id) && d.status === "done" && d.mode === "video" && d.src); |
| 108 | 104 | if (!got) return; |
| 109 | 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 | 107 | choose([got.src!]); |
| 112 | 108 | }); |
| 113 | 109 | </script> |
| @@ -116,7 +112,7 @@ watch(downloads, (list) => { | ||
| 116 | 112 | <Modal :title="tr(`editing.beat.image.tabs.${tab}`)" @close="emit('close')"> |
| 117 | 113 | <template #nav> |
| 118 | 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 | 116 | <span class="label">{{ tr(`editing.beat.image.tabs.${k}`) }}</span> |
| 121 | 117 | <span class="desc">{{ tr(`editing.beat.image.tabsDesc.${k}`) }}</span> |
| 122 | 118 | </button> |
| @@ -125,7 +121,7 @@ watch(downloads, (list) => { | ||
| 125 | 121 | <span class="mono count">{{ tr("editing.beat.image.chosen", selected.length) }}</span> |
| 126 | 122 | <button class="btn dark md done" @click="emit('close')">{{ tr("editing.beat.image.done") }}</button> |
| 127 | 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 | 125 | </template> |
| 130 | 126 | |
| 131 | 127 | <template v-if="tab === 'media'"> |
| @@ -154,9 +150,9 @@ watch(downloads, (list) => { | ||
| 154 | 150 | <div v-else-if="tab === 'import'" class="import"> |
| 155 | 151 | <div class="drop-zone" :class="{ over }" @click="fileInput?.click()" |
| 156 | 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 | 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 | 156 | <label class="f"> |
| 161 | 157 | {{ tr("editing.beat.image.url") }} |
| 162 | 158 | <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 reste | 5 | * 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 { | ||
| 90 | 90 | zoom?: { scale: number; x: number; y: number }; |
| 91 | 91 | /** broll : le média (vide : à trouver), la recherche toute prête, et pour une vidéo la seconde où le plan commence */ |
| 92 | 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 | 94 | images?: string[]; |
| 95 | 95 | /** motion et broll : les transitions d'entrée et de sortie au montage */ |
| 96 | 96 | enter?: Fx | null; exit?: Fx | null; |
| 97 | 97 | } |
| 98 | 98 | const IMAGE = /\.(png|jpe?g|webp|gif|svg)$/i; |
| 99 | 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 | 101 | export const beatImages = (b: Beat): string[] => b.images ?? (b.type === "motion" && isImage(b.src) ? [b.src!] : []); |
| 102 | 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 | 104 | /** Le beat passe par une animation écrite par Daryl ; sinon (zoom, B-roll vidéo) il est posé tel quel. */ |
| 105 | 105 | export const needsAnim = (b: Beat) => b.type === "motion" || b.type === "transition" || (b.type === "broll" && isImage(b.src)); |
| 106 | 106 | /** 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). */ |