hlassiege/darylpublic Fork
⑂ a675e4e
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): pick a beat's images from the editing step

The beat panel gets a media popup: choose among the project and library
media, import a file, a pasted screenshot or a URL, or have Daryl search
the web from the beat's brief. A motion takes several reference images,
copied next to its composition and described in its brief; a B-roll
takes one media. Image searches run per beat in a queue next to the chat
and survive closing the popup.
hlassiege committed 2026-10-22T16:23:08+02:00 Browse files
a675e4e parent: cc6717a
modified backend/studio/modules/projects/api.py +78 -13
@@ -2,11 +2,12 @@
22 API des projets : /api/projects/…
33 """
44 import base64
5+import re
56 import shutil
67 import time
78 import uuid
89 from pathlib import Path
9-from urllib.parse import quote
10+from urllib.parse import quote, unquote, urlparse
1011
1112 from fastapi import APIRouter, File, Form, HTTPException, UploadFile
1213 from fastapi.responses import FileResponse, Response
@@ -15,7 +16,7 @@ from pydantic import BaseModel
1516 from ...core import jobs, system
1617 from ..animations import kit
1718 from ..youtube import service as youtube
18-from . import (assets, autoderush, derush, kit_adapt, library, migrate, montage, publish, render, roast, runtime, script,
19+from . import (asset_tasks, assets, autoderush, derush, kit_adapt, library, migrate, montage, publish, render, roast, runtime, script,
1920 service, storyboard, storyboard_chat, thumbs, timeline, transitions)
2021 from . import beat_anim
2122
@@ -1212,10 +1213,17 @@ def remove_download(pid: str, job_id: str):
12121213
12131214 # --- Assets : images -------------------------------------------------------------------
12141215
1216+def _beat(beat: str) -> str:
1217+ """L'id d'un beat dont on cherche les images ("" : la recherche de Médias)."""
1218+ if beat and not re.fullmatch(r"[\w-]{1,40}", beat):
1219+ raise HTTPException(400, "beat inconnu")
1220+ return beat
1221+
1222+
12151223 @router.get("/{pid}/assets/images")
1216-def get_images(pid: str):
1224+def get_images(pid: str, beat: str = ""):
12171225 _check(pid)
1218- return assets.images_state(pid)
1226+ return assets.images_state(pid, _beat(beat))
12191227
12201228
12211229 class ImagesRequest(BaseModel):
@@ -1223,21 +1231,54 @@ class ImagesRequest(BaseModel):
12231231
12241232
12251233 @router.post("/{pid}/assets/images")
1226-def search_images(pid: str, req: ImagesRequest):
1234+def search_images(pid: str, req: ImagesRequest, beat: str = ""):
12271235 _check(pid)
1236+ beat = _beat(beat)
12281237 if not req.text.strip():
12291238 raise HTTPException(400, "demande vide")
1230- job = jobs.get(assets.images_job_id(pid))
1239+ job = jobs.get(assets.images_job_id(pid, beat))
12311240 state = job.state if job else {"messages": [], "results": []}
12321241 if job and job.status == "error" and state["messages"] and state["messages"][-1]["role"] == "user":
12331242 # la demande restée sans réponse est remplacée par la nouvelle
12341243 state["messages"].pop()
12351244 state["messages"].append({"role": "user", "text": req.text.strip()})
1245+ # une nouvelle demande revient dans la file du montage
1246+ state.pop("dismissed", None)
12361247 try:
1237- service.requeue(assets.images_job_id(pid), "project_images", _claude_payload(pid), state)
1248+ service.requeue(assets.images_job_id(pid, beat), "project_images", _claude_payload(pid, beat=beat), state)
12381249 except ValueError as e:
12391250 raise HTTPException(409, str(e)) from e
1240- return assets.images_state(pid)
1251+ return assets.images_state(pid, beat)
1252+
1253+
1254+@router.post("/{pid}/assets/images/stop")
1255+def stop_images(pid: str, beat: str = ""):
1256+ _check(pid)
1257+ beat = _beat(beat)
1258+ job = jobs.get(assets.images_job_id(pid, beat))
1259+ if job and not job.finished:
1260+ jobs.cancel(job)
1261+ return assets.images_state(pid, beat)
1262+
1263+
1264+@router.post("/{pid}/assets/images/dismiss")
1265+def dismiss_images(pid: str, beat: str):
1266+ """La recherche d'un beat quitte la file du montage."""
1267+ _check(pid)
1268+ assets.dismiss_search(pid, _beat(beat))
1269+ return {"ok": True}
1270+
1271+
1272+@router.delete("/{pid}/assets/images")
1273+def reset_images(pid: str, beat: str = ""):
1274+ """Repart d'une recherche d'images vierge."""
1275+ _check(pid)
1276+ beat = _beat(beat)
1277+ try:
1278+ assets.reset_images(pid, beat)
1279+ except ValueError as e:
1280+ raise HTTPException(409, str(e)) from e
1281+ return assets.images_state(pid, beat)
12411282
12421283
12431284 class AddImagesRequest(BaseModel):
@@ -1247,19 +1288,43 @@ class AddImagesRequest(BaseModel):
12471288
12481289
12491290 @router.post("/{pid}/assets/images/add")
1250-def add_images(pid: str, req: AddImagesRequest):
1291+def add_images(pid: str, req: AddImagesRequest, beat: str = ""):
12511292 _check(pid)
1293+ beat = _beat(beat)
12521294 try:
1253- count = assets.add_images(pid, req.ids, req.common, req.category, service.load(pid)["title"])
1295+ srcs = assets.add_images(pid, req.ids, req.common, req.category, service.load(pid)["title"], beat)
12541296 except ValueError as e:
12551297 raise HTTPException(409, str(e)) from e
1256- return {"added": count, **assets.images_state(pid)}
1298+ return {"added": len(srcs), "srcs": srcs, **assets.images_state(pid, beat)}
1299+
1300+
1301+@router.post("/{pid}/assets/image")
1302+def import_image(pid: str, file: UploadFile | None = File(None), url: str = Form("")):
1303+ """Une image ajoutée au projet d'un geste (fichier envoyé, ou téléchargée depuis son adresse), rangée dans ses
1304+ images ; rend son `src` (comme la timeline), pour l'attacher tout de suite à un beat."""
1305+ _check(pid)
1306+ folder = service.project_dir(pid) / service.ASSETS_DIR / "images"
1307+ if file is not None:
1308+ if storyboard.media_kind(file.filename or "") != "image":
1309+ raise HTTPException(400, "pas une image (png, jpg, webp, gif, svg…)")
1310+ dest = _save_upload(file, folder)
1311+ elif url.strip():
1312+ try:
1313+ got = asset_tasks.fetch_image(url.strip(), folder / f".import-{uuid.uuid4().hex[:10]}")
1314+ except Exception as e: # noqa: BLE001 — lien mort, pas une image, trop lourde…
1315+ raise HTTPException(400, f"image non récupérée : {e}") from e
1316+ # nommée d'après son adresse, l'extension d'après son type
1317+ name = service.safe_name(unquote(Path(urlparse(url.strip()).path).stem)[:80], "image")
1318+ dest = got.rename(service.unique_path(folder, name + got.suffix))
1319+ else:
1320+ raise HTTPException(400, "ni fichier ni adresse")
1321+ return {"src": dest.relative_to(service.project_dir(pid)).as_posix()}
12571322
12581323
12591324 @router.get("/{pid}/assets/candidates/{name}")
1260-def candidate(pid: str, name: str):
1325+def candidate(pid: str, name: str, beat: str = ""):
12611326 _check(pid)
1262- path = assets.candidates_dir(pid) / service.safe_name(name)
1327+ path = assets.candidates_dir(pid, _beat(beat)) / service.safe_name(name)
12631328 if not path.is_file():
12641329 raise HTTPException(404, "image inconnue")
12651330 return FileResponse(path)
@@ -2,11 +2,12 @@
2 API des projets : /api/projects/…2 API des projets : /api/projects/…
3 """3 """
4 import base644 import base64
5+import re
5 import shutil6 import shutil
6 import time7 import time
7 import uuid8 import uuid
8 from pathlib import Path9 from pathlib import Path
9-from urllib.parse import quote10+from urllib.parse import quote, unquote, urlparse
10 11
11 from fastapi import APIRouter, File, Form, HTTPException, UploadFile12 from fastapi import APIRouter, File, Form, HTTPException, UploadFile
12 from fastapi.responses import FileResponse, Response13 from fastapi.responses import FileResponse, Response
@@ -15,7 +16,7 @@ from pydantic import BaseModel
15 from ...core import jobs, system16 from ...core import jobs, system
16 from ..animations import kit17 from ..animations import kit
17 from ..youtube import service as youtube18 from ..youtube import service as youtube
18-from . import (assets, autoderush, derush, kit_adapt, library, migrate, montage, publish, render, roast, runtime, script,19+from . import (asset_tasks, assets, autoderush, derush, kit_adapt, library, migrate, montage, publish, render, roast, runtime, script,
19 service, storyboard, storyboard_chat, thumbs, timeline, transitions)20 service, storyboard, storyboard_chat, thumbs, timeline, transitions)
20 from . import beat_anim21 from . import beat_anim
21 22
@@ -1212,10 +1213,17 @@ def remove_download(pid: str, job_id: str):
1212 1213
1213 # --- Assets : images -------------------------------------------------------------------1214 # --- Assets : images -------------------------------------------------------------------
1214 1215
1216+def _beat(beat: str) -> str:
1217+ """L'id d'un beat dont on cherche les images ("" : la recherche de Médias)."""
1218+ if beat and not re.fullmatch(r"[\w-]{1,40}", beat):
1219+ raise HTTPException(400, "beat inconnu")
1220+ return beat
1221+
1222+
1215 @router.get("/{pid}/assets/images")1223 @router.get("/{pid}/assets/images")
1216-def get_images(pid: str):1224+def get_images(pid: str, beat: str = ""):
1217 _check(pid)1225 _check(pid)
1218- return assets.images_state(pid)1226+ return assets.images_state(pid, _beat(beat))
1219 1227
1220 1228
1221 class ImagesRequest(BaseModel):1229 class ImagesRequest(BaseModel):
@@ -1223,21 +1231,54 @@ class ImagesRequest(BaseModel):
1223 1231
1224 1232
1225 @router.post("/{pid}/assets/images")1233 @router.post("/{pid}/assets/images")
1226-def search_images(pid: str, req: ImagesRequest):1234+def search_images(pid: str, req: ImagesRequest, beat: str = ""):
1227 _check(pid)1235 _check(pid)
1236+ beat = _beat(beat)
1228 if not req.text.strip():1237 if not req.text.strip():
1229 raise HTTPException(400, "demande vide")1238 raise HTTPException(400, "demande vide")
1230- job = jobs.get(assets.images_job_id(pid))1239+ job = jobs.get(assets.images_job_id(pid, beat))
1231 state = job.state if job else {"messages": [], "results": []}1240 state = job.state if job else {"messages": [], "results": []}
1232 if job and job.status == "error" and state["messages"] and state["messages"][-1]["role"] == "user":1241 if job and job.status == "error" and state["messages"] and state["messages"][-1]["role"] == "user":
1233 # la demande restée sans réponse est remplacée par la nouvelle1242 # la demande restée sans réponse est remplacée par la nouvelle
1234 state["messages"].pop()1243 state["messages"].pop()
1235 state["messages"].append({"role": "user", "text": req.text.strip()})1244 state["messages"].append({"role": "user", "text": req.text.strip()})
1245+ # une nouvelle demande revient dans la file du montage
1246+ state.pop("dismissed", None)
1236 try:1247 try:
1237- service.requeue(assets.images_job_id(pid), "project_images", _claude_payload(pid), state)1248+ service.requeue(assets.images_job_id(pid, beat), "project_images", _claude_payload(pid, beat=beat), state)
1238 except ValueError as e:1249 except ValueError as e:
1239 raise HTTPException(409, str(e)) from e1250 raise HTTPException(409, str(e)) from e
1240- return assets.images_state(pid)1251+ return assets.images_state(pid, beat)
1252+
1253+
1254+@router.post("/{pid}/assets/images/stop")
1255+def stop_images(pid: str, beat: str = ""):
1256+ _check(pid)
1257+ beat = _beat(beat)
1258+ job = jobs.get(assets.images_job_id(pid, beat))
1259+ if job and not job.finished:
1260+ jobs.cancel(job)
1261+ return assets.images_state(pid, beat)
1262+
1263+
1264+@router.post("/{pid}/assets/images/dismiss")
1265+def dismiss_images(pid: str, beat: str):
1266+ """La recherche d'un beat quitte la file du montage."""
1267+ _check(pid)
1268+ assets.dismiss_search(pid, _beat(beat))
1269+ return {"ok": True}
1270+
1271+
1272+@router.delete("/{pid}/assets/images")
1273+def reset_images(pid: str, beat: str = ""):
1274+ """Repart d'une recherche d'images vierge."""
1275+ _check(pid)
1276+ beat = _beat(beat)
1277+ try:
1278+ assets.reset_images(pid, beat)
1279+ except ValueError as e:
1280+ raise HTTPException(409, str(e)) from e
1281+ return assets.images_state(pid, beat)
1241 1282
1242 1283
1243 class AddImagesRequest(BaseModel):1284 class AddImagesRequest(BaseModel):
@@ -1247,19 +1288,43 @@ class AddImagesRequest(BaseModel):
1247 1288
1248 1289
1249 @router.post("/{pid}/assets/images/add")1290 @router.post("/{pid}/assets/images/add")
1250-def add_images(pid: str, req: AddImagesRequest):1291+def add_images(pid: str, req: AddImagesRequest, beat: str = ""):
1251 _check(pid)1292 _check(pid)
1293+ beat = _beat(beat)
1252 try:1294 try:
1253- count = assets.add_images(pid, req.ids, req.common, req.category, service.load(pid)["title"])1295+ srcs = assets.add_images(pid, req.ids, req.common, req.category, service.load(pid)["title"], beat)
1254 except ValueError as e:1296 except ValueError as e:
1255 raise HTTPException(409, str(e)) from e1297 raise HTTPException(409, str(e)) from e
1256- return {"added": count, **assets.images_state(pid)}1298+ return {"added": len(srcs), "srcs": srcs, **assets.images_state(pid, beat)}
1299+
1300+
1301+@router.post("/{pid}/assets/image")
1302+def import_image(pid: str, file: UploadFile | None = File(None), url: str = Form("")):
1303+ """Une image ajoutée au projet d'un geste (fichier envoyé, ou téléchargée depuis son adresse), rangée dans ses
1304+ images ; rend son `src` (comme la timeline), pour l'attacher tout de suite à un beat."""
1305+ _check(pid)
1306+ folder = service.project_dir(pid) / service.ASSETS_DIR / "images"
1307+ if file is not None:
1308+ if storyboard.media_kind(file.filename or "") != "image":
1309+ raise HTTPException(400, "pas une image (png, jpg, webp, gif, svg…)")
1310+ dest = _save_upload(file, folder)
1311+ elif url.strip():
1312+ try:
1313+ got = asset_tasks.fetch_image(url.strip(), folder / f".import-{uuid.uuid4().hex[:10]}")
1314+ except Exception as e: # noqa: BLE001 — lien mort, pas une image, trop lourde…
1315+ raise HTTPException(400, f"image non récupérée : {e}") from e
1316+ # nommée d'après son adresse, l'extension d'après son type
1317+ name = service.safe_name(unquote(Path(urlparse(url.strip()).path).stem)[:80], "image")
1318+ dest = got.rename(service.unique_path(folder, name + got.suffix))
1319+ else:
1320+ raise HTTPException(400, "ni fichier ni adresse")
1321+ return {"src": dest.relative_to(service.project_dir(pid)).as_posix()}
1257 1322
1258 1323
1259 @router.get("/{pid}/assets/candidates/{name}")1324 @router.get("/{pid}/assets/candidates/{name}")
1260-def candidate(pid: str, name: str):1325+def candidate(pid: str, name: str, beat: str = ""):
1261 _check(pid)1326 _check(pid)
1262- path = assets.candidates_dir(pid) / service.safe_name(name)1327+ path = assets.candidates_dir(pid, _beat(beat)) / service.safe_name(name)
1263 if not path.is_file():1328 if not path.is_file():
1264 raise HTTPException(404, "image inconnue")1329 raise HTTPException(404, "image inconnue")
1265 return FileResponse(path)1330 return FileResponse(path)
modified backend/studio/modules/projects/asset_tasks.py +1 -1
@@ -73,7 +73,7 @@ def images(ctx: TaskContext) -> None:
7373 ctx.set_status("downloading")
7474 state["activity"] = "vérifie les images"
7575 ctx.save(force=True)
76- cache = assets.candidates_dir(p["project"])
76+ cache = assets.candidates_dir(p["project"], p.get("beat", ""))
7777 known = {r["url"] for r in results}
7878 fresh, rejected = [], 0
7979 for img in output.get("images") or []:
@@ -73,7 +73,7 @@ def images(ctx: TaskContext) -> None:
73 ctx.set_status("downloading")73 ctx.set_status("downloading")
74 state["activity"] = "vérifie les images"74 state["activity"] = "vérifie les images"
75 ctx.save(force=True)75 ctx.save(force=True)
76- cache = assets.candidates_dir(p["project"])76+ cache = assets.candidates_dir(p["project"], p.get("beat", ""))
77 known = {r["url"] for r in results}77 known = {r["url"] for r in results}
78 fresh, rejected = [], 078 fresh, rejected = [], 0
79 for img in output.get("images") or []:79 for img in output.get("images") or []:
modified backend/studio/modules/projects/assets.py +60 -13
@@ -19,12 +19,15 @@ from . import library, service
1919 CANDIDATES_DIR = ".candidates"
2020
2121
22-def candidates_dir(pid: str) -> Path:
23- return service.project_dir(pid) / service.ASSETS_DIR / CANDIDATES_DIR
22+def candidates_dir(pid: str, beat: str = "") -> Path:
23+ """Les images proposées par une recherche : celle de Médias, ou celle d'un beat du montage (`beat`)."""
24+ folder = service.project_dir(pid) / service.ASSETS_DIR / CANDIDATES_DIR
25+ return folder / "beats" / beat if beat else folder
2426
2527
26-def images_job_id(pid: str) -> str:
27- return f"{pid}-images"
28+def images_job_id(pid: str, beat: str = "") -> str:
29+ """La recherche d'images de Médias, ou celle d'un beat : chaque beat a sa discussion, qui tourne sans la popup."""
30+ return f"{pid}-images-{beat}" if beat else f"{pid}-images"
2831
2932
3033 # --- YouTube ---------------------------------------------------------------------------
@@ -109,8 +112,8 @@ def view(project: dict) -> dict:
109112
110113 # --- Images ------------------------------------------------------------------------------
111114
112-def images_state(pid: str) -> dict:
113- job = jobs.get(images_job_id(pid))
115+def images_state(pid: str, beat: str = "") -> dict:
116+ job = jobs.get(images_job_id(pid, beat))
114117 if not job:
115118 return {"messages": [], "results": [], "busy": False, "error": None, "activity": None}
116119 return {"messages": job.state.get("messages", []), "results": job.state.get("results", []),
@@ -118,19 +121,59 @@ def images_state(pid: str) -> dict:
118121 "activity": job.state.get("activity")}
119122
120123
121-def add_images(pid: str, ids: list[str], common: bool, category: str, project_title: str) -> int:
122- """Copie les images retenues à destination ; rend leur nombre."""
123- job = jobs.get(images_job_id(pid))
124+def reset_images(pid: str, beat: str = "") -> None:
125+ """Une nouvelle recherche : la discussion et les images proposées s'effacent (celles déjà rangées restent)."""
126+ job = jobs.get(images_job_id(pid, beat))
127+ if job and not job.finished:
128+ raise ValueError("Daryl cherche encore : attends la fin de la recherche")
129+ if job:
130+ jobs.delete(job.id)
131+ folder = candidates_dir(pid, beat)
132+ if beat:
133+ shutil.rmtree(folder, ignore_errors=True)
134+ elif folder.is_dir():
135+ # celles des beats restent : leurs recherches continuent
136+ for f in folder.iterdir():
137+ if f.is_file():
138+ f.unlink(missing_ok=True)
139+
140+
141+def beat_searches(pid: str, beats: list[str]) -> dict:
142+ """Les recherches d'images des beats, pour la file du montage : {beat: {status, activity, error, query, found}}.
143+ Une recherche finie que l'auteur a écartée (ou dont il a gardé des images) n'y figure plus."""
144+ out = {}
145+ for beat in beats:
146+ job = jobs.get(images_job_id(pid, beat))
147+ if not job or job.state.get("dismissed"):
148+ continue
149+ asked = next((m["text"] for m in reversed(job.state.get("messages", [])) if m["role"] == "user"), "")
150+ found = sum(1 for r in job.state.get("results", []) if r.get("query") == asked and not r.get("added"))
151+ status = job.status if job.status in ("queued", "error", "cancelled", "done") else "running"
152+ out[beat] = {"status": status, "activity": job.state.get("activity"), "error": job.error, "query": asked,
153+ "found": found}
154+ return out
155+
156+
157+def dismiss_search(pid: str, beat: str) -> None:
158+ """Retire la recherche d'un beat de la file (sa discussion reste, pour la popup)."""
159+ job = jobs.get(images_job_id(pid, beat))
160+ if job and job.finished:
161+ jobs.update(job.id, state={**job.state, "dismissed": True})
162+
163+
164+def add_images(pid: str, ids: list[str], common: bool, category: str, project_title: str, beat: str = "") -> list[str]:
165+ """Copie les images retenues à destination ; rend leur `src` (comme la timeline), dans l'ordre de `ids`."""
166+ job = jobs.get(images_job_id(pid, beat))
124167 if not job:
125168 raise ValueError("aucune recherche d'images")
126169 if not job.finished:
127170 raise ValueError("Claude cherche encore : attends la fin de la recherche")
128171 folder, label = library.destination(pid, common, category)
129- count = 0
172+ added = {}
130173 for r in job.state.get("results", []):
131174 if r["id"] not in ids or r.get("added"):
132175 continue
133- src = candidates_dir(pid) / r["file"]
176+ src = candidates_dir(pid, beat) / r["file"]
134177 if not src.is_file():
135178 continue
136179 name = r["title"] + (f" - {r['variant']}" if r.get("variant") else "")
@@ -139,6 +182,10 @@ def add_images(pid: str, ids: list[str], common: bool, category: str, project_ti
139182 if common:
140183 library.remember_origin(f"{category}/{dest.name}", project_title)
141184 r["added"] = label
142- count += 1
185+ added[r["id"]] = (f"library/{category}/{dest.name}" if common
186+ else dest.relative_to(service.project_dir(pid)).as_posix())
187+ if beat and added:
188+ # gardées : la recherche quitte la file du montage
189+ job.state["dismissed"] = True
143190 jobs.update(job.id, state=job.state)
144- return count
191+ return [added[i] for i in ids if i in added]
@@ -19,12 +19,15 @@ from . import library, service
19 CANDIDATES_DIR = ".candidates"19 CANDIDATES_DIR = ".candidates"
20 20
21 21
22-def candidates_dir(pid: str) -> Path:22+def candidates_dir(pid: str, beat: str = "") -> Path:
23- return service.project_dir(pid) / service.ASSETS_DIR / CANDIDATES_DIR23+ """Les images proposées par une recherche : celle de Médias, ou celle d'un beat du montage (`beat`)."""
24+ folder = service.project_dir(pid) / service.ASSETS_DIR / CANDIDATES_DIR
25+ return folder / "beats" / beat if beat else folder
24 26
25 27
26-def images_job_id(pid: str) -> str:28+def images_job_id(pid: str, beat: str = "") -> str:
27- return f"{pid}-images"29+ """La recherche d'images de Médias, ou celle d'un beat : chaque beat a sa discussion, qui tourne sans la popup."""
30+ return f"{pid}-images-{beat}" if beat else f"{pid}-images"
28 31
29 32
30 # --- YouTube ---------------------------------------------------------------------------33 # --- YouTube ---------------------------------------------------------------------------
@@ -109,8 +112,8 @@ def view(project: dict) -> dict:
109 112
110 # --- Images ------------------------------------------------------------------------------113 # --- Images ------------------------------------------------------------------------------
111 114
112-def images_state(pid: str) -> dict:115+def images_state(pid: str, beat: str = "") -> dict:
113- job = jobs.get(images_job_id(pid))116+ job = jobs.get(images_job_id(pid, beat))
114 if not job:117 if not job:
115 return {"messages": [], "results": [], "busy": False, "error": None, "activity": None}118 return {"messages": [], "results": [], "busy": False, "error": None, "activity": None}
116 return {"messages": job.state.get("messages", []), "results": job.state.get("results", []),119 return {"messages": job.state.get("messages", []), "results": job.state.get("results", []),
@@ -118,19 +121,59 @@ def images_state(pid: str) -> dict:
118 "activity": job.state.get("activity")}121 "activity": job.state.get("activity")}
119 122
120 123
121-def add_images(pid: str, ids: list[str], common: bool, category: str, project_title: str) -> int:124+def reset_images(pid: str, beat: str = "") -> None:
122- """Copie les images retenues à destination ; rend leur nombre."""125+ """Une nouvelle recherche : la discussion et les images proposées s'effacent (celles déjà rangées restent)."""
123- job = jobs.get(images_job_id(pid))126+ job = jobs.get(images_job_id(pid, beat))
127+ if job and not job.finished:
128+ raise ValueError("Daryl cherche encore : attends la fin de la recherche")
129+ if job:
130+ jobs.delete(job.id)
131+ folder = candidates_dir(pid, beat)
132+ if beat:
133+ shutil.rmtree(folder, ignore_errors=True)
134+ elif folder.is_dir():
135+ # celles des beats restent : leurs recherches continuent
136+ for f in folder.iterdir():
137+ if f.is_file():
138+ f.unlink(missing_ok=True)
139+
140+
141+def beat_searches(pid: str, beats: list[str]) -> dict:
142+ """Les recherches d'images des beats, pour la file du montage : {beat: {status, activity, error, query, found}}.
143+ Une recherche finie que l'auteur a écartée (ou dont il a gardé des images) n'y figure plus."""
144+ out = {}
145+ for beat in beats:
146+ job = jobs.get(images_job_id(pid, beat))
147+ if not job or job.state.get("dismissed"):
148+ continue
149+ asked = next((m["text"] for m in reversed(job.state.get("messages", [])) if m["role"] == "user"), "")
150+ found = sum(1 for r in job.state.get("results", []) if r.get("query") == asked and not r.get("added"))
151+ status = job.status if job.status in ("queued", "error", "cancelled", "done") else "running"
152+ out[beat] = {"status": status, "activity": job.state.get("activity"), "error": job.error, "query": asked,
153+ "found": found}
154+ return out
155+
156+
157+def dismiss_search(pid: str, beat: str) -> None:
158+ """Retire la recherche d'un beat de la file (sa discussion reste, pour la popup)."""
159+ job = jobs.get(images_job_id(pid, beat))
160+ if job and job.finished:
161+ jobs.update(job.id, state={**job.state, "dismissed": True})
162+
163+
164+def add_images(pid: str, ids: list[str], common: bool, category: str, project_title: str, beat: str = "") -> list[str]:
165+ """Copie les images retenues à destination ; rend leur `src` (comme la timeline), dans l'ordre de `ids`."""
166+ job = jobs.get(images_job_id(pid, beat))
124 if not job:167 if not job:
125 raise ValueError("aucune recherche d'images")168 raise ValueError("aucune recherche d'images")
126 if not job.finished:169 if not job.finished:
127 raise ValueError("Claude cherche encore : attends la fin de la recherche")170 raise ValueError("Claude cherche encore : attends la fin de la recherche")
128 folder, label = library.destination(pid, common, category)171 folder, label = library.destination(pid, common, category)
129- count = 0172+ added = {}
130 for r in job.state.get("results", []):173 for r in job.state.get("results", []):
131 if r["id"] not in ids or r.get("added"):174 if r["id"] not in ids or r.get("added"):
132 continue175 continue
133- src = candidates_dir(pid) / r["file"]176+ src = candidates_dir(pid, beat) / r["file"]
134 if not src.is_file():177 if not src.is_file():
135 continue178 continue
136 name = r["title"] + (f" - {r['variant']}" if r.get("variant") else "")179 name = r["title"] + (f" - {r['variant']}" if r.get("variant") else "")
@@ -139,6 +182,10 @@ def add_images(pid: str, ids: list[str], common: bool, category: str, project_ti
139 if common:182 if common:
140 library.remember_origin(f"{category}/{dest.name}", project_title)183 library.remember_origin(f"{category}/{dest.name}", project_title)
141 r["added"] = label184 r["added"] = label
142- count += 1185+ added[r["id"]] = (f"library/{category}/{dest.name}" if common
186+ else dest.relative_to(service.project_dir(pid)).as_posix())
187+ if beat and added:
188+ # gardées : la recherche quitte la file du montage
189+ job.state["dismissed"] = True
143 jobs.update(job.id, state=job.state)190 jobs.update(job.id, state=job.state)
144- return count191+ return [added[i] for i in ids if i in added]
modified backend/studio/modules/projects/storyboard.py +70 -14
@@ -26,7 +26,8 @@ Fichier : montage/storyboard.json
2626 "beats": [{"id", "type", "at", "dur", "anchor", "until", "says", "show", "kind", "place",
2727 "motion": {"enter", "hold", "exit"}, "sfx", "ref", "why", "on",
2828 "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)
3031 `at` / `dur` en secondes de la timeline. Les contrôles (`check`) sont refaits à chaque lecture :
3132 le montage de l'A-roll peut avoir bougé depuis la génération.
3233 """
@@ -41,7 +42,7 @@ from ...core import jobs
4142 from ...core.tasks import TaskContext
4243 from ..settings.service import language_name
4344 from ..transcript import cues
44-from . import autoderush, library, media, montage, runtime, service, transitions
45+from . import assets, autoderush, library, media, montage, runtime, service, transitions
4546
4647 KIND = "project_storyboard"
4748 FILE = "storyboard.json"
@@ -926,17 +927,21 @@ def retype(b: dict, kind) -> None:
926927 """Change le type d'un beat, avec ce qu'il faut au nouveau type (sa durée ne bouge pas)."""
927928 if kind not in TYPES or kind == b.get("type"):
928929 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 []
929932 b["type"] = kind
930933 # ce qui ne sert qu'à l'ancien type s'en va
931- for key, owner in (("zoom", "zoom"), ("src", "broll"), ("search", "broll"), ("start", "broll")):
934+ for key, owner in (("zoom", "zoom"), ("src", "broll"), ("search", "broll"), ("start", "broll"), ("images", "motion")):
932935 if kind != owner:
933936 b.pop(key, None)
937+ if kind == "motion" and images:
938+ b["images"] = images
934939 if kind not in ("motion", "broll"):
935940 b["enter"] = b["exit"] = None
936941 if kind == "zoom":
937942 b.setdefault("zoom", zoom_of(None))
938943 if kind == "broll":
939- b.setdefault("src", "")
944+ b.setdefault("src", images[0] if images else "")
940945 b.setdefault("search", "")
941946 b.setdefault("start", 0.0)
942947 if b.get("place") not in ("full", "left", "right"):
@@ -977,6 +982,14 @@ def edit(pid: str, changes: list[dict]) -> None:
977982 b["search"] = c["search"].strip()[:200]
978983 if "start" in c:
979984 b["start"] = start_of(c["start"])
985+ if b["type"] == "motion" and isinstance(c.get("images"), list):
986+ b.pop("src", None)
987+ 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"))
989+ if images:
990+ b["images"] = images
991+ else:
992+ b.pop("images", None)
980993 if c.get("kind") in KINDS:
981994 b["kind"] = c["kind"]
982995 if c.get("place") in PLACES:
@@ -1097,9 +1110,28 @@ def animated(pid: str, timeline: dict) -> dict[str, str]:
10971110 return {b: x for b, x in composed(timeline).items() if x not in motions or (folder / f"{x}.html").is_file()}
10981111
10991112
1100-def image_file(beat: dict) -> str:
1101- """Le nom, dans montage/motion/, de l'image d'un B-roll animé."""
1102- return f"{MEDIA_DIR}/{beat['id']}-{Path(beat['src']).name}"
1113+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)."""
1115+ return f"{MEDIA_DIR}/{beat['id']}-{Path(src or beat['src']).name}"
1116+
1117+
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"]
1121+ if media_kind(beat.get("src", "")) == "image" and beat["src"] not in images:
1122+ images.insert(0, beat["src"])
1123+ return images
1124+
1125+
1126+def copy_image(pid: str, beat: dict, src: str | None = None) -> bool:
1127+ """Copie une image du beat (celle d'un B-roll, sinon `src`) à côté des compositions ; faux si elle est introuvable."""
1128+ rel = montage._resolve_src(pid, src or beat["src"])
1129+ if not rel:
1130+ return False
1131+ dest = montage.motion_dir(pid) / image_file(beat, src)
1132+ dest.parent.mkdir(parents=True, exist_ok=True)
1133+ shutil.copy2(service.project_dir(pid) / rel, dest)
1134+ return True
11031135
11041136
11051137 def _ends(beat: dict) -> dict:
@@ -1143,7 +1175,10 @@ def compose(pid: str, only: list[str] | None = None) -> dict:
11431175 if not b.get("on") or (only is not None and b["id"] not in only) or b["id"] in written:
11441176 continue
11451177 if b["id"] in done:
1146- # déjà posé, composition jamais écrite : on la redemande sans le reposer
1178+ # déjà posé, composition jamais écrite : on la redemande sans le reposer (avec les images choisies depuis)
1179+ if b.get("type") == "motion":
1180+ for src in motion_images(b):
1181+ copy_image(pid, b, src)
11471182 out["motions"].append(done[b["id"]])
11481183 continue
11491184 kind = b.get("type", "motion")
@@ -1174,13 +1209,13 @@ def compose(pid: str, only: list[str] | None = None) -> dict:
11741209 else:
11751210 if kind == "broll":
11761211 # l'image, à côté des compositions
1177- src = montage._resolve_src(pid, b["src"])
1178- if not src:
1212+ if not copy_image(pid, b):
11791213 out["missing"].append(b["id"])
11801214 continue
1181- dest = montage.motion_dir(pid) / image_file(b)
1182- dest.parent.mkdir(parents=True, exist_ok=True)
1183- shutil.copy2(service.project_dir(pid) / src, dest)
1215+ 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+ copy_image(pid, b, src)
11841219 mid = next_id("motion")
11851220 timeline["motion"].append({"id": mid, "at": b["at"], "dur": b["dur"], "title": b["show"][:60],
11861221 "kind": "image" if kind == "broll" else "transition" if kind == "transition"
@@ -1237,6 +1272,25 @@ def image_brief(pid: str, b: dict) -> list[str]:
12371272 return lines
12381273
12391274
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()]
1279+ if not files:
1280+ 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)
1286+ 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 ""
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.")
1291+ return lines
1292+
1293+
12401294 def brief(pid: str, ids: list[str]) -> str:
12411295 """La fiche de chaque motion à composer : ce que le storyboard validé demande."""
12421296 board = load(pid) or {"beats": []}
@@ -1264,6 +1318,7 @@ def brief(pid: str, ids: list[str]) -> str:
12641318 lines.append(f"- {FULL_SCREEN}")
12651319 if b.get("anim"):
12661320 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)
12671322 if b.get("enter") or b.get("exit"):
12681323 lines.append("- Le montage ajoute " + " et ".join(
12691324 f"{'en entrée' if k == 'enter' else 'en sortie'} la transition « {transitions.label(b[k]['fx'])} » "
@@ -1298,7 +1353,7 @@ def view(pid: str) -> dict:
12981353 "types": TYPES, "kinds": KINDS, "places": PLACES, "sfx": ["", *(x["src"] for x in sfx)],
12991354 "visuals": visuals(pid),
13001355 "sfx_help": {x["src"]: x["description"] for x in sfx}, "anims": anims, "composed": {},
1301- "chat": storyboard_chat.view(pid), "anim_jobs": {},
1356+ "chat": storyboard_chat.view(pid), "anim_jobs": {}, "image_jobs": {},
13021357 # les mots de la timeline [début, fin], où s'aimantent les beats qu'on pose ou tire à la main
13031358 "words": [[w.start, w.end] for w in words], "lead": LEAD, "tail": TAIL}
13041359 if board:
@@ -1309,6 +1364,7 @@ def view(pid: str) -> dict:
13091364 b["anim"] = resolve_anim(b.get("anim", ""), [a["name"] for a in anims])
13101365 out["composed"] = animated(pid, timeline)
13111366 out["anim_jobs"] = beat_anim.states(pid, board["beats"])
1367+ out["image_jobs"] = assets.beat_searches(pid, [b["id"] for b in board["beats"]])
13121368 total = sum(c["out"] - c["in"] for c in timeline["aroll"])
13131369 covers = [p for p in script_plans(pid, words) if not p["motion"] and p["at"] is not None]
13141370 out["checks"] = check(board["beats"], words, timeline, total, board.get("density") or DEFAULT_DENSITY, covers)
@@ -26,7 +26,8 @@ Fichier : montage/storyboard.json
26 "beats": [{"id", "type", "at", "dur", "anchor", "until", "says", "show", "kind", "place",26 "beats": [{"id", "type", "at", "dur", "anchor", "until", "says", "show", "kind", "place",
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 `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 :
31 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.
32 """33 """
@@ -41,7 +42,7 @@ from ...core import jobs
41 from ...core.tasks import TaskContext42 from ...core.tasks import TaskContext
42 from ..settings.service import language_name43 from ..settings.service import language_name
43 from ..transcript import cues44 from ..transcript import cues
44-from . import autoderush, library, media, montage, runtime, service, transitions45+from . import assets, autoderush, library, media, montage, runtime, service, transitions
45 46
46 KIND = "project_storyboard"47 KIND = "project_storyboard"
47 FILE = "storyboard.json"48 FILE = "storyboard.json"
@@ -926,17 +927,21 @@ def retype(b: dict, kind) -> None:
926 """Change le type d'un beat, avec ce qu'il faut au nouveau type (sa durée ne bouge pas)."""927 """Change le type d'un beat, avec ce qu'il faut au nouveau type (sa durée ne bouge pas)."""
927 if kind not in TYPES or kind == b.get("type"):928 if kind not in TYPES or kind == b.get("type"):
928 return929 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 []
929 b["type"] = kind932 b["type"] = kind
930 # ce qui ne sert qu'à l'ancien type s'en va933 # ce qui ne sert qu'à l'ancien type s'en va
931- for key, owner in (("zoom", "zoom"), ("src", "broll"), ("search", "broll"), ("start", "broll")):934+ for key, owner in (("zoom", "zoom"), ("src", "broll"), ("search", "broll"), ("start", "broll"), ("images", "motion")):
932 if kind != owner:935 if kind != owner:
933 b.pop(key, None)936 b.pop(key, None)
937+ if kind == "motion" and images:
938+ b["images"] = images
934 if kind not in ("motion", "broll"):939 if kind not in ("motion", "broll"):
935 b["enter"] = b["exit"] = None940 b["enter"] = b["exit"] = None
936 if kind == "zoom":941 if kind == "zoom":
937 b.setdefault("zoom", zoom_of(None))942 b.setdefault("zoom", zoom_of(None))
938 if kind == "broll":943 if kind == "broll":
939- b.setdefault("src", "")944+ b.setdefault("src", images[0] if images else "")
940 b.setdefault("search", "")945 b.setdefault("search", "")
941 b.setdefault("start", 0.0)946 b.setdefault("start", 0.0)
942 if b.get("place") not in ("full", "left", "right"):947 if b.get("place") not in ("full", "left", "right"):
@@ -977,6 +982,14 @@ def edit(pid: str, changes: list[dict]) -> None:
977 b["search"] = c["search"].strip()[:200]982 b["search"] = c["search"].strip()[:200]
978 if "start" in c:983 if "start" in c:
979 b["start"] = start_of(c["start"])984 b["start"] = start_of(c["start"])
985+ if b["type"] == "motion" and isinstance(c.get("images"), list):
986+ b.pop("src", None)
987+ 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"))
989+ if images:
990+ b["images"] = images
991+ else:
992+ b.pop("images", None)
980 if c.get("kind") in KINDS:993 if c.get("kind") in KINDS:
981 b["kind"] = c["kind"]994 b["kind"] = c["kind"]
982 if c.get("place") in PLACES:995 if c.get("place") in PLACES:
@@ -1097,9 +1110,28 @@ def animated(pid: str, timeline: dict) -> dict[str, str]:
1097 return {b: x for b, x in composed(timeline).items() if x not in motions or (folder / f"{x}.html").is_file()}1110 return {b: x for b, x in composed(timeline).items() if x not in motions or (folder / f"{x}.html").is_file()}
1098 1111
1099 1112
1100-def image_file(beat: dict) -> str:1113+def image_file(beat: dict, src: str | None = None) -> str:
1101- """Le nom, dans montage/motion/, de l'image d'un B-roll animé."""1114+ """Le nom, dans montage/motion/, de l'image d'un B-roll animé (ou de `src`, une image d'un motion)."""
1102- return f"{MEDIA_DIR}/{beat['id']}-{Path(beat['src']).name}"1115+ return f"{MEDIA_DIR}/{beat['id']}-{Path(src or beat['src']).name}"
1116+
1117+
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"]
1121+ if media_kind(beat.get("src", "")) == "image" and beat["src"] not in images:
1122+ images.insert(0, beat["src"])
1123+ return images
1124+
1125+
1126+def copy_image(pid: str, beat: dict, src: str | None = None) -> bool:
1127+ """Copie une image du beat (celle d'un B-roll, sinon `src`) à côté des compositions ; faux si elle est introuvable."""
1128+ rel = montage._resolve_src(pid, src or beat["src"])
1129+ if not rel:
1130+ return False
1131+ dest = montage.motion_dir(pid) / image_file(beat, src)
1132+ dest.parent.mkdir(parents=True, exist_ok=True)
1133+ shutil.copy2(service.project_dir(pid) / rel, dest)
1134+ return True
1103 1135
1104 1136
1105 def _ends(beat: dict) -> dict:1137 def _ends(beat: dict) -> dict:
@@ -1143,7 +1175,10 @@ def compose(pid: str, only: list[str] | None = None) -> dict:
1143 if not b.get("on") or (only is not None and b["id"] not in only) or b["id"] in written:1175 if not b.get("on") or (only is not None and b["id"] not in only) or b["id"] in written:
1144 continue1176 continue
1145 if b["id"] in done:1177 if b["id"] in done:
1146- # déjà posé, composition jamais écrite : on la redemande sans le reposer1178+ # déjà posé, composition jamais écrite : on la redemande sans le reposer (avec les images choisies depuis)
1179+ if b.get("type") == "motion":
1180+ for src in motion_images(b):
1181+ copy_image(pid, b, src)
1147 out["motions"].append(done[b["id"]])1182 out["motions"].append(done[b["id"]])
1148 continue1183 continue
1149 kind = b.get("type", "motion")1184 kind = b.get("type", "motion")
@@ -1174,13 +1209,13 @@ def compose(pid: str, only: list[str] | None = None) -> dict:
1174 else:1209 else:
1175 if kind == "broll":1210 if kind == "broll":
1176 # l'image, à côté des compositions1211 # l'image, à côté des compositions
1177- src = montage._resolve_src(pid, b["src"])1212+ if not copy_image(pid, b):
1178- if not src:
1179 out["missing"].append(b["id"])1213 out["missing"].append(b["id"])
1180 continue1214 continue
1181- dest = montage.motion_dir(pid) / image_file(b)1215+ elif kind == "motion":
1182- dest.parent.mkdir(parents=True, exist_ok=True)1216+ # les images de référence aussi ; une introuvable est passée (la consigne reste)
1183- shutil.copy2(service.project_dir(pid) / src, dest)1217+ for src in motion_images(b):
1218+ copy_image(pid, b, src)
1184 mid = next_id("motion")1219 mid = next_id("motion")
1185 timeline["motion"].append({"id": mid, "at": b["at"], "dur": b["dur"], "title": b["show"][:60],1220 timeline["motion"].append({"id": mid, "at": b["at"], "dur": b["dur"], "title": b["show"][:60],
1186 "kind": "image" if kind == "broll" else "transition" if kind == "transition"1221 "kind": "image" if kind == "broll" else "transition" if kind == "transition"
@@ -1237,6 +1272,25 @@ def image_brief(pid: str, b: dict) -> list[str]:
1237 return lines1272 return lines
1238 1273
1239 1274
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()]
1279+ if not files:
1280+ 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)
1286+ 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 ""
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.")
1291+ return lines
1292+
1293+
1240 def brief(pid: str, ids: list[str]) -> str:1294 def brief(pid: str, ids: list[str]) -> str:
1241 """La fiche de chaque motion à composer : ce que le storyboard validé demande."""1295 """La fiche de chaque motion à composer : ce que le storyboard validé demande."""
1242 board = load(pid) or {"beats": []}1296 board = load(pid) or {"beats": []}
@@ -1264,6 +1318,7 @@ def brief(pid: str, ids: list[str]) -> str:
1264 lines.append(f"- {FULL_SCREEN}")1318 lines.append(f"- {FULL_SCREEN}")
1265 if b.get("anim"):1319 if b.get("anim"):
1266 lines.append(f"- Base : l'animation du kit montage/motion/exemples/{b['anim']} (adapte-la, garde sa mécanique)")1320 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)
1267 if b.get("enter") or b.get("exit"):1322 if b.get("enter") or b.get("exit"):
1268 lines.append("- Le montage ajoute " + " et ".join(1323 lines.append("- Le montage ajoute " + " et ".join(
1269 f"{'en entrée' if k == 'enter' else 'en sortie'} la transition « {transitions.label(b[k]['fx'])} » "1324 f"{'en entrée' if k == 'enter' else 'en sortie'} la transition « {transitions.label(b[k]['fx'])} » "
@@ -1298,7 +1353,7 @@ def view(pid: str) -> dict:
1298 "types": TYPES, "kinds": KINDS, "places": PLACES, "sfx": ["", *(x["src"] for x in sfx)],1353 "types": TYPES, "kinds": KINDS, "places": PLACES, "sfx": ["", *(x["src"] for x in sfx)],
1299 "visuals": visuals(pid),1354 "visuals": visuals(pid),
1300 "sfx_help": {x["src"]: x["description"] for x in sfx}, "anims": anims, "composed": {},1355 "sfx_help": {x["src"]: x["description"] for x in sfx}, "anims": anims, "composed": {},
1301- "chat": storyboard_chat.view(pid), "anim_jobs": {},1356+ "chat": storyboard_chat.view(pid), "anim_jobs": {}, "image_jobs": {},
1302 # les mots de la timeline [début, fin], où s'aimantent les beats qu'on pose ou tire à la main1357 # les mots de la timeline [début, fin], où s'aimantent les beats qu'on pose ou tire à la main
1303 "words": [[w.start, w.end] for w in words], "lead": LEAD, "tail": TAIL}1358 "words": [[w.start, w.end] for w in words], "lead": LEAD, "tail": TAIL}
1304 if board:1359 if board:
@@ -1309,6 +1364,7 @@ def view(pid: str) -> dict:
1309 b["anim"] = resolve_anim(b.get("anim", ""), [a["name"] for a in anims])1364 b["anim"] = resolve_anim(b.get("anim", ""), [a["name"] for a in anims])
1310 out["composed"] = animated(pid, timeline)1365 out["composed"] = animated(pid, timeline)
1311 out["anim_jobs"] = beat_anim.states(pid, board["beats"])1366 out["anim_jobs"] = beat_anim.states(pid, board["beats"])
1367+ out["image_jobs"] = assets.beat_searches(pid, [b["id"] for b in board["beats"]])
1312 total = sum(c["out"] - c["in"] for c in timeline["aroll"])1368 total = sum(c["out"] - c["in"] for c in timeline["aroll"])
1313 covers = [p for p in script_plans(pid, words) if not p["motion"] and p["at"] is not None]1369 covers = [p for p in script_plans(pid, words) if not p["motion"] and p["at"] is not None]
1314 out["checks"] = check(board["beats"], words, timeline, total, board.get("density") or DEFAULT_DENSITY, covers)1370 out["checks"] = check(board["beats"], words, timeline, total, board.get("density") or DEFAULT_DENSITY, covers)
modified backend/studio/modules/projects/storyboard_chat.py +3 -1
@@ -152,7 +152,9 @@ def _beat_line(b: dict, composed: dict) -> str:
152152 elif kind == "transition":
153153 what = "transition"
154154 else:
155- what = f"motion · {b['kind']} · {b['place']}{' · base ' + b['anim'] if b.get('anim') else ''}"
155+ images = storyboard.motion_images(b)
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 ""))
156158 ends = "".join(f" · {k} {b[k]['fx']}" for k in ("enter", "exit") if b.get(k))
157159 return (f"- {b['id']}{state} [{storyboard._clock(b['at'])} → {storyboard._clock(b['at'] + b['dur'])}] "
158160 f"{what}{ends}{' · son ' + b['sfx'] if b.get('sfx') else ''} · il dit « {b['says'][:160]} » · on montre : {b['show']}")
@@ -152,7 +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- what = f"motion · {b['kind']} · {b['place']}{' · base ' + b['anim'] if b.get('anim') else ''}"155+ images = storyboard.motion_images(b)
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 ""))
156 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))
157 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'])}] "
158 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 +32 -0
@@ -142,6 +142,32 @@ export default {
142142 askTitle: "Draft a message to Daryl, the editor, about this beat",
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.",
145+ image: {
146+ label: "Images to include",
147+ title: "Beat images",
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",
152+ done: "Done",
153+ addBroll: "Choose the media",
154+ change: "Change",
155+ remove: "Remove",
156+ tabs: { media: "Media", import: "Import", search: "Search the web" },
157+ tabsDesc: {
158+ media: "What is already in the project or the library",
159+ import: "A file, a pasted screenshot, a URL",
160+ search: "Daryl searches from the beat’s brief",
161+ },
162+ foot: "An imported image is saved in the project’s images.",
163+ noMedia: "No media yet: import an image or have one searched.",
164+ drop: "Drop an image here or click to choose one — or paste a screenshot (Ctrl+V)",
165+ url: "From a URL",
166+ urlPh: "https://… (direct link to the image)",
167+ add: "Add",
168+ importing: "Importing…",
169+ notImage: "This file is not an image.",
170+ },
145171 },
146172 storyboard: {
147173 tab: "Storyboard",
@@ -250,6 +276,12 @@ export default {
250276 density: "{n} beats kept, the chosen density asks for {a} to {b}",
251277 },
252278 },
279+ imageSearch: {
280+ running: "searching",
281+ found: "nothing found | 1 image | {n} images",
282+ scope: "images · beat {tc}",
283+ dismiss: "Remove from the queue (the discussion stays in the beat’s popup)",
284+ },
253285 tasks: {
254286 queued: "queued",
255287 running: "running",
@@ -142,6 +142,32 @@ export default {
142 askTitle: "Draft a message to Daryl, the editor, about this beat",142 askTitle: "Draft a message to Daryl, the editor, about this beat",
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: {
146+ label: "Images to include",
147+ title: "Beat images",
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",
152+ done: "Done",
153+ addBroll: "Choose the media",
154+ change: "Change",
155+ remove: "Remove",
156+ tabs: { media: "Media", import: "Import", search: "Search the web" },
157+ tabsDesc: {
158+ media: "What is already in the project or the library",
159+ import: "A file, a pasted screenshot, a URL",
160+ search: "Daryl searches from the beat’s brief",
161+ },
162+ foot: "An imported image is saved in the project’s images.",
163+ noMedia: "No media yet: import an image or have one searched.",
164+ drop: "Drop an image here or click to choose one — or paste a screenshot (Ctrl+V)",
165+ url: "From a URL",
166+ urlPh: "https://… (direct link to the image)",
167+ add: "Add",
168+ importing: "Importing…",
169+ notImage: "This file is not an image.",
170+ },
145 },171 },
146 storyboard: {172 storyboard: {
147 tab: "Storyboard",173 tab: "Storyboard",
@@ -250,6 +276,12 @@ export default {
250 density: "{n} beats kept, the chosen density asks for {a} to {b}",276 density: "{n} beats kept, the chosen density asks for {a} to {b}",
251 },277 },
252 },278 },
279+ imageSearch: {
280+ running: "searching",
281+ found: "nothing found | 1 image | {n} images",
282+ scope: "images · beat {tc}",
283+ dismiss: "Remove from the queue (the discussion stays in the beat’s popup)",
284+ },
253 tasks: {285 tasks: {
254 queued: "queued",286 queued: "queued",
255 running: "running",287 running: "running",
modified web/src/i18n/locales/en/sourcing.ts +1 -0
@@ -55,6 +55,7 @@ export default {
5555 resultsEmpty: "Results will appear here.",
5656 addedTag: "Added",
5757 source: "source ↗",
58+ restart: "New search",
5859 validateN: "Add | Add 1 image | Add {n} images",
5960 },
6061 youtube: {
@@ -55,6 +55,7 @@ export default {
55 resultsEmpty: "Results will appear here.",55 resultsEmpty: "Results will appear here.",
56 addedTag: "Added",56 addedTag: "Added",
57 source: "source ↗",57 source: "source ↗",
58+ restart: "New search",
58 validateN: "Add | Add 1 image | Add {n} images",59 validateN: "Add | Add 1 image | Add {n} images",
59 },60 },
60 youtube: {61 youtube: {
modified web/src/i18n/locales/fr/editing.ts +32 -0
@@ -142,6 +142,32 @@ export default {
142142 askTitle: "Préparer un message à Daryl, le monteur, sur ce beat",
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.",
145+ image: {
146+ label: "Images à intégrer",
147+ title: "Images du beat",
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",
152+ done: "Terminé",
153+ addBroll: "Choisir le média",
154+ change: "Changer",
155+ remove: "Retirer",
156+ tabs: { media: "Médias", import: "Importer", search: "Chercher sur le web" },
157+ tabsDesc: {
158+ media: "Ce qui est déjà dans le projet ou la bibliothèque",
159+ import: "Un fichier, une capture collée, une adresse",
160+ search: "Daryl cherche à partir de la consigne du beat",
161+ },
162+ foot: "Une image importée est rangée dans les images du projet.",
163+ noMedia: "Aucun média pour l’instant : importe une image ou fais-la chercher.",
164+ drop: "Glisse une image ici ou clique pour en choisir une — ou colle une capture (Ctrl+V)",
165+ url: "Depuis une adresse",
166+ urlPh: "https://… (lien direct vers l’image)",
167+ add: "Ajouter",
168+ importing: "Import…",
169+ notImage: "Ce fichier n’est pas une image.",
170+ },
145171 },
146172 storyboard: {
147173 tab: "Storyboard",
@@ -250,6 +276,12 @@ export default {
250276 density: "{n} beats gardés, la densité choisie en demande {a} à {b}",
251277 },
252278 },
279+ imageSearch: {
280+ running: "cherche",
281+ found: "rien trouvé | 1 image | {n} images",
282+ scope: "images · beat {tc}",
283+ dismiss: "Retirer de la file (la discussion reste dans la popup du beat)",
284+ },
253285 tasks: {
254286 queued: "en file",
255287 running: "en cours",
@@ -142,6 +142,32 @@ export default {
142 askTitle: "Préparer un message à Daryl, le monteur, sur ce beat",142 askTitle: "Préparer un message à Daryl, le monteur, sur ce beat",
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: {
146+ label: "Images à intégrer",
147+ title: "Images du beat",
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",
152+ done: "Terminé",
153+ addBroll: "Choisir le média",
154+ change: "Changer",
155+ remove: "Retirer",
156+ tabs: { media: "Médias", import: "Importer", search: "Chercher sur le web" },
157+ tabsDesc: {
158+ media: "Ce qui est déjà dans le projet ou la bibliothèque",
159+ import: "Un fichier, une capture collée, une adresse",
160+ search: "Daryl cherche à partir de la consigne du beat",
161+ },
162+ foot: "Une image importée est rangée dans les images du projet.",
163+ noMedia: "Aucun média pour l’instant : importe une image ou fais-la chercher.",
164+ drop: "Glisse une image ici ou clique pour en choisir une — ou colle une capture (Ctrl+V)",
165+ url: "Depuis une adresse",
166+ urlPh: "https://… (lien direct vers l’image)",
167+ add: "Ajouter",
168+ importing: "Import…",
169+ notImage: "Ce fichier n’est pas une image.",
170+ },
145 },171 },
146 storyboard: {172 storyboard: {
147 tab: "Storyboard",173 tab: "Storyboard",
@@ -250,6 +276,12 @@ export default {
250 density: "{n} beats gardés, la densité choisie en demande {a} à {b}",276 density: "{n} beats gardés, la densité choisie en demande {a} à {b}",
251 },277 },
252 },278 },
279+ imageSearch: {
280+ running: "cherche",
281+ found: "rien trouvé | 1 image | {n} images",
282+ scope: "images · beat {tc}",
283+ dismiss: "Retirer de la file (la discussion reste dans la popup du beat)",
284+ },
253 tasks: {285 tasks: {
254 queued: "en file",286 queued: "en file",
255 running: "en cours",287 running: "en cours",
modified web/src/i18n/locales/fr/sourcing.ts +1 -0
@@ -55,6 +55,7 @@ export default {
5555 resultsEmpty: "Les résultats apparaîtront ici.",
5656 addedTag: "Ajoutée",
5757 source: "source ↗",
58+ restart: "Nouvelle recherche",
5859 validateN: "Valider | Valider {n} image | Valider {n} images",
5960 },
6061 youtube: {
@@ -55,6 +55,7 @@ export default {
55 resultsEmpty: "Les résultats apparaîtront ici.",55 resultsEmpty: "Les résultats apparaîtront ici.",
56 addedTag: "Ajoutée",56 addedTag: "Ajoutée",
57 source: "source ↗",57 source: "source ↗",
58+ restart: "Nouvelle recherche",
58 validateN: "Valider | Valider {n} image | Valider {n} images",59 validateN: "Valider | Valider {n} image | Valider {n} images",
59 },60 },
60 youtube: {61 youtube: {
modified web/src/project/media/ImagesTab.vue +32 -10
@@ -2,11 +2,13 @@
22 /**
33 * Onglet Images : on dit à Claude ce qu'on cherche (logo d'une marque, photo d'une personne…), il
44 * cherche sur le web ; les images trouvées sont vérifiées et téléchargées par le studio, on coche
5- * celles à garder et on les range dans le projet ou dans les assets communs.
5+ * celles à garder et on les range dans le projet ou dans les assets communs. Depuis le montage, la recherche part
6+ * de la consigne d'un beat (`prefill`), dans la discussion propre à ce beat (`beat`), et l'image gardée lui est
7+ * attachée.
68 */
79 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
810 import { useI18n } from "vue-i18n";
9-import { api, post } from "../../lib/api";
11+import { api, del, post } from "../../lib/api";
1012 import { usePref } from "../../lib/composables";
1113 import { toast } from "../../lib/toast";
1214 import { store } from "../store";
@@ -14,11 +16,15 @@ import type { ImagesState } from "../types";
1416 import CheckBox from "./CheckBox.vue";
1517 import DestPicker from "./DestPicker.vue";
1618
17-const emit = defineEmits<{ added: [] }>();
19+const props = defineProps<{ prefill?: string; beat?: string }>();
20+/** les images rangées, par leur `src` (comme la timeline) */
21+const emit = defineEmits<{ added: [srcs: string[]] }>();
1822 const { t } = useI18n();
1923 const base = `/api/projects/${store.project!.id}/assets`;
24+/** la recherche d'un beat, ou celle de Médias */
25+const scope = props.beat ? `?beat=${encodeURIComponent(props.beat)}` : "";
2026 const state = ref<ImagesState>({ messages: [], results: [], busy: false, error: null, activity: null });
21-const input = ref("");
27+const input = ref(props.prefill ?? "");
2228 const sending = ref(false);
2329 const picked = ref(new Set<string>());
2430 const scroller = ref<HTMLElement>();
@@ -35,10 +41,10 @@ function receive(s: ImagesState): void {
3541 if (first && first.query === asked) picked.value = new Set([...picked.value, first.id]);
3642 }
3743 clearTimeout(timer);
38- if (s.busy) timer = setTimeout(() => api<ImagesState>(`${base}/images`).then(receive).catch(() => receive(s)), 1500);
44+ if (s.busy) timer = setTimeout(() => api<ImagesState>(`${base}/images${scope}`).then(receive).catch(() => receive(s)), 1500);
3945 }
4046
41-onMounted(() => api<ImagesState>(`${base}/images`).then(receive).catch((e) => toast(e.message, true)));
47+onMounted(() => api<ImagesState>(`${base}/images${scope}`).then(receive).catch((e) => toast(e.message, true)));
4248 onBeforeUnmount(() => clearTimeout(timer));
4349
4450 watch(() => state.value.messages.length + (state.value.busy ? 1 : 0), async () => {
@@ -51,7 +57,7 @@ async function send() {
5157 if (!text || sending.value || state.value.busy) return;
5258 sending.value = true;
5359 try {
54- receive(await post<ImagesState>(`${base}/images`, { text }));
60+ receive(await post<ImagesState>(`${base}/images${scope}`, { text }));
5561 input.value = "";
5662 } catch (e) {
5763 toast((e as Error).message, true);
@@ -60,6 +66,17 @@ async function send() {
6066 }
6167 }
6268
69+/** une nouvelle recherche : la discussion et les images proposées s'effacent */
70+async function restart() {
71+ try {
72+ receive(await del<ImagesState>(`${base}/images${scope}`));
73+ picked.value = new Set();
74+ input.value = props.prefill ?? "";
75+ } catch (e) {
76+ toast((e as Error).message, true);
77+ }
78+}
79+
6380 function toggle(id: string) {
6481 const s = new Set(picked.value);
6582 if (s.has(id)) s.delete(id);
@@ -75,25 +92,28 @@ const category = usePref("assets.imgCategory", "logos");
7592 async function validate() {
7693 if (!selection.value.length) return toast(t("sourcing.images.checkOne"), true);
7794 try {
78- const out = await post<ImagesState & { added: number }>(`${base}/images/add`, {
95+ const out = await post<ImagesState & { added: number; srcs: string[] }>(`${base}/images/add${scope}`, {
7996 ids: selection.value.map((r) => r.id), common: common.value, category: common.value ? category.value : "images",
8097 });
8198 picked.value = new Set();
8299 receive(out);
83100 const dest = out.results.find((r) => r.added)?.added ?? "";
84101 toast(t("sourcing.images.added", { dest }, out.added));
85- if (out.added) emit("added");
102+ if (out.added) emit("added", out.srcs);
86103 } catch (e) {
87104 toast((e as Error).message, true);
88105 }
89106 }
90107
91-const src = (file: string) => `${base}/candidates/${encodeURIComponent(file)}`;
108+const src = (file: string) => `${base}/candidates/${encodeURIComponent(file)}${scope}`;
92109 </script>
93110
94111 <template>
95112 <div class="images">
96113 <div class="chat">
114+ <div v-if="state.messages.length" class="chat-head">
115+ <button class="link-btn" :disabled="state.busy" @click="restart">+ {{ t("sourcing.images.restart") }}</button>
116+ </div>
97117 <div ref="scroller" class="messages">
98118 <div class="msg ai">{{ t("sourcing.images.hello") }}</div>
99119 <div v-for="(m, i) in state.messages" :key="i" class="msg" :class="m.role">{{ m.text }}</div>
@@ -142,6 +162,8 @@ const src = (file: string) => `${base}/candidates/${encodeURIComponent(file)}`;
142162 <style scoped>
143163 .images { display: grid; grid-template-columns: 340px minmax(0, 1fr); gap: 24px; align-items: start; }
144164 .chat { display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: 4px; height: 520px; position: sticky; top: 0; }
165+.chat-head { display: flex; justify-content: flex-end; padding: 8px 12px; border-bottom: 1px solid var(--line); }
166+.chat-head .link-btn { font-size: 12.5px; }
145167 .messages { flex: 1; overflow: auto; padding: 14px; display: flex; flex-direction: column; gap: 10px; }
146168 .msg { max-width: 88%; padding: 8px 11px; border-radius: 6px; font-size: 13px; line-height: 1.45; white-space: pre-wrap; }
147169 .msg.ai { align-self: flex-start; background: #F2F2EF; color: var(--ink-2); }
@@ -2,11 +2,13 @@
2 /**2 /**
3 * Onglet Images : on dit à Claude ce qu'on cherche (logo d'une marque, photo d'une personne…), il3 * Onglet Images : on dit à Claude ce qu'on cherche (logo d'une marque, photo d'une personne…), il
4 * cherche sur le web ; les images trouvées sont vérifiées et téléchargées par le studio, on coche4 * cherche sur le web ; les images trouvées sont vérifiées et téléchargées par le studio, on coche
5- * celles à garder et on les range dans le projet ou dans les assets communs.5+ * celles à garder et on les range dans le projet ou dans les assets communs. Depuis le montage, la recherche part
6+ * de la consigne d'un beat (`prefill`), dans la discussion propre à ce beat (`beat`), et l'image gardée lui est
7+ * attachée.
6 */8 */
7 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";9 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
8 import { useI18n } from "vue-i18n";10 import { useI18n } from "vue-i18n";
9-import { api, post } from "../../lib/api";11+import { api, del, post } from "../../lib/api";
10 import { usePref } from "../../lib/composables";12 import { usePref } from "../../lib/composables";
11 import { toast } from "../../lib/toast";13 import { toast } from "../../lib/toast";
12 import { store } from "../store";14 import { store } from "../store";
@@ -14,11 +16,15 @@ import type { ImagesState } from "../types";
14 import CheckBox from "./CheckBox.vue";16 import CheckBox from "./CheckBox.vue";
15 import DestPicker from "./DestPicker.vue";17 import DestPicker from "./DestPicker.vue";
16 18
17-const emit = defineEmits<{ added: [] }>();19+const props = defineProps<{ prefill?: string; beat?: string }>();
20+/** les images rangées, par leur `src` (comme la timeline) */
21+const emit = defineEmits<{ added: [srcs: string[]] }>();
18 const { t } = useI18n();22 const { t } = useI18n();
19 const base = `/api/projects/${store.project!.id}/assets`;23 const base = `/api/projects/${store.project!.id}/assets`;
24+/** la recherche d'un beat, ou celle de Médias */
25+const scope = props.beat ? `?beat=${encodeURIComponent(props.beat)}` : "";
20 const state = ref<ImagesState>({ messages: [], results: [], busy: false, error: null, activity: null });26 const state = ref<ImagesState>({ messages: [], results: [], busy: false, error: null, activity: null });
21-const input = ref("");27+const input = ref(props.prefill ?? "");
22 const sending = ref(false);28 const sending = ref(false);
23 const picked = ref(new Set<string>());29 const picked = ref(new Set<string>());
24 const scroller = ref<HTMLElement>();30 const scroller = ref<HTMLElement>();
@@ -35,10 +41,10 @@ function receive(s: ImagesState): void {
35 if (first && first.query === asked) picked.value = new Set([...picked.value, first.id]);41 if (first && first.query === asked) picked.value = new Set([...picked.value, first.id]);
36 }42 }
37 clearTimeout(timer);43 clearTimeout(timer);
38- if (s.busy) timer = setTimeout(() => api<ImagesState>(`${base}/images`).then(receive).catch(() => receive(s)), 1500);44+ if (s.busy) timer = setTimeout(() => api<ImagesState>(`${base}/images${scope}`).then(receive).catch(() => receive(s)), 1500);
39 }45 }
40 46
41-onMounted(() => api<ImagesState>(`${base}/images`).then(receive).catch((e) => toast(e.message, true)));47+onMounted(() => api<ImagesState>(`${base}/images${scope}`).then(receive).catch((e) => toast(e.message, true)));
42 onBeforeUnmount(() => clearTimeout(timer));48 onBeforeUnmount(() => clearTimeout(timer));
43 49
44 watch(() => state.value.messages.length + (state.value.busy ? 1 : 0), async () => {50 watch(() => state.value.messages.length + (state.value.busy ? 1 : 0), async () => {
@@ -51,7 +57,7 @@ async function send() {
51 if (!text || sending.value || state.value.busy) return;57 if (!text || sending.value || state.value.busy) return;
52 sending.value = true;58 sending.value = true;
53 try {59 try {
54- receive(await post<ImagesState>(`${base}/images`, { text }));60+ receive(await post<ImagesState>(`${base}/images${scope}`, { text }));
55 input.value = "";61 input.value = "";
56 } catch (e) {62 } catch (e) {
57 toast((e as Error).message, true);63 toast((e as Error).message, true);
@@ -60,6 +66,17 @@ async function send() {
60 }66 }
61 }67 }
62 68
69+/** une nouvelle recherche : la discussion et les images proposées s'effacent */
70+async function restart() {
71+ try {
72+ receive(await del<ImagesState>(`${base}/images${scope}`));
73+ picked.value = new Set();
74+ input.value = props.prefill ?? "";
75+ } catch (e) {
76+ toast((e as Error).message, true);
77+ }
78+}
79+
63 function toggle(id: string) {80 function toggle(id: string) {
64 const s = new Set(picked.value);81 const s = new Set(picked.value);
65 if (s.has(id)) s.delete(id);82 if (s.has(id)) s.delete(id);
@@ -75,25 +92,28 @@ const category = usePref("assets.imgCategory", "logos");
75 async function validate() {92 async function validate() {
76 if (!selection.value.length) return toast(t("sourcing.images.checkOne"), true);93 if (!selection.value.length) return toast(t("sourcing.images.checkOne"), true);
77 try {94 try {
78- const out = await post<ImagesState & { added: number }>(`${base}/images/add`, {95+ const out = await post<ImagesState & { added: number; srcs: string[] }>(`${base}/images/add${scope}`, {
79 ids: selection.value.map((r) => r.id), common: common.value, category: common.value ? category.value : "images",96 ids: selection.value.map((r) => r.id), common: common.value, category: common.value ? category.value : "images",
80 });97 });
81 picked.value = new Set();98 picked.value = new Set();
82 receive(out);99 receive(out);
83 const dest = out.results.find((r) => r.added)?.added ?? "";100 const dest = out.results.find((r) => r.added)?.added ?? "";
84 toast(t("sourcing.images.added", { dest }, out.added));101 toast(t("sourcing.images.added", { dest }, out.added));
85- if (out.added) emit("added");102+ if (out.added) emit("added", out.srcs);
86 } catch (e) {103 } catch (e) {
87 toast((e as Error).message, true);104 toast((e as Error).message, true);
88 }105 }
89 }106 }
90 107
91-const src = (file: string) => `${base}/candidates/${encodeURIComponent(file)}`;108+const src = (file: string) => `${base}/candidates/${encodeURIComponent(file)}${scope}`;
92 </script>109 </script>
93 110
94 <template>111 <template>
95 <div class="images">112 <div class="images">
96 <div class="chat">113 <div class="chat">
114+ <div v-if="state.messages.length" class="chat-head">
115+ <button class="link-btn" :disabled="state.busy" @click="restart">+ {{ t("sourcing.images.restart") }}</button>
116+ </div>
97 <div ref="scroller" class="messages">117 <div ref="scroller" class="messages">
98 <div class="msg ai">{{ t("sourcing.images.hello") }}</div>118 <div class="msg ai">{{ t("sourcing.images.hello") }}</div>
99 <div v-for="(m, i) in state.messages" :key="i" class="msg" :class="m.role">{{ m.text }}</div>119 <div v-for="(m, i) in state.messages" :key="i" class="msg" :class="m.role">{{ m.text }}</div>
@@ -142,6 +162,8 @@ const src = (file: string) => `${base}/candidates/${encodeURIComponent(file)}`;
142 <style scoped>162 <style scoped>
143 .images { display: grid; grid-template-columns: 340px minmax(0, 1fr); gap: 24px; align-items: start; }163 .images { display: grid; grid-template-columns: 340px minmax(0, 1fr); gap: 24px; align-items: start; }
144 .chat { display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: 4px; height: 520px; position: sticky; top: 0; }164 .chat { display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: 4px; height: 520px; position: sticky; top: 0; }
165+.chat-head { display: flex; justify-content: flex-end; padding: 8px 12px; border-bottom: 1px solid var(--line); }
166+.chat-head .link-btn { font-size: 12.5px; }
145 .messages { flex: 1; overflow: auto; padding: 14px; display: flex; flex-direction: column; gap: 10px; }167 .messages { flex: 1; overflow: auto; padding: 14px; display: flex; flex-direction: column; gap: 10px; }
146 .msg { max-width: 88%; padding: 8px 11px; border-radius: 6px; font-size: 13px; line-height: 1.45; white-space: pre-wrap; }168 .msg { max-width: 88%; padding: 8px 11px; border-radius: 6px; font-size: 13px; line-height: 1.45; white-space: pre-wrap; }
147 .msg.ai { align-self: flex-start; background: #F2F2EF; color: var(--ink-2); }169 .msg.ai { align-self: flex-start; background: #F2F2EF; color: var(--ink-2); }
modified web/src/project/steps/MontageStep.vue +39 -3
@@ -16,6 +16,8 @@ import { toast } from "../../lib/toast";
1616 import { store } from "../store";
1717 import { usePlaylist, type Piece } from "./derush/usePlaylist";
1818 import BeatPanel from "./montage/BeatPanel.vue";
19+import type { ImageTab } from "./montage/BeatImageModal.vue";
20+import ImageSearchQueue from "./montage/ImageSearchQueue.vue";
1921 import Inspector from "./montage/Inspector.vue";
2022 import MonteurChat from "./montage/MonteurChat.vue";
2123 import TaskQueue from "./montage/TaskQueue.vue";
@@ -171,6 +173,19 @@ function openBeat(id: string) {
171173 selection.value = null;
172174 }
173175 watch(selection, (s) => { if (s) beatSel.value = null; });
176+/** la popup des images du beat sélectionné, et son onglet */
177+const imagePopup = ref<ImageTab | null>(null);
178+watch(beatSel, () => { imagePopup.value = null; });
179+/** les recherches d'images des beats encore à faire, dans la file à côté du chat */
180+const imageJobs = computed(() => pending.value
181+ .filter((b) => SB.view.value?.image_jobs?.[b.id])
182+ .map((b) => ({ beat: b, job: SB.view.value!.image_jobs[b.id] })));
183+/** rouvre la popup d'un beat sur sa recherche */
184+async function openSearch(id: string) {
185+ openBeat(id);
186+ await nextTick();
187+ imagePopup.value = "search";
188+}
174189 /** un beat déplacé ou tiré sur la timeline (enregistré au lâcher) */
175190 function moveBeat(id: string, at: number, dur: number) {
176191 SB.edit((list) => { const b = list.find((x) => x.id === id); if (b) Object.assign(b, { at, dur }); }, 60_000);
@@ -191,8 +206,16 @@ function askBeat(id: string) {
191206 if (!b) return;
192207 comment(tr("editing.beat.askText", { id, tc: tcd(b.at) }));
193208 }
194-/** le média d'un B-roll choisi depuis l'aperçu (comme au Storyboard : une image va en plein écran ou sur un côté) */
195-function pickMedia(id: string, src: string) {
209+/** une image tout juste importée n'est connue qu'une fois les médias relus */
210+async function knowMedia(srcs: string[]) {
211+ if (srcs.some((s) => !SB.view.value?.visuals.some((v) => v.src === s))) {
212+ await SB.flush();
213+ await SB.load();
214+ }
215+}
216+/** le média d'un B-roll (comme au Storyboard : une image va en plein écran ou sur un côté) */
217+async function pickMedia(id: string, src: string) {
218+ if (src) await knowMedia([src]);
196219 SB.edit((list) => {
197220 const b = list.find((x) => x.id === id);
198221 if (!b) return;
@@ -200,6 +223,16 @@ function pickMedia(id: string, src: string) {
200223 if (isImage(src) && !["full", "left", "right"].includes(b.place)) b.place = "full";
201224 }, 150);
202225 }
226+/** les images de référence d'un motion */
227+async function setImages(id: string, srcs: string[]) {
228+ await knowMedia(srcs);
229+ SB.edit((list) => {
230+ const b = list.find((x) => x.id === id);
231+ if (!b) return;
232+ b.images = srcs;
233+ delete b.src;
234+ }, 150);
235+}
203236 /** supprime un beat du storyboard depuis l'aperçu, après confirmation (sa création en file ou en cours est arrêtée) */
204237 async function removeBeat(id: string) {
205238 const b = pending.value.find((x) => x.id === id);
@@ -341,7 +374,9 @@ onUnmounted(() => window.removeEventListener("keydown", onKey));
341374 @pick-text="(id: string) => (selection = { item: { track: 'texts', id } })" @move-text="moveText" />
342375 <BeatPanel v-if="selBeat" class="insp" :beat="selBeat" :anims="anims" :visuals="SB.view.value?.visuals ?? []"
343376 :places="SB.view.value?.places ?? []" @change="(c, d) => changeBeat(selBeat!.id, c, d)" @create="(id: string) => createAnimations([id])"
344- @stop="(id: string) => SB.stopAnim(id)" @remove="removeBeat" @ask="askBeat" @seek="seek" @close="beatSel = null" />
377+ @stop="(id: string) => SB.stopAnim(id)" @remove="removeBeat" @ask="askBeat" @seek="seek" @close="beatSel = null"
378+ @image="(src: string) => pickMedia(selBeat!.id, src)" @images="(srcs: string[]) => setImages(selBeat!.id, srcs)"
379+ :image-popup="imagePopup" @image-popup="(tab: ImageTab | null) => (imagePopup = tab)" />
345380 <Inspector v-else v-model:selection="selection" class="insp" :tl="tl" :view="view" :available="M.available.value"
346381 :t="t" :total="M.total.value" @edit="edit" @comment="comment" @seek="seek" @beat="addBeat"
347382 @insert="(m: AvailableMedia) => insertClip(m, t)" @media="M.loadAvailable()" />
@@ -404,6 +439,7 @@ onUnmounted(() => window.removeEventListener("keydown", onKey));
404439 <span class="spacer" />
405440 <button class="link-btn new" :disabled="!current" @click="newRequest">{{ tr("editing.chat.new") }}</button>
406441 </div>
442+ <ImageSearchQueue v-if="imageJobs.length" :jobs="imageJobs" @open="openSearch" @stop="SB.stopSearch" @dismiss="SB.dismissSearch" />
407443 <TaskQueue v-if="view.tasks.length" :tasks="view.tasks" :current="current" :describe="describe"
408444 @open="openTask" @stop="M.stop" @close="M.close" />
409445 <MonteurChat ref="chat" :task="task" :usage="view.usage" :selection="selection" :describe="describe"
@@ -16,6 +16,8 @@ import { toast } from "../../lib/toast";
16 import { store } from "../store";16 import { store } from "../store";
17 import { usePlaylist, type Piece } from "./derush/usePlaylist";17 import { usePlaylist, type Piece } from "./derush/usePlaylist";
18 import BeatPanel from "./montage/BeatPanel.vue";18 import BeatPanel from "./montage/BeatPanel.vue";
19+import type { ImageTab } from "./montage/BeatImageModal.vue";
20+import ImageSearchQueue from "./montage/ImageSearchQueue.vue";
19 import Inspector from "./montage/Inspector.vue";21 import Inspector from "./montage/Inspector.vue";
20 import MonteurChat from "./montage/MonteurChat.vue";22 import MonteurChat from "./montage/MonteurChat.vue";
21 import TaskQueue from "./montage/TaskQueue.vue";23 import TaskQueue from "./montage/TaskQueue.vue";
@@ -171,6 +173,19 @@ function openBeat(id: string) {
171 selection.value = null;173 selection.value = null;
172 }174 }
173 watch(selection, (s) => { if (s) beatSel.value = null; });175 watch(selection, (s) => { if (s) beatSel.value = null; });
176+/** la popup des images du beat sélectionné, et son onglet */
177+const imagePopup = ref<ImageTab | null>(null);
178+watch(beatSel, () => { imagePopup.value = null; });
179+/** les recherches d'images des beats encore à faire, dans la file à côté du chat */
180+const imageJobs = computed(() => pending.value
181+ .filter((b) => SB.view.value?.image_jobs?.[b.id])
182+ .map((b) => ({ beat: b, job: SB.view.value!.image_jobs[b.id] })));
183+/** rouvre la popup d'un beat sur sa recherche */
184+async function openSearch(id: string) {
185+ openBeat(id);
186+ await nextTick();
187+ imagePopup.value = "search";
188+}
174 /** un beat déplacé ou tiré sur la timeline (enregistré au lâcher) */189 /** un beat déplacé ou tiré sur la timeline (enregistré au lâcher) */
175 function moveBeat(id: string, at: number, dur: number) {190 function moveBeat(id: string, at: number, dur: number) {
176 SB.edit((list) => { const b = list.find((x) => x.id === id); if (b) Object.assign(b, { at, dur }); }, 60_000);191 SB.edit((list) => { const b = list.find((x) => x.id === id); if (b) Object.assign(b, { at, dur }); }, 60_000);
@@ -191,8 +206,16 @@ function askBeat(id: string) {
191 if (!b) return;206 if (!b) return;
192 comment(tr("editing.beat.askText", { id, tc: tcd(b.at) }));207 comment(tr("editing.beat.askText", { id, tc: tcd(b.at) }));
193 }208 }
194-/** le média d'un B-roll choisi depuis l'aperçu (comme au Storyboard : une image va en plein écran ou sur un côté) */209+/** une image tout juste importée n'est connue qu'une fois les médias relus */
195-function pickMedia(id: string, src: string) {210+async function knowMedia(srcs: string[]) {
211+ if (srcs.some((s) => !SB.view.value?.visuals.some((v) => v.src === s))) {
212+ await SB.flush();
213+ await SB.load();
214+ }
215+}
216+/** le média d'un B-roll (comme au Storyboard : une image va en plein écran ou sur un côté) */
217+async function pickMedia(id: string, src: string) {
218+ if (src) await knowMedia([src]);
196 SB.edit((list) => {219 SB.edit((list) => {
197 const b = list.find((x) => x.id === id);220 const b = list.find((x) => x.id === id);
198 if (!b) return;221 if (!b) return;
@@ -200,6 +223,16 @@ function pickMedia(id: string, src: string) {
200 if (isImage(src) && !["full", "left", "right"].includes(b.place)) b.place = "full";223 if (isImage(src) && !["full", "left", "right"].includes(b.place)) b.place = "full";
201 }, 150);224 }, 150);
202 }225 }
226+/** les images de référence d'un motion */
227+async function setImages(id: string, srcs: string[]) {
228+ await knowMedia(srcs);
229+ SB.edit((list) => {
230+ const b = list.find((x) => x.id === id);
231+ if (!b) return;
232+ b.images = srcs;
233+ delete b.src;
234+ }, 150);
235+}
203 /** supprime un beat du storyboard depuis l'aperçu, après confirmation (sa création en file ou en cours est arrêtée) */236 /** supprime un beat du storyboard depuis l'aperçu, après confirmation (sa création en file ou en cours est arrêtée) */
204 async function removeBeat(id: string) {237 async function removeBeat(id: string) {
205 const b = pending.value.find((x) => x.id === id);238 const b = pending.value.find((x) => x.id === id);
@@ -341,7 +374,9 @@ onUnmounted(() => window.removeEventListener("keydown", onKey));
341 @pick-text="(id: string) => (selection = { item: { track: 'texts', id } })" @move-text="moveText" />374 @pick-text="(id: string) => (selection = { item: { track: 'texts', id } })" @move-text="moveText" />
342 <BeatPanel v-if="selBeat" class="insp" :beat="selBeat" :anims="anims" :visuals="SB.view.value?.visuals ?? []"375 <BeatPanel v-if="selBeat" class="insp" :beat="selBeat" :anims="anims" :visuals="SB.view.value?.visuals ?? []"
343 :places="SB.view.value?.places ?? []" @change="(c, d) => changeBeat(selBeat!.id, c, d)" @create="(id: string) => createAnimations([id])"376 :places="SB.view.value?.places ?? []" @change="(c, d) => changeBeat(selBeat!.id, c, d)" @create="(id: string) => createAnimations([id])"
344- @stop="(id: string) => SB.stopAnim(id)" @remove="removeBeat" @ask="askBeat" @seek="seek" @close="beatSel = null" />377+ @stop="(id: string) => SB.stopAnim(id)" @remove="removeBeat" @ask="askBeat" @seek="seek" @close="beatSel = null"
378+ @image="(src: string) => pickMedia(selBeat!.id, src)" @images="(srcs: string[]) => setImages(selBeat!.id, srcs)"
379+ :image-popup="imagePopup" @image-popup="(tab: ImageTab | null) => (imagePopup = tab)" />
345 <Inspector v-else v-model:selection="selection" class="insp" :tl="tl" :view="view" :available="M.available.value"380 <Inspector v-else v-model:selection="selection" class="insp" :tl="tl" :view="view" :available="M.available.value"
346 :t="t" :total="M.total.value" @edit="edit" @comment="comment" @seek="seek" @beat="addBeat"381 :t="t" :total="M.total.value" @edit="edit" @comment="comment" @seek="seek" @beat="addBeat"
347 @insert="(m: AvailableMedia) => insertClip(m, t)" @media="M.loadAvailable()" />382 @insert="(m: AvailableMedia) => insertClip(m, t)" @media="M.loadAvailable()" />
@@ -404,6 +439,7 @@ onUnmounted(() => window.removeEventListener("keydown", onKey));
404 <span class="spacer" />439 <span class="spacer" />
405 <button class="link-btn new" :disabled="!current" @click="newRequest">{{ tr("editing.chat.new") }}</button>440 <button class="link-btn new" :disabled="!current" @click="newRequest">{{ tr("editing.chat.new") }}</button>
406 </div>441 </div>
442+ <ImageSearchQueue v-if="imageJobs.length" :jobs="imageJobs" @open="openSearch" @stop="SB.stopSearch" @dismiss="SB.dismissSearch" />
407 <TaskQueue v-if="view.tasks.length" :tasks="view.tasks" :current="current" :describe="describe"443 <TaskQueue v-if="view.tasks.length" :tasks="view.tasks" :current="current" :describe="describe"
408 @open="openTask" @stop="M.stop" @close="M.close" />444 @open="openTask" @stop="M.stop" @close="M.close" />
409 <MonteurChat ref="chat" :task="task" :usage="view.usage" :selection="selection" :describe="describe"445 <MonteurChat ref="chat" :task="task" :usage="view.usage" :selection="selection" :describe="describe"
added web/src/project/steps/montage/BeatImage.vue +101 -0
new file mode 100644
@@ -0,0 +1,101 @@
1+<script setup lang="ts">
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.
6+ */
7+import { computed } from "vue";
8+import { useI18n } from "vue-i18n";
9+import BeatImageModal, { type ImageTab } from "./BeatImageModal.vue";
10+import { beatImages, type Beat, type Visual } from "./types";
11+
12+/** `popup` : la popup ouverte, sur cet onglet (tenue par le montage, qui la rouvre depuis la file des recherches) */
13+const props = defineProps<{ beat: Beat; visuals: Visual[]; popup: ImageTab | null }>();
14+const emit = defineEmits<{
15+ /** B-roll : le média choisi ("" : aucun) */
16+ pick: [string];
17+ /** motion : ses images, dans l'ordre */
18+ images: [string[]];
19+ /** ouvrir la popup (sur son premier onglet), ou la fermer */
20+ popup: [ImageTab | null];
21+}>();
22+const { t: tr } = useI18n();
23+
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";
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");
28+const images = computed(() => beatImages(props.beat));
29+const visual = (src?: string) => props.visuals.find((v) => v.src === src);
30+const current = computed(() => visual(props.beat.src));
31+const shortName = (name: string) => name.replace(/\.[^.]+$/, "");
32+const nameOf = (src: string) => shortName(visual(src)?.name ?? src.split("/").pop() ?? src);
33+
34+function pick(src: string) {
35+ open.value = false;
36+ emit("pick", src);
37+}
38+function toggle(src: string) {
39+ emit("images", images.value.includes(src) ? images.value.filter((x) => x !== src) : [...images.value, src]);
40+}
41+function add(srcs: string[]) {
42+ emit("images", [...images.value, ...srcs.filter((s) => !images.value.includes(s))]);
43+}
44+</script>
45+
46+<template>
47+ <div class="beat-image">
48+ <span class="lbl">
49+ {{ multi ? tr("editing.beat.image.label") : tr("editing.storyboard.broll.media") }}
50+ <template v-if="multi && images.length"> · {{ images.length }}</template>
51+ </span>
52+
53+ <template v-if="multi">
54+ <div v-if="images.length" class="strip">
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">
57+ <button class="rm" :title="tr('editing.beat.image.remove')" @click="toggle(src)">×</button>
58+ </div>
59+ <button class="tile add" :title="tr('editing.beat.image.addImages')" @click="open = true">+</button>
60+ </div>
61+ <button v-else class="link-btn start" @click="open = true">+ {{ tr("editing.beat.image.addImages") }}</button>
62+ </template>
63+
64+ <template v-else>
65+ <div v-if="current" class="cur">
66+ <span class="thumb hatch" @click="open = true">
67+ <img v-if="current.kind === 'image'" :src="current.url" :alt="current.name">
68+ <span v-else class="mono">▶</span>
69+ </span>
70+ <span class="name" :title="current.name">{{ shortName(current.name) }}</span>
71+ <button class="link-btn" @click="open = true">{{ tr("editing.beat.image.change") }}</button>
72+ <button class="x-btn" :title="tr('editing.beat.image.remove')" @click="emit('pick', '')">×</button>
73+ </div>
74+ <button v-else class="link-btn start" @click="open = true">+ {{ tr("editing.beat.image.addBroll") }}</button>
75+ </template>
76+
77+ <BeatImageModal v-if="popup" :tab="popup" :beat="beat" :visuals="visuals" :multi="multi" :selected="multi ? images : beat.src ? [beat.src] : []"
78+ @pick="pick" @toggle="toggle" @add="add" @close="open = false" />
79+ </div>
80+</template>
81+
82+<style scoped>
83+.beat-image { display: flex; flex-direction: column; gap: 6px; font-size: 12.5px; color: var(--muted); min-width: 0; }
84+/* 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; }
86+.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; }
88+.rm {
89+ 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;
91+}
92+.tile:hover .rm, .rm:focus-visible { opacity: 1; }
93+.tile.add { border: 1px dashed var(--line-strong); background: #fff; color: var(--muted); font-size: 18px; cursor: pointer; }
94+.tile.add:hover { border-color: var(--ink); color: var(--ink); }
95+.cur { display: flex; align-items: center; gap: 10px; min-width: 0; }
96+.thumb { width: 56px; height: 36px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; overflow: hidden; border-radius: 3px; cursor: pointer; }
97+.thumb img { width: 100%; height: 100%; object-fit: cover; }
98+.name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-2); font-size: 13px; }
99+.link-btn { font-size: 12.5px; }
100+.start { align-self: flex-start; }
101+</style>
new file mode 100644
@@ -0,0 +1,101 @@
1+<script setup lang="ts">
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.
6+ */
7+import { computed } from "vue";
8+import { useI18n } from "vue-i18n";
9+import BeatImageModal, { type ImageTab } from "./BeatImageModal.vue";
10+import { beatImages, type Beat, type Visual } from "./types";
11+
12+/** `popup` : la popup ouverte, sur cet onglet (tenue par le montage, qui la rouvre depuis la file des recherches) */
13+const props = defineProps<{ beat: Beat; visuals: Visual[]; popup: ImageTab | null }>();
14+const emit = defineEmits<{
15+ /** B-roll : le média choisi ("" : aucun) */
16+ pick: [string];
17+ /** motion : ses images, dans l'ordre */
18+ images: [string[]];
19+ /** ouvrir la popup (sur son premier onglet), ou la fermer */
20+ popup: [ImageTab | null];
21+}>();
22+const { t: tr } = useI18n();
23+
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";
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");
28+const images = computed(() => beatImages(props.beat));
29+const visual = (src?: string) => props.visuals.find((v) => v.src === src);
30+const current = computed(() => visual(props.beat.src));
31+const shortName = (name: string) => name.replace(/\.[^.]+$/, "");
32+const nameOf = (src: string) => shortName(visual(src)?.name ?? src.split("/").pop() ?? src);
33+
34+function pick(src: string) {
35+ open.value = false;
36+ emit("pick", src);
37+}
38+function toggle(src: string) {
39+ emit("images", images.value.includes(src) ? images.value.filter((x) => x !== src) : [...images.value, src]);
40+}
41+function add(srcs: string[]) {
42+ emit("images", [...images.value, ...srcs.filter((s) => !images.value.includes(s))]);
43+}
44+</script>
45+
46+<template>
47+ <div class="beat-image">
48+ <span class="lbl">
49+ {{ multi ? tr("editing.beat.image.label") : tr("editing.storyboard.broll.media") }}
50+ <template v-if="multi && images.length"> · {{ images.length }}</template>
51+ </span>
52+
53+ <template v-if="multi">
54+ <div v-if="images.length" class="strip">
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">
57+ <button class="rm" :title="tr('editing.beat.image.remove')" @click="toggle(src)">×</button>
58+ </div>
59+ <button class="tile add" :title="tr('editing.beat.image.addImages')" @click="open = true">+</button>
60+ </div>
61+ <button v-else class="link-btn start" @click="open = true">+ {{ tr("editing.beat.image.addImages") }}</button>
62+ </template>
63+
64+ <template v-else>
65+ <div v-if="current" class="cur">
66+ <span class="thumb hatch" @click="open = true">
67+ <img v-if="current.kind === 'image'" :src="current.url" :alt="current.name">
68+ <span v-else class="mono">▶</span>
69+ </span>
70+ <span class="name" :title="current.name">{{ shortName(current.name) }}</span>
71+ <button class="link-btn" @click="open = true">{{ tr("editing.beat.image.change") }}</button>
72+ <button class="x-btn" :title="tr('editing.beat.image.remove')" @click="emit('pick', '')">×</button>
73+ </div>
74+ <button v-else class="link-btn start" @click="open = true">+ {{ tr("editing.beat.image.addBroll") }}</button>
75+ </template>
76+
77+ <BeatImageModal v-if="popup" :tab="popup" :beat="beat" :visuals="visuals" :multi="multi" :selected="multi ? images : beat.src ? [beat.src] : []"
78+ @pick="pick" @toggle="toggle" @add="add" @close="open = false" />
79+ </div>
80+</template>
81+
82+<style scoped>
83+.beat-image { display: flex; flex-direction: column; gap: 6px; font-size: 12.5px; color: var(--muted); min-width: 0; }
84+/* 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; }
86+.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; }
88+.rm {
89+ 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;
91+}
92+.tile:hover .rm, .rm:focus-visible { opacity: 1; }
93+.tile.add { border: 1px dashed var(--line-strong); background: #fff; color: var(--muted); font-size: 18px; cursor: pointer; }
94+.tile.add:hover { border-color: var(--ink); color: var(--ink); }
95+.cur { display: flex; align-items: center; gap: 10px; min-width: 0; }
96+.thumb { width: 56px; height: 36px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; overflow: hidden; border-radius: 3px; cursor: pointer; }
97+.thumb img { width: 100%; height: 100%; object-fit: cover; }
98+.name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-2); font-size: 13px; }
99+.link-btn { font-size: 12.5px; }
100+.start { align-self: flex-start; }
101+</style>
added web/src/project/steps/montage/BeatImageModal.vue +183 -0
new file mode 100644
@@ -0,0 +1,183 @@
1+<script setup lang="ts">
2+/**
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.
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).
7+ */
8+import { computed, onMounted, onUnmounted, ref } from "vue";
9+import { useI18n } from "vue-i18n";
10+import Modal from "../../../components/Modal.vue";
11+import { toast } from "../../../lib/toast";
12+import { upload } from "../../../lib/upload";
13+import ImagesTab from "../../media/ImagesTab.vue";
14+import { store } from "../../store";
15+import { isImage, type Beat, type Visual } from "./types";
16+
17+export type ImageTab = "media" | "import" | "search";
18+const TABS: ImageTab[] = ["media", "import", "search"];
19+
20+/** `tab` : l'onglet à l'ouverture (la recherche, rouverte depuis la file) */
21+const props = defineProps<{ beat: Beat; visuals: Visual[]; multi: boolean; selected: string[]; tab?: ImageTab }>();
22+const emit = defineEmits<{
23+ /** un seul média : le choix */
24+ pick: [string];
25+ /** plusieurs : une image cochée ou décochée, des images ajoutées */
26+ toggle: [string]; add: [string[]];
27+ close: [];
28+}>();
29+const { t: tr } = useI18n();
30+
31+/** un motion n'intègre qu'une image ; un B-roll montre aussi une vidéo */
32+const choices = computed(() => props.visuals.filter((v) => props.beat.type === "broll" || v.kind === "image"));
33+const tab = ref<ImageTab>(props.tab ?? (choices.value.length ? "media" : "import"));
34+const shortName = (name: string) => name.replace(/\.[^.]+$/, "");
35+
36+const busy = ref(false);
37+const over = ref(false);
38+const link = ref("");
39+const fileInput = ref<HTMLInputElement>();
40+
41+async function importImage(file: File | null, url = "") {
42+ if (busy.value) return;
43+ if (file && !isImage(file.name)) return toast(tr("editing.beat.image.notImage"), true);
44+ busy.value = true;
45+ try {
46+ const { src } = await upload<{ src: string }>(`/api/projects/${store.project!.id}/assets/image`,
47+ file ? [file] : [], "file", file ? {} : { url });
48+ choose([src]);
49+ if (props.multi) toast(tr("editing.beat.image.added"));
50+ link.value = "";
51+ } catch (e) {
52+ toast((e as Error).message, true);
53+ } finally {
54+ busy.value = false;
55+ }
56+}
57+
58+function onPick(e: Event) {
59+ const el = e.target as HTMLInputElement;
60+ if (el.files?.[0]) importImage(el.files[0]);
61+ el.value = "";
62+}
63+function onDrop(e: DragEvent) {
64+ over.value = false;
65+ const file = e.dataTransfer?.files?.[0];
66+ if (file) return importImage(file);
67+ // une image glissée depuis une page web arrive comme une adresse
68+ const url = e.dataTransfer?.getData("text/uri-list") || e.dataTransfer?.getData("text/plain");
69+ if (url?.startsWith("http")) importImage(null, url.trim());
70+}
71+/** une capture collée (Ctrl+V), où que soit le curseur dans la popup */
72+function onPaste(e: ClipboardEvent) {
73+ const file = [...(e.clipboardData?.files ?? [])].find((f) => f.type.startsWith("image/"));
74+ if (!file) return;
75+ e.preventDefault();
76+ importImage(new File([file], `capture-${Date.now()}.${file.type.split("/")[1] || "png"}`, { type: file.type }));
77+}
78+onMounted(() => window.addEventListener("paste", onPaste));
79+onUnmounted(() => window.removeEventListener("paste", onPaste));
80+
81+function addLink() {
82+ const url = link.value.trim();
83+ if (url) importImage(null, url);
84+}
85+/** des images importées ou trouvées */
86+function choose(srcs: string[]) {
87+ if (!srcs.length) return;
88+ if (props.multi) emit("add", srcs);
89+ else emit("pick", srcs[0]);
90+}
91+function click(src: string) {
92+ if (props.multi) emit("toggle", src);
93+ else emit("pick", src);
94+}
95+</script>
96+
97+<template>
98+ <Modal :title="tr(`editing.beat.image.tabs.${tab}`)" @close="emit('close')">
99+ <template #nav>
100+ <span class="nav-title">{{ beat.type === "broll" ? tr("editing.beat.image.titleBroll") : tr("editing.beat.image.title") }}</span>
101+ <button v-for="k in TABS" :key="k" class="src" :class="{ on: k === tab }" @click="tab = k">
102+ <span class="label">{{ tr(`editing.beat.image.tabs.${k}`) }}</span>
103+ <span class="desc">{{ tr(`editing.beat.image.tabsDesc.${k}`) }}</span>
104+ </button>
105+ <span class="spacer" />
106+ <template v-if="multi">
107+ <span class="mono count">{{ tr("editing.beat.image.chosen", selected.length) }}</span>
108+ <button class="btn dark md done" @click="emit('close')">{{ tr("editing.beat.image.done") }}</button>
109+ </template>
110+ <span class="foot">{{ tr("editing.beat.image.foot") }}</span>
111+ </template>
112+
113+ <template v-if="tab === 'media'">
114+ <div v-if="!choices.length" class="dashed-box empty faint">{{ tr("editing.beat.image.noMedia") }}</div>
115+ <div v-for="w in ['projet', 'bibliothèque']" v-else :key="w" class="group">
116+ <template v-if="choices.some((x) => x.where === w)">
117+ <span class="eyebrow">{{ tr(`editing.storyboard.broll.where.${w === 'projet' ? 'project' : 'library'}`) }}</span>
118+ <div class="grid">
119+ <button v-for="v in choices.filter((x) => x.where === w)" :key="v.src" class="card" :class="{ on: selected.includes(v.src) }"
120+ :title="v.description || v.name" @click="click(v.src)">
121+ <span class="frame hatch">
122+ <span v-if="multi" class="check" :class="{ on: selected.includes(v.src) }">✓</span>
123+ <img v-if="v.kind === 'image'" :src="v.url" :alt="v.name" loading="lazy">
124+ <span v-else class="mono video">▶</span>
125+ </span>
126+ <span class="cap">{{ shortName(v.name) }}</span>
127+ </button>
128+ </div>
129+ </template>
130+ </div>
131+ </template>
132+
133+ <div v-else-if="tab === 'import'" class="import">
134+ <div class="drop-zone" :class="{ over }" @click="fileInput?.click()"
135+ @dragover.prevent="over = true" @dragleave="over = false" @drop.prevent="onDrop">
136+ {{ busy ? tr("editing.beat.image.importing") : tr("editing.beat.image.drop") }}
137+ </div>
138+ <input ref="fileInput" type="file" accept="image/*" hidden @change="onPick">
139+ <label class="f">
140+ {{ tr("editing.beat.image.url") }}
141+ <span class="row">
142+ <input v-model="link" class="input soft link" :placeholder="tr('editing.beat.image.urlPh')" :disabled="busy" @keydown.enter="addLink">
143+ <button class="btn dark md" :disabled="busy || !link.trim()" @click="addLink">{{ tr("editing.beat.image.add") }}</button>
144+ </span>
145+ </label>
146+ </div>
147+
148+ <ImagesTab v-else :beat="beat.id" :prefill="beat.show" @added="choose" />
149+ </Modal>
150+</template>
151+
152+<style scoped>
153+.nav-title { font-size: 15px; font-weight: 600; padding: 0 10px 14px; }
154+.src { display: flex; flex-direction: column; gap: 2px; padding: 9px 10px; border: none; border-radius: 4px; background: transparent; text-align: left; cursor: pointer; }
155+.src:hover { background: var(--hover); }
156+.src.on { background: #fff; }
157+.src .label { font-size: 13.5px; font-weight: 450; color: var(--ink); }
158+.src.on .label { font-weight: 600; }
159+.src .desc { font-size: 12px; color: var(--muted-2); line-height: 1.35; }
160+.foot { font-size: 12px; color: var(--faint); padding: 0 10px; line-height: 1.4; }
161+.empty { padding: 48px 20px; text-align: center; font-size: 13px; }
162+.group { display: flex; flex-direction: column; gap: 10px; margin-bottom: 20px; }
163+.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
164+.card { display: flex; flex-direction: column; gap: 4px; padding: 0; border: none; background: none; cursor: pointer; text-align: left; }
165+.frame { position: relative; aspect-ratio: 4/3; display: flex; align-items: center; justify-content: center; overflow: hidden; outline: 2px solid transparent; outline-offset: -2px; }
166+.card:hover .frame { outline-color: var(--line-strong); }
167+.card.on .frame { outline-color: var(--ink); }
168+.frame img { max-width: 100%; max-height: 100%; object-fit: contain; }
169+.check {
170+ position: absolute; top: 6px; left: 6px; width: 18px; height: 18px; border: 1px solid var(--line-strong); border-radius: 3px;
171+ background: #fff; color: transparent; font-size: 11px; line-height: 16px; text-align: center;
172+}
173+.check.on { background: var(--ink); border-color: var(--ink); color: #fff; }
174+.count { font-size: 12px; color: var(--muted); padding: 0 10px 8px; }
175+.done { margin: 0 10px 14px; }
176+.video { font-size: 22px; color: var(--muted); }
177+.cap { font-size: 12.5px; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
178+.import { display: flex; flex-direction: column; gap: 22px; max-width: 640px; }
179+.drop-zone { padding: 56px 18px; }
180+.f { display: flex; flex-direction: column; gap: 6px; font-size: 12.5px; color: var(--muted); }
181+.row { display: flex; gap: 8px; }
182+.link { flex: 1; min-width: 0; height: 34px; font-size: 13px; padding: 0 10px; }
183+</style>
new file mode 100644
@@ -0,0 +1,183 @@
1+<script setup lang="ts">
2+/**
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.
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).
7+ */
8+import { computed, onMounted, onUnmounted, ref } from "vue";
9+import { useI18n } from "vue-i18n";
10+import Modal from "../../../components/Modal.vue";
11+import { toast } from "../../../lib/toast";
12+import { upload } from "../../../lib/upload";
13+import ImagesTab from "../../media/ImagesTab.vue";
14+import { store } from "../../store";
15+import { isImage, type Beat, type Visual } from "./types";
16+
17+export type ImageTab = "media" | "import" | "search";
18+const TABS: ImageTab[] = ["media", "import", "search"];
19+
20+/** `tab` : l'onglet à l'ouverture (la recherche, rouverte depuis la file) */
21+const props = defineProps<{ beat: Beat; visuals: Visual[]; multi: boolean; selected: string[]; tab?: ImageTab }>();
22+const emit = defineEmits<{
23+ /** un seul média : le choix */
24+ pick: [string];
25+ /** plusieurs : une image cochée ou décochée, des images ajoutées */
26+ toggle: [string]; add: [string[]];
27+ close: [];
28+}>();
29+const { t: tr } = useI18n();
30+
31+/** un motion n'intègre qu'une image ; un B-roll montre aussi une vidéo */
32+const choices = computed(() => props.visuals.filter((v) => props.beat.type === "broll" || v.kind === "image"));
33+const tab = ref<ImageTab>(props.tab ?? (choices.value.length ? "media" : "import"));
34+const shortName = (name: string) => name.replace(/\.[^.]+$/, "");
35+
36+const busy = ref(false);
37+const over = ref(false);
38+const link = ref("");
39+const fileInput = ref<HTMLInputElement>();
40+
41+async function importImage(file: File | null, url = "") {
42+ if (busy.value) return;
43+ if (file && !isImage(file.name)) return toast(tr("editing.beat.image.notImage"), true);
44+ busy.value = true;
45+ try {
46+ const { src } = await upload<{ src: string }>(`/api/projects/${store.project!.id}/assets/image`,
47+ file ? [file] : [], "file", file ? {} : { url });
48+ choose([src]);
49+ if (props.multi) toast(tr("editing.beat.image.added"));
50+ link.value = "";
51+ } catch (e) {
52+ toast((e as Error).message, true);
53+ } finally {
54+ busy.value = false;
55+ }
56+}
57+
58+function onPick(e: Event) {
59+ const el = e.target as HTMLInputElement;
60+ if (el.files?.[0]) importImage(el.files[0]);
61+ el.value = "";
62+}
63+function onDrop(e: DragEvent) {
64+ over.value = false;
65+ const file = e.dataTransfer?.files?.[0];
66+ if (file) return importImage(file);
67+ // une image glissée depuis une page web arrive comme une adresse
68+ const url = e.dataTransfer?.getData("text/uri-list") || e.dataTransfer?.getData("text/plain");
69+ if (url?.startsWith("http")) importImage(null, url.trim());
70+}
71+/** une capture collée (Ctrl+V), où que soit le curseur dans la popup */
72+function onPaste(e: ClipboardEvent) {
73+ const file = [...(e.clipboardData?.files ?? [])].find((f) => f.type.startsWith("image/"));
74+ if (!file) return;
75+ e.preventDefault();
76+ importImage(new File([file], `capture-${Date.now()}.${file.type.split("/")[1] || "png"}`, { type: file.type }));
77+}
78+onMounted(() => window.addEventListener("paste", onPaste));
79+onUnmounted(() => window.removeEventListener("paste", onPaste));
80+
81+function addLink() {
82+ const url = link.value.trim();
83+ if (url) importImage(null, url);
84+}
85+/** des images importées ou trouvées */
86+function choose(srcs: string[]) {
87+ if (!srcs.length) return;
88+ if (props.multi) emit("add", srcs);
89+ else emit("pick", srcs[0]);
90+}
91+function click(src: string) {
92+ if (props.multi) emit("toggle", src);
93+ else emit("pick", src);
94+}
95+</script>
96+
97+<template>
98+ <Modal :title="tr(`editing.beat.image.tabs.${tab}`)" @close="emit('close')">
99+ <template #nav>
100+ <span class="nav-title">{{ beat.type === "broll" ? tr("editing.beat.image.titleBroll") : tr("editing.beat.image.title") }}</span>
101+ <button v-for="k in TABS" :key="k" class="src" :class="{ on: k === tab }" @click="tab = k">
102+ <span class="label">{{ tr(`editing.beat.image.tabs.${k}`) }}</span>
103+ <span class="desc">{{ tr(`editing.beat.image.tabsDesc.${k}`) }}</span>
104+ </button>
105+ <span class="spacer" />
106+ <template v-if="multi">
107+ <span class="mono count">{{ tr("editing.beat.image.chosen", selected.length) }}</span>
108+ <button class="btn dark md done" @click="emit('close')">{{ tr("editing.beat.image.done") }}</button>
109+ </template>
110+ <span class="foot">{{ tr("editing.beat.image.foot") }}</span>
111+ </template>
112+
113+ <template v-if="tab === 'media'">
114+ <div v-if="!choices.length" class="dashed-box empty faint">{{ tr("editing.beat.image.noMedia") }}</div>
115+ <div v-for="w in ['projet', 'bibliothèque']" v-else :key="w" class="group">
116+ <template v-if="choices.some((x) => x.where === w)">
117+ <span class="eyebrow">{{ tr(`editing.storyboard.broll.where.${w === 'projet' ? 'project' : 'library'}`) }}</span>
118+ <div class="grid">
119+ <button v-for="v in choices.filter((x) => x.where === w)" :key="v.src" class="card" :class="{ on: selected.includes(v.src) }"
120+ :title="v.description || v.name" @click="click(v.src)">
121+ <span class="frame hatch">
122+ <span v-if="multi" class="check" :class="{ on: selected.includes(v.src) }">✓</span>
123+ <img v-if="v.kind === 'image'" :src="v.url" :alt="v.name" loading="lazy">
124+ <span v-else class="mono video">▶</span>
125+ </span>
126+ <span class="cap">{{ shortName(v.name) }}</span>
127+ </button>
128+ </div>
129+ </template>
130+ </div>
131+ </template>
132+
133+ <div v-else-if="tab === 'import'" class="import">
134+ <div class="drop-zone" :class="{ over }" @click="fileInput?.click()"
135+ @dragover.prevent="over = true" @dragleave="over = false" @drop.prevent="onDrop">
136+ {{ busy ? tr("editing.beat.image.importing") : tr("editing.beat.image.drop") }}
137+ </div>
138+ <input ref="fileInput" type="file" accept="image/*" hidden @change="onPick">
139+ <label class="f">
140+ {{ tr("editing.beat.image.url") }}
141+ <span class="row">
142+ <input v-model="link" class="input soft link" :placeholder="tr('editing.beat.image.urlPh')" :disabled="busy" @keydown.enter="addLink">
143+ <button class="btn dark md" :disabled="busy || !link.trim()" @click="addLink">{{ tr("editing.beat.image.add") }}</button>
144+ </span>
145+ </label>
146+ </div>
147+
148+ <ImagesTab v-else :beat="beat.id" :prefill="beat.show" @added="choose" />
149+ </Modal>
150+</template>
151+
152+<style scoped>
153+.nav-title { font-size: 15px; font-weight: 600; padding: 0 10px 14px; }
154+.src { display: flex; flex-direction: column; gap: 2px; padding: 9px 10px; border: none; border-radius: 4px; background: transparent; text-align: left; cursor: pointer; }
155+.src:hover { background: var(--hover); }
156+.src.on { background: #fff; }
157+.src .label { font-size: 13.5px; font-weight: 450; color: var(--ink); }
158+.src.on .label { font-weight: 600; }
159+.src .desc { font-size: 12px; color: var(--muted-2); line-height: 1.35; }
160+.foot { font-size: 12px; color: var(--faint); padding: 0 10px; line-height: 1.4; }
161+.empty { padding: 48px 20px; text-align: center; font-size: 13px; }
162+.group { display: flex; flex-direction: column; gap: 10px; margin-bottom: 20px; }
163+.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
164+.card { display: flex; flex-direction: column; gap: 4px; padding: 0; border: none; background: none; cursor: pointer; text-align: left; }
165+.frame { position: relative; aspect-ratio: 4/3; display: flex; align-items: center; justify-content: center; overflow: hidden; outline: 2px solid transparent; outline-offset: -2px; }
166+.card:hover .frame { outline-color: var(--line-strong); }
167+.card.on .frame { outline-color: var(--ink); }
168+.frame img { max-width: 100%; max-height: 100%; object-fit: contain; }
169+.check {
170+ position: absolute; top: 6px; left: 6px; width: 18px; height: 18px; border: 1px solid var(--line-strong); border-radius: 3px;
171+ background: #fff; color: transparent; font-size: 11px; line-height: 16px; text-align: center;
172+}
173+.check.on { background: var(--ink); border-color: var(--ink); color: #fff; }
174+.count { font-size: 12px; color: var(--muted); padding: 0 10px 8px; }
175+.done { margin: 0 10px 14px; }
176+.video { font-size: 22px; color: var(--muted); }
177+.cap { font-size: 12.5px; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
178+.import { display: flex; flex-direction: column; gap: 22px; max-width: 640px; }
179+.drop-zone { padding: 56px 18px; }
180+.f { display: flex; flex-direction: column; gap: 6px; font-size: 12.5px; color: var(--muted); }
181+.row { display: flex; gap: 8px; }
182+.link { flex: 1; min-width: 0; height: 34px; font-size: 13px; padding: 0 10px; }
183+</style>
modified web/src/project/steps/montage/BeatPanel.vue +8 -20
@@ -1,24 +1,27 @@
11 <script setup lang="ts">
22 /**
33 * À la place de l'inspecteur : le beat du storyboard sélectionné sur la timeline (posé à la main, ou proposé). On y
4- * change son type, on réécrit sa consigne (« on montre »), puis on lance ce qu'il faut : créer l'animation, poser
4+ * change son type, on réécrit sa consigne (« on montre »), on lui donne son image, puis on lance ce qu'il faut : créer l'animation, poser
55 * le zoom ou le B-roll, ou en parler à Daryl. Les réglages fins restent à l'étape Storyboard.
66 */
77 import { computed } from "vue";
88 import { useI18n } from "vue-i18n";
9+import BeatImage from "./BeatImage.vue";
10+import type { ImageTab } from "./BeatImageModal.vue";
911 import { BEAT_TYPES, isImage, needsAnim, tcd, TYPE_COLOR, type AnimState, type Beat, type BeatType, type Visual } from "./types";
1012
11-const props = defineProps<{ beat: Beat; anims: Record<string, AnimState>; visuals: Visual[]; places: string[] }>();
13+const props = defineProps<{ beat: Beat; anims: Record<string, AnimState>; visuals: Visual[]; places: string[]; imagePopup: ImageTab | null }>();
1214 const emit = defineEmits<{
1315 /** une retouche du beat ; `delay` : regroupe les frappes */
1416 change: [(b: Beat) => void, number?];
1517 create: [string]; stop: [string]; remove: [string]; ask: [string]; seek: [number]; close: [];
18+ /** le média d'un B-roll ("" : aucun), ou les images d'un motion ; peut-être tout juste importés */
19+ image: [string]; images: [string[]]; imagePopup: [ImageTab | null];
1620 }>();
1721 const { t: tr } = useI18n();
1822
1923 const anim = computed(() => props.anims[props.beat.id]);
2024 const live = computed(() => ["queued", "running"].includes(anim.value?.status ?? ""));
21-const shortName = (name: string) => name.replace(/\.[^.]+$/, "");
2225 /** zoom et B-roll vidéo se posent tels quels ; un B-roll sans média attend le sien */
2326 const placeable = computed(() => props.beat.type === "zoom" || (props.beat.type === "broll" && !!props.beat.src && !isImage(props.beat.src)));
2427
@@ -33,12 +36,6 @@ function setType(type: BeatType) {
3336 }
3437 });
3538 }
36-function setSrc(src: string) {
37- emit("change", (b) => {
38- b.src = src;
39- if (isImage(src) && !["full", "left", "right"].includes(b.place)) b.place = "full";
40- });
41-}
4239 </script>
4340
4441 <template>
@@ -66,17 +63,8 @@ function setSrc(src: string) {
6663 </select>
6764 </label>
6865
69- <label v-if="beat.type === 'broll'" class="f">
70- {{ tr("editing.storyboard.broll.media") }}
71- <select :value="beat.src ?? ''" @change="setSrc(($event.target as HTMLSelectElement).value)">
72- <option value="">{{ tr("editing.storyboard.broll.missing") }}</option>
73- <optgroup v-for="w in ['projet', 'bibliothèque']" :key="w" :label="tr(`editing.storyboard.broll.where.${w === 'projet' ? 'project' : 'library'}`)">
74- <option v-for="v in visuals.filter((x) => x.where === w)" :key="v.src" :value="v.src" :title="v.description">
75- {{ v.kind === "video" ? "▶ " : "" }}{{ shortName(v.name) }}
76- </option>
77- </optgroup>
78- </select>
79- </label>
66+ <BeatImage v-if="beat.type === 'motion' || beat.type === 'broll'" :beat="beat" :visuals="visuals" :popup="imagePopup"
67+ @pick="(src) => emit('image', src)" @images="(srcs) => emit('images', srcs)" @popup="(t) => emit('imagePopup', t)" />
8068
8169 <p v-if="anim?.status === 'error'" class="err">{{ tr("editing.anims.failed", { e: anim.error }) }}</p>
8270 <div class="row actions">
@@ -1,24 +1,27 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2 /**2 /**
3 * À la place de l'inspecteur : le beat du storyboard sélectionné sur la timeline (posé à la main, ou proposé). On y3 * À la place de l'inspecteur : le beat du storyboard sélectionné sur la timeline (posé à la main, ou proposé). On y
4- * change son type, on réécrit sa consigne (« on montre »), puis on lance ce qu'il faut : créer l'animation, poser4+ * change son type, on réécrit sa consigne (« on montre »), on lui donne son image, puis on lance ce qu'il faut : créer l'animation, poser
5 * le zoom ou le B-roll, ou en parler à Daryl. Les réglages fins restent à l'étape Storyboard.5 * le zoom ou le B-roll, ou en parler à Daryl. Les réglages fins restent à l'étape Storyboard.
6 */6 */
7 import { computed } from "vue";7 import { computed } from "vue";
8 import { useI18n } from "vue-i18n";8 import { useI18n } from "vue-i18n";
9+import BeatImage from "./BeatImage.vue";
10+import type { ImageTab } from "./BeatImageModal.vue";
9 import { BEAT_TYPES, isImage, needsAnim, tcd, TYPE_COLOR, type AnimState, type Beat, type BeatType, type Visual } from "./types";11 import { BEAT_TYPES, isImage, needsAnim, tcd, TYPE_COLOR, type AnimState, type Beat, type BeatType, type Visual } from "./types";
10 12
11-const props = defineProps<{ beat: Beat; anims: Record<string, AnimState>; visuals: Visual[]; places: string[] }>();13+const props = defineProps<{ beat: Beat; anims: Record<string, AnimState>; visuals: Visual[]; places: string[]; imagePopup: ImageTab | null }>();
12 const emit = defineEmits<{14 const emit = defineEmits<{
13 /** une retouche du beat ; `delay` : regroupe les frappes */15 /** une retouche du beat ; `delay` : regroupe les frappes */
14 change: [(b: Beat) => void, number?];16 change: [(b: Beat) => void, number?];
15 create: [string]; stop: [string]; remove: [string]; ask: [string]; seek: [number]; close: [];17 create: [string]; stop: [string]; remove: [string]; ask: [string]; seek: [number]; close: [];
18+ /** le média d'un B-roll ("" : aucun), ou les images d'un motion ; peut-être tout juste importés */
19+ image: [string]; images: [string[]]; imagePopup: [ImageTab | null];
16 }>();20 }>();
17 const { t: tr } = useI18n();21 const { t: tr } = useI18n();
18 22
19 const anim = computed(() => props.anims[props.beat.id]);23 const anim = computed(() => props.anims[props.beat.id]);
20 const live = computed(() => ["queued", "running"].includes(anim.value?.status ?? ""));24 const live = computed(() => ["queued", "running"].includes(anim.value?.status ?? ""));
21-const shortName = (name: string) => name.replace(/\.[^.]+$/, "");
22 /** zoom et B-roll vidéo se posent tels quels ; un B-roll sans média attend le sien */25 /** zoom et B-roll vidéo se posent tels quels ; un B-roll sans média attend le sien */
23 const placeable = computed(() => props.beat.type === "zoom" || (props.beat.type === "broll" && !!props.beat.src && !isImage(props.beat.src)));26 const placeable = computed(() => props.beat.type === "zoom" || (props.beat.type === "broll" && !!props.beat.src && !isImage(props.beat.src)));
24 27
@@ -33,12 +36,6 @@ function setType(type: BeatType) {
33 }36 }
34 });37 });
35 }38 }
36-function setSrc(src: string) {
37- emit("change", (b) => {
38- b.src = src;
39- if (isImage(src) && !["full", "left", "right"].includes(b.place)) b.place = "full";
40- });
41-}
42 </script>39 </script>
43 40
44 <template>41 <template>
@@ -66,17 +63,8 @@ function setSrc(src: string) {
66 </select>63 </select>
67 </label>64 </label>
68 65
69- <label v-if="beat.type === 'broll'" class="f">66+ <BeatImage v-if="beat.type === 'motion' || beat.type === 'broll'" :beat="beat" :visuals="visuals" :popup="imagePopup"
70- {{ tr("editing.storyboard.broll.media") }}67+ @pick="(src) => emit('image', src)" @images="(srcs) => emit('images', srcs)" @popup="(t) => emit('imagePopup', t)" />
71- <select :value="beat.src ?? ''" @change="setSrc(($event.target as HTMLSelectElement).value)">
72- <option value="">{{ tr("editing.storyboard.broll.missing") }}</option>
73- <optgroup v-for="w in ['projet', 'bibliothèque']" :key="w" :label="tr(`editing.storyboard.broll.where.${w === 'projet' ? 'project' : 'library'}`)">
74- <option v-for="v in visuals.filter((x) => x.where === w)" :key="v.src" :value="v.src" :title="v.description">
75- {{ v.kind === "video" ? "▶ " : "" }}{{ shortName(v.name) }}
76- </option>
77- </optgroup>
78- </select>
79- </label>
80 68
81 <p v-if="anim?.status === 'error'" class="err">{{ tr("editing.anims.failed", { e: anim.error }) }}</p>69 <p v-if="anim?.status === 'error'" class="err">{{ tr("editing.anims.failed", { e: anim.error }) }}</p>
82 <div class="row actions">70 <div class="row actions">
added web/src/project/steps/montage/ImageSearchQueue.vue +49 -0
new file mode 100644
@@ -0,0 +1,49 @@
1+<script setup lang="ts">
2+/**
3+ * Les recherches d'images lancées depuis les beats, dans la file à côté du chat : elles tournent sans la popup.
4+ * Ouvrir une recherche rouvre la popup de son beat, sur les images trouvées.
5+ */
6+import { useI18n } from "vue-i18n";
7+import { tcd, type Beat, type ImageJob } from "./types";
8+
9+defineProps<{ jobs: { beat: Beat; job: ImageJob }[] }>();
10+const emit = defineEmits<{ open: [string]; stop: [string]; dismiss: [string] }>();
11+const { t } = useI18n();
12+
13+const active = (j: ImageJob) => j.status === "queued" || j.status === "running";
14+function state(j: ImageJob): string {
15+ if (j.status === "running") return j.activity || t("editing.imageSearch.running");
16+ if (j.status === "done") return t("editing.imageSearch.found", j.found);
17+ return t(`editing.tasks.${j.status}`);
18+}
19+</script>
20+
21+<template>
22+ <div class="queue">
23+ <div v-for="{ beat, job } in jobs" :key="beat.id" class="row" :class="job.status" :title="job.error || job.query" @click="emit('open', beat.id)">
24+ <span class="st mono">{{ state(job) }}</span>
25+ <span class="title">{{ job.query }}</span>
26+ <span class="scope mono">{{ t("editing.imageSearch.scope", { tc: tcd(beat.at) }) }}</span>
27+ <button v-if="active(job)" class="x-btn" :title="t('common.stop')" @click.stop="emit('stop', beat.id)">■</button>
28+ <button v-else class="x-btn" :title="t('editing.imageSearch.dismiss')" @click.stop="emit('dismiss', beat.id)">×</button>
29+ </div>
30+ </div>
31+</template>
32+
33+<style scoped>
34+.queue { max-height: 30%; overflow-y: auto; border-bottom: 1px solid var(--line); display: flex; flex-direction: column; flex-shrink: 0; }
35+.row {
36+ display: grid; grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: "st title x" "st scope x";
37+ align-items: center; column-gap: 8px; padding: 6px 10px; cursor: pointer; border-left: 2px solid transparent;
38+}
39+.row + .row { border-top: 1px solid var(--line-soft); }
40+.row:hover { background: var(--bg-soft); }
41+.row.done { border-left-color: #2A9D8F; }
42+.st { grid-area: st; font-size: 10.5px; color: var(--faint); max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
43+.running .st { color: #7C5CE0; }
44+.done .st { color: #1F7A6F; }
45+.error .st { color: var(--red-ink); }
46+.title { grid-area: title; font-size: 12.5px; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
47+.scope { grid-area: scope; font-size: 10.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
48+.x-btn { grid-area: x; }
49+</style>
new file mode 100644
@@ -0,0 +1,49 @@
1+<script setup lang="ts">
2+/**
3+ * Les recherches d'images lancées depuis les beats, dans la file à côté du chat : elles tournent sans la popup.
4+ * Ouvrir une recherche rouvre la popup de son beat, sur les images trouvées.
5+ */
6+import { useI18n } from "vue-i18n";
7+import { tcd, type Beat, type ImageJob } from "./types";
8+
9+defineProps<{ jobs: { beat: Beat; job: ImageJob }[] }>();
10+const emit = defineEmits<{ open: [string]; stop: [string]; dismiss: [string] }>();
11+const { t } = useI18n();
12+
13+const active = (j: ImageJob) => j.status === "queued" || j.status === "running";
14+function state(j: ImageJob): string {
15+ if (j.status === "running") return j.activity || t("editing.imageSearch.running");
16+ if (j.status === "done") return t("editing.imageSearch.found", j.found);
17+ return t(`editing.tasks.${j.status}`);
18+}
19+</script>
20+
21+<template>
22+ <div class="queue">
23+ <div v-for="{ beat, job } in jobs" :key="beat.id" class="row" :class="job.status" :title="job.error || job.query" @click="emit('open', beat.id)">
24+ <span class="st mono">{{ state(job) }}</span>
25+ <span class="title">{{ job.query }}</span>
26+ <span class="scope mono">{{ t("editing.imageSearch.scope", { tc: tcd(beat.at) }) }}</span>
27+ <button v-if="active(job)" class="x-btn" :title="t('common.stop')" @click.stop="emit('stop', beat.id)">■</button>
28+ <button v-else class="x-btn" :title="t('editing.imageSearch.dismiss')" @click.stop="emit('dismiss', beat.id)">×</button>
29+ </div>
30+ </div>
31+</template>
32+
33+<style scoped>
34+.queue { max-height: 30%; overflow-y: auto; border-bottom: 1px solid var(--line); display: flex; flex-direction: column; flex-shrink: 0; }
35+.row {
36+ display: grid; grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: "st title x" "st scope x";
37+ align-items: center; column-gap: 8px; padding: 6px 10px; cursor: pointer; border-left: 2px solid transparent;
38+}
39+.row + .row { border-top: 1px solid var(--line-soft); }
40+.row:hover { background: var(--bg-soft); }
41+.row.done { border-left-color: #2A9D8F; }
42+.st { grid-area: st; font-size: 10.5px; color: var(--faint); max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
43+.running .st { color: #7C5CE0; }
44+.done .st { color: #1F7A6F; }
45+.error .st { color: var(--red-ink); }
46+.title { grid-area: title; font-size: 12.5px; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
47+.scope { grid-area: scope; font-size: 10.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
48+.x-btn { grid-area: x; }
49+</style>
modified web/src/project/steps/montage/Preview.vue +3 -3
@@ -18,7 +18,7 @@ import { fxFrame, layersAt } from "../../../lib/transitions";
1818 import LayerVideo from "./LayerVideo.vue";
1919 import { releaseMedia } from "../../../lib/media";
2020 import {
21- clipStarts, isImage, tcd, TEXT_BORDER, TEXT_FADE, TEXT_FONT, type AnimState, type Beat, type MontageView, type TextNote, type Timeline, type Visual,
21+ clipStarts, isImage, slideImage, tcd, TEXT_BORDER, TEXT_FADE, TEXT_FONT, type AnimState, type Beat, type MontageView, type TextNote, type Timeline, type Visual,
2222 } from "./types";
2323
2424 const props = defineProps<{
@@ -351,8 +351,8 @@ defineExpose({ capture });
351351 <RouterLink class="btn sm" :to="`/p/${route.params.pid}/ressources?add=youtube`">{{ tr("editing.storyboard.broll.goMedia") }}</RouterLink>
352352 </span>
353353 </div>
354- <div v-else-if="beat" class="slide" :class="[beat.type === 'transition' ? 'full' : beat.place, beat.type, { off: !beat.on, img: isImage(beat.src) }]">
355- <img v-if="beat.type === 'broll' && isImage(beat.src)" class="s-img" :src="mediaUrl(beat.src!)" alt="">
354+ <div v-else-if="beat" class="slide" :class="[beat.type === 'transition' ? 'full' : beat.place, beat.type, { off: !beat.on, img: !!slideImage(beat) }]">
355+ <img v-if="slideImage(beat)" class="s-img" :src="mediaUrl(slideImage(beat)!)" alt="">
356356 <button v-if="create" class="x-btn s-remove" :title="tr('editing.storyboard.remove')" @click.stop="emit('remove', beat.id)">×</button>
357357 <span class="s-kind mono">{{ slideKind(beat) }}</span>
358358 <textarea v-if="create" class="s-edit" :rows="beat.type === 'transition' || beat.place === 'full' ? 3 : 4" :value="beat.show"
@@ -18,7 +18,7 @@ import { fxFrame, layersAt } from "../../../lib/transitions";
18 import LayerVideo from "./LayerVideo.vue";18 import LayerVideo from "./LayerVideo.vue";
19 import { releaseMedia } from "../../../lib/media";19 import { releaseMedia } from "../../../lib/media";
20 import {20 import {
21- clipStarts, isImage, tcd, TEXT_BORDER, TEXT_FADE, TEXT_FONT, type AnimState, type Beat, type MontageView, type TextNote, type Timeline, type Visual,21+ clipStarts, isImage, slideImage, tcd, TEXT_BORDER, TEXT_FADE, TEXT_FONT, type AnimState, type Beat, type MontageView, type TextNote, type Timeline, type Visual,
22 } from "./types";22 } from "./types";
23 23
24 const props = defineProps<{24 const props = defineProps<{
@@ -351,8 +351,8 @@ defineExpose({ capture });
351 <RouterLink class="btn sm" :to="`/p/${route.params.pid}/ressources?add=youtube`">{{ tr("editing.storyboard.broll.goMedia") }}</RouterLink>351 <RouterLink class="btn sm" :to="`/p/${route.params.pid}/ressources?add=youtube`">{{ tr("editing.storyboard.broll.goMedia") }}</RouterLink>
352 </span>352 </span>
353 </div>353 </div>
354- <div v-else-if="beat" class="slide" :class="[beat.type === 'transition' ? 'full' : beat.place, beat.type, { off: !beat.on, img: isImage(beat.src) }]">354+ <div v-else-if="beat" class="slide" :class="[beat.type === 'transition' ? 'full' : beat.place, beat.type, { off: !beat.on, img: !!slideImage(beat) }]">
355- <img v-if="beat.type === 'broll' && isImage(beat.src)" class="s-img" :src="mediaUrl(beat.src!)" alt="">355+ <img v-if="slideImage(beat)" class="s-img" :src="mediaUrl(slideImage(beat)!)" alt="">
356 <button v-if="create" class="x-btn s-remove" :title="tr('editing.storyboard.remove')" @click.stop="emit('remove', beat.id)">×</button>356 <button v-if="create" class="x-btn s-remove" :title="tr('editing.storyboard.remove')" @click.stop="emit('remove', beat.id)">×</button>
357 <span class="s-kind mono">{{ slideKind(beat) }}</span>357 <span class="s-kind mono">{{ slideKind(beat) }}</span>
358 <textarea v-if="create" class="s-edit" :rows="beat.type === 'transition' || beat.place === 'full' ? 3 : 4" :value="beat.show"358 <textarea v-if="create" class="s-edit" :rows="beat.type === 'transition' || beat.place === 'full' ? 3 : 4" :value="beat.show"
modified web/src/project/steps/montage/types.ts +10 -0
@@ -90,11 +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) */
94+ images?: string[];
9395 /** motion et broll : les transitions d'entrée et de sortie au montage */
9496 enter?: Fx | null; exit?: Fx | null;
9597 }
9698 const IMAGE = /\.(png|jpe?g|webp|gif|svg)$/i;
9799 export const isImage = (src?: string) => !!src && IMAGE.test(src);
100+/** Les images 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!] : []);
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;
98104 /** Le beat passe par une animation écrite par Daryl ; sinon (zoom, B-roll vidéo) il est posé tel quel. */
99105 export const needsAnim = (b: Beat) => b.type === "motion" || b.type === "transition" || (b.type === "broll" && isImage(b.src));
100106 /** La couleur de chaque type de beat (bande de rythme, cartes, timeline). */
@@ -127,10 +133,14 @@ export interface StoryboardView {
127133 chat: StoryboardChat;
128134 /** la création de l'animation des beats qui en ont une en cours, en file ou ratée */
129135 anim_jobs: Record<string, AnimState>;
136+ /** les recherches d'images des beats, tant qu'elles sont dans la file */
137+ image_jobs: Record<string, ImageJob>;
130138 /** les mots de la timeline [début, fin], où s'aimantent les beats posés à la main ; un beat commence `lead` s
131139 * avant son premier mot et finit `tail` s après le dernier */
132140 words: [number, number][]; lead: number; tail: number;
133141 }
142+/** La recherche d'images d'un beat (backend assets.beat_searches) : `found`, les images proposées à sa dernière demande. */
143+export interface ImageJob { status: "queued" | "running" | "done" | "error" | "cancelled"; activity: string | null; error: string | null; query: string; found: number }
134144 export interface AnimState { status: "queued" | "running" | "error" | "cancelled"; activity: string | null; error: string | null }
135145 export interface StoryboardMessage {
136146 role: "user" | "ai"; text: string;
@@ -90,11 +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) */
94+ images?: string[];
93 /** 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 */
94 enter?: Fx | null; exit?: Fx | null;96 enter?: Fx | null; exit?: Fx | null;
95 }97 }
96 const IMAGE = /\.(png|jpe?g|webp|gif|svg)$/i;98 const IMAGE = /\.(png|jpe?g|webp|gif|svg)$/i;
97 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). */
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. */
103+export const slideImage = (b: Beat): string | undefined => b.type === "motion" ? beatImages(b)[0] : isImage(b.src) ? b.src : undefined;
98 /** 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. */
99 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));
100 /** 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). */
@@ -127,10 +133,14 @@ export interface StoryboardView {
127 chat: StoryboardChat;133 chat: StoryboardChat;
128 /** la création de l'animation des beats qui en ont une en cours, en file ou ratée */134 /** la création de l'animation des beats qui en ont une en cours, en file ou ratée */
129 anim_jobs: Record<string, AnimState>;135 anim_jobs: Record<string, AnimState>;
136+ /** les recherches d'images des beats, tant qu'elles sont dans la file */
137+ image_jobs: Record<string, ImageJob>;
130 /** les mots de la timeline [début, fin], où s'aimantent les beats posés à la main ; un beat commence `lead` s138 /** les mots de la timeline [début, fin], où s'aimantent les beats posés à la main ; un beat commence `lead` s
131 * avant son premier mot et finit `tail` s après le dernier */139 * avant son premier mot et finit `tail` s après le dernier */
132 words: [number, number][]; lead: number; tail: number;140 words: [number, number][]; lead: number; tail: number;
133 }141 }
142+/** La recherche d'images d'un beat (backend assets.beat_searches) : `found`, les images proposées à sa dernière demande. */
143+export interface ImageJob { status: "queued" | "running" | "done" | "error" | "cancelled"; activity: string | null; error: string | null; query: string; found: number }
134 export interface AnimState { status: "queued" | "running" | "error" | "cancelled"; activity: string | null; error: string | null }144 export interface AnimState { status: "queued" | "running" | "error" | "cancelled"; activity: string | null; error: string | null }
135 export interface StoryboardMessage {145 export interface StoryboardMessage {
136 role: "user" | "ai"; text: string;146 role: "user" | "ai"; text: string;
modified web/src/project/steps/montage/useStoryboard.ts +15 -3
@@ -16,6 +16,8 @@ export function useStoryboard(pid: string) {
1616 const chatBusy = computed(() => !!view.value?.chat.busy);
1717 /** des animations de beats en file ou en cours */
1818 const animating = computed(() => Object.values(view.value?.anim_jobs ?? {}).some((a) => a.status === "queued" || a.status === "running"));
19+ /** des recherches d'images de beats en file ou en cours */
20+ const searching = computed(() => Object.values(view.value?.image_jobs ?? {}).some((a) => a.status === "queued" || a.status === "running"));
1921
2022 let timer: ReturnType<typeof setTimeout> | undefined;
2123 let saveTimer: ReturnType<typeof setTimeout> | undefined;
@@ -32,7 +34,7 @@ export function useStoryboard(pid: string) {
3234 async function poll() {
3335 await load();
3436 if (stopped) return;
35- timer = setTimeout(poll, busy.value || chatBusy.value || animating.value ? 1500 : 8000);
37+ timer = setTimeout(poll, busy.value || chatBusy.value || animating.value || searching.value ? 1500 : 8000);
3638 }
3739 poll();
3840 onUnmounted(() => { stopped = true; clearTimeout(timer); clearTimeout(saveTimer); });
@@ -87,6 +89,16 @@ export function useStoryboard(pid: string) {
8789 }
8890 }
8991
92+ /** La recherche d'images d'un beat : arrêtée, ou retirée de la file (sa discussion reste). */
93+ async function stopSearch(beat: string) {
94+ await post(`/api/projects/${pid}/assets/images/stop?beat=${encodeURIComponent(beat)}`).catch((e) => toast(e.message, true));
95+ await load();
96+ }
97+ async function dismissSearch(beat: string) {
98+ await post(`/api/projects/${pid}/assets/images/dismiss?beat=${encodeURIComponent(beat)}`).catch((e) => toast(e.message, true));
99+ await load();
100+ }
101+
90102 async function stopAnim(beat: string) {
91103 view.value = await post<StoryboardView>(`${base}/animate/stop`, { beat });
92104 }
@@ -118,7 +130,7 @@ export function useStoryboard(pid: string) {
118130 const v = await putJson<StoryboardView>(base, {
119131 beats: board.beats.map((b) => ({
120132 id: b.id, type: b.type, show: b.show, kind: b.kind, place: b.place, anim: b.anim ?? "", sfx: b.sfx, on: b.on,
121- zoom: b.zoom, src: b.src, search: b.search, start: b.start, enter: b.enter ?? null, exit: b.exit ?? null,
133+ zoom: b.zoom, src: b.src, images: b.images, search: b.search, start: b.start, enter: b.enter ?? null, exit: b.exit ?? null,
122134 at: b.at, dur: b.dur,
123135 })),
124136 });
@@ -136,7 +148,7 @@ export function useStoryboard(pid: string) {
136148 }
137149 }
138150
139- return { view, busy, chatBusy, animating, run, stop, send, stopChat, animate, add, stopAnim, edit, flush, load };
151+ return { view, busy, chatBusy, animating, run, stop, send, stopChat, animate, add, stopAnim, stopSearch, dismissSearch, edit, flush, load };
140152 }
141153
142154 export type StoryboardState = ReturnType<typeof useStoryboard>;
@@ -16,6 +16,8 @@ export function useStoryboard(pid: string) {
16 const chatBusy = computed(() => !!view.value?.chat.busy);16 const chatBusy = computed(() => !!view.value?.chat.busy);
17 /** des animations de beats en file ou en cours */17 /** des animations de beats en file ou en cours */
18 const animating = computed(() => Object.values(view.value?.anim_jobs ?? {}).some((a) => a.status === "queued" || a.status === "running"));18 const animating = computed(() => Object.values(view.value?.anim_jobs ?? {}).some((a) => a.status === "queued" || a.status === "running"));
19+ /** des recherches d'images de beats en file ou en cours */
20+ const searching = computed(() => Object.values(view.value?.image_jobs ?? {}).some((a) => a.status === "queued" || a.status === "running"));
19 21
20 let timer: ReturnType<typeof setTimeout> | undefined;22 let timer: ReturnType<typeof setTimeout> | undefined;
21 let saveTimer: ReturnType<typeof setTimeout> | undefined;23 let saveTimer: ReturnType<typeof setTimeout> | undefined;
@@ -32,7 +34,7 @@ export function useStoryboard(pid: string) {
32 async function poll() {34 async function poll() {
33 await load();35 await load();
34 if (stopped) return;36 if (stopped) return;
35- timer = setTimeout(poll, busy.value || chatBusy.value || animating.value ? 1500 : 8000);37+ timer = setTimeout(poll, busy.value || chatBusy.value || animating.value || searching.value ? 1500 : 8000);
36 }38 }
37 poll();39 poll();
38 onUnmounted(() => { stopped = true; clearTimeout(timer); clearTimeout(saveTimer); });40 onUnmounted(() => { stopped = true; clearTimeout(timer); clearTimeout(saveTimer); });
@@ -87,6 +89,16 @@ export function useStoryboard(pid: string) {
87 }89 }
88 }90 }
89 91
92+ /** La recherche d'images d'un beat : arrêtée, ou retirée de la file (sa discussion reste). */
93+ async function stopSearch(beat: string) {
94+ await post(`/api/projects/${pid}/assets/images/stop?beat=${encodeURIComponent(beat)}`).catch((e) => toast(e.message, true));
95+ await load();
96+ }
97+ async function dismissSearch(beat: string) {
98+ await post(`/api/projects/${pid}/assets/images/dismiss?beat=${encodeURIComponent(beat)}`).catch((e) => toast(e.message, true));
99+ await load();
100+ }
101+
90 async function stopAnim(beat: string) {102 async function stopAnim(beat: string) {
91 view.value = await post<StoryboardView>(`${base}/animate/stop`, { beat });103 view.value = await post<StoryboardView>(`${base}/animate/stop`, { beat });
92 }104 }
@@ -118,7 +130,7 @@ export function useStoryboard(pid: string) {
118 const v = await putJson<StoryboardView>(base, {130 const v = await putJson<StoryboardView>(base, {
119 beats: board.beats.map((b) => ({131 beats: board.beats.map((b) => ({
120 id: b.id, type: b.type, show: b.show, kind: b.kind, place: b.place, anim: b.anim ?? "", sfx: b.sfx, on: b.on,132 id: b.id, type: b.type, show: b.show, kind: b.kind, place: b.place, anim: b.anim ?? "", sfx: b.sfx, on: b.on,
121- zoom: b.zoom, src: b.src, search: b.search, start: b.start, enter: b.enter ?? null, exit: b.exit ?? null,133+ zoom: b.zoom, src: b.src, images: b.images, search: b.search, start: b.start, enter: b.enter ?? null, exit: b.exit ?? null,
122 at: b.at, dur: b.dur,134 at: b.at, dur: b.dur,
123 })),135 })),
124 });136 });
@@ -136,7 +148,7 @@ export function useStoryboard(pid: string) {
136 }148 }
137 }149 }
138 150
139- return { view, busy, chatBusy, animating, run, stop, send, stopChat, animate, add, stopAnim, edit, flush, load };151+ return { view, busy, chatBusy, animating, run, stop, send, stopChat, animate, add, stopAnim, stopSearch, dismissSearch, edit, flush, load };
140 }152 }
141 153
142 export type StoryboardState = ReturnType<typeof useStoryboard>;154 export type StoryboardState = ReturnType<typeof useStoryboard>;