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.
a675e4e parent: cc6717a modified
backend/studio/modules/projects/api.py +78 -13 | @@ -2,11 +2,12 @@ | ||
| 2 | 2 | API des projets : /api/projects/… |
| 3 | 3 | """ |
| 4 | 4 | import base64 |
| 5 | +import re | |
| 5 | 6 | import shutil |
| 6 | 7 | import time |
| 7 | 8 | import uuid |
| 8 | 9 | from pathlib import Path |
| 9 | -from urllib.parse import quote | |
| 10 | +from urllib.parse import quote, unquote, urlparse | |
| 10 | 11 | |
| 11 | 12 | from fastapi import APIRouter, File, Form, HTTPException, UploadFile |
| 12 | 13 | from fastapi.responses import FileResponse, Response |
| @@ -15,7 +16,7 @@ from pydantic import BaseModel | ||
| 15 | 16 | from ...core import jobs, system |
| 16 | 17 | from ..animations import kit |
| 17 | 18 | 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 | 20 | service, storyboard, storyboard_chat, thumbs, timeline, transitions) |
| 20 | 21 | from . import beat_anim |
| 21 | 22 | |
| @@ -1212,10 +1213,17 @@ def remove_download(pid: str, job_id: str): | ||
| 1212 | 1213 | |
| 1213 | 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 | 1223 | @router.get("/{pid}/assets/images") |
| 1216 | -def get_images(pid: str): | |
| 1224 | +def get_images(pid: str, beat: str = ""): | |
| 1217 | 1225 | _check(pid) |
| 1218 | - return assets.images_state(pid) | |
| 1226 | + return assets.images_state(pid, _beat(beat)) | |
| 1219 | 1227 | |
| 1220 | 1228 | |
| 1221 | 1229 | class ImagesRequest(BaseModel): |
| @@ -1223,21 +1231,54 @@ class ImagesRequest(BaseModel): | ||
| 1223 | 1231 | |
| 1224 | 1232 | |
| 1225 | 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 | 1235 | _check(pid) |
| 1236 | + beat = _beat(beat) | |
| 1228 | 1237 | if not req.text.strip(): |
| 1229 | 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 | 1240 | state = job.state if job else {"messages": [], "results": []} |
| 1232 | 1241 | if job and job.status == "error" and state["messages"] and state["messages"][-1]["role"] == "user": |
| 1233 | 1242 | # la demande restée sans réponse est remplacée par la nouvelle |
| 1234 | 1243 | state["messages"].pop() |
| 1235 | 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 | 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 | 1249 | except ValueError as e: |
| 1239 | 1250 | 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 | 1284 | class AddImagesRequest(BaseModel): |
| @@ -1247,19 +1288,43 @@ class AddImagesRequest(BaseModel): | ||
| 1247 | 1288 | |
| 1248 | 1289 | |
| 1249 | 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 | 1292 | _check(pid) |
| 1293 | + beat = _beat(beat) | |
| 1252 | 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 | 1296 | except ValueError as e: |
| 1255 | 1297 | 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 | 1324 | @router.get("/{pid}/assets/candidates/{name}") |
| 1260 | -def candidate(pid: str, name: str): | |
| 1325 | +def candidate(pid: str, name: str, beat: str = ""): | |
| 1261 | 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 | 1328 | if not path.is_file(): |
| 1264 | 1329 | raise HTTPException(404, "image inconnue") |
| 1265 | 1330 | return FileResponse(path) |
| @@ -2,11 +2,12 @@ | |||
| 2 | API des projets : /api/projects/… | 2 | API des projets : /api/projects/… |
| 3 | """ | 3 | """ |
| 4 | import base64 | 4 | import base64 |
| 5 | +import re | ||
| 5 | import shutil | 6 | import shutil |
| 6 | import time | 7 | import time |
| 7 | import uuid | 8 | import uuid |
| 8 | from pathlib import Path | 9 | from pathlib import Path |
| 9 | -from urllib.parse import quote | 10 | +from urllib.parse import quote, unquote, urlparse |
| 10 | 11 | ||
| 11 | from fastapi import APIRouter, File, Form, HTTPException, UploadFile | 12 | from fastapi import APIRouter, File, Form, HTTPException, UploadFile |
| 12 | from fastapi.responses import FileResponse, Response | 13 | from fastapi.responses import FileResponse, Response |
| @@ -15,7 +16,7 @@ from pydantic import BaseModel | |||
| 15 | from ...core import jobs, system | 16 | from ...core import jobs, system |
| 16 | from ..animations import kit | 17 | from ..animations import kit |
| 17 | from ..youtube import service as youtube | 18 | 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_anim | 21 | 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 nouvelle | 1242 | # 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 e | 1250 | 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 e | 1297 | 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: | ||
| 73 | 73 | ctx.set_status("downloading") |
| 74 | 74 | state["activity"] = "vérifie les images" |
| 75 | 75 | ctx.save(force=True) |
| 76 | - cache = assets.candidates_dir(p["project"]) | |
| 76 | + cache = assets.candidates_dir(p["project"], p.get("beat", "")) | |
| 77 | 77 | known = {r["url"] for r in results} |
| 78 | 78 | fresh, rejected = [], 0 |
| 79 | 79 | 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 = [], 0 | 78 | 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 | ||
| 19 | 19 | CANDIDATES_DIR = ".candidates" |
| 20 | 20 | |
| 21 | 21 | |
| 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 | |
| 24 | 26 | |
| 25 | 27 | |
| 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" | |
| 28 | 31 | |
| 29 | 32 | |
| 30 | 33 | # --- YouTube --------------------------------------------------------------------------- |
| @@ -109,8 +112,8 @@ def view(project: dict) -> dict: | ||
| 109 | 112 | |
| 110 | 113 | # --- Images ------------------------------------------------------------------------------ |
| 111 | 114 | |
| 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)) | |
| 114 | 117 | if not job: |
| 115 | 118 | return {"messages": [], "results": [], "busy": False, "error": None, "activity": None} |
| 116 | 119 | return {"messages": job.state.get("messages", []), "results": job.state.get("results", []), |
| @@ -118,19 +121,59 @@ def images_state(pid: str) -> dict: | ||
| 118 | 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: | |
| 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)) | |
| 124 | 167 | if not job: |
| 125 | 168 | raise ValueError("aucune recherche d'images") |
| 126 | 169 | if not job.finished: |
| 127 | 170 | raise ValueError("Claude cherche encore : attends la fin de la recherche") |
| 128 | 171 | folder, label = library.destination(pid, common, category) |
| 129 | - count = 0 | |
| 172 | + added = {} | |
| 130 | 173 | for r in job.state.get("results", []): |
| 131 | 174 | if r["id"] not in ids or r.get("added"): |
| 132 | 175 | continue |
| 133 | - src = candidates_dir(pid) / r["file"] | |
| 176 | + src = candidates_dir(pid, beat) / r["file"] | |
| 134 | 177 | if not src.is_file(): |
| 135 | 178 | continue |
| 136 | 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 | 182 | if common: |
| 140 | 183 | library.remember_origin(f"{category}/{dest.name}", project_title) |
| 141 | 184 | 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 | |
| 143 | 190 | 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_DIR | 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 | ||
| 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 = 0 | 172 | + 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 | continue | 175 | 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 | continue | 178 | 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"] = label | 184 | 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 | ||
| 143 | jobs.update(job.id, state=job.state) | 190 | jobs.update(job.id, state=job.state) |
| 144 | - return count | 191 | + 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 | ||
| 26 | 26 | "beats": [{"id", "type", "at", "dur", "anchor", "until", "says", "show", "kind", "place", |
| 27 | 27 | "motion": {"enter", "hold", "exit"}, "sfx", "ref", "why", "on", |
| 28 | 28 | "zoom": {"scale", "x", "y"}, # zoom |
| 29 | - "src", "search"}]} # broll : le média (vide : à trouver), la recherche | |
| 29 | + "src", "search", # broll : le média (vide : à trouver), la recherche | |
| 30 | + "images"}]} # motion : les images à y intégrer (facultatives) | |
| 30 | 31 | `at` / `dur` en secondes de la timeline. Les contrôles (`check`) sont refaits à chaque lecture : |
| 31 | 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 | 42 | from ...core.tasks import TaskContext |
| 42 | 43 | from ..settings.service import language_name |
| 43 | 44 | 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 | |
| 45 | 46 | |
| 46 | 47 | KIND = "project_storyboard" |
| 47 | 48 | FILE = "storyboard.json" |
| @@ -926,17 +927,21 @@ def retype(b: dict, kind) -> None: | ||
| 926 | 927 | """Change le type d'un beat, avec ce qu'il faut au nouveau type (sa durée ne bouge pas).""" |
| 927 | 928 | if kind not in TYPES or kind == b.get("type"): |
| 928 | 929 | 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 | 932 | b["type"] = kind |
| 930 | 933 | # 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 | 935 | if kind != owner: |
| 933 | 936 | b.pop(key, None) |
| 937 | + if kind == "motion" and images: | |
| 938 | + b["images"] = images | |
| 934 | 939 | if kind not in ("motion", "broll"): |
| 935 | 940 | b["enter"] = b["exit"] = None |
| 936 | 941 | if kind == "zoom": |
| 937 | 942 | b.setdefault("zoom", zoom_of(None)) |
| 938 | 943 | if kind == "broll": |
| 939 | - b.setdefault("src", "") | |
| 944 | + b.setdefault("src", images[0] if images else "") | |
| 940 | 945 | b.setdefault("search", "") |
| 941 | 946 | b.setdefault("start", 0.0) |
| 942 | 947 | if b.get("place") not in ("full", "left", "right"): |
| @@ -977,6 +982,14 @@ def edit(pid: str, changes: list[dict]) -> None: | ||
| 977 | 982 | b["search"] = c["search"].strip()[:200] |
| 978 | 983 | if "start" in c: |
| 979 | 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 | 993 | if c.get("kind") in KINDS: |
| 981 | 994 | b["kind"] = c["kind"] |
| 982 | 995 | if c.get("place") in PLACES: |
| @@ -1097,9 +1110,28 @@ def animated(pid: str, timeline: dict) -> dict[str, str]: | ||
| 1097 | 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: | |
| 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 | |
| 1103 | 1135 | |
| 1104 | 1136 | |
| 1105 | 1137 | def _ends(beat: dict) -> dict: |
| @@ -1143,7 +1175,10 @@ def compose(pid: str, only: list[str] | None = None) -> dict: | ||
| 1143 | 1175 | if not b.get("on") or (only is not None and b["id"] not in only) or b["id"] in written: |
| 1144 | 1176 | continue |
| 1145 | 1177 | 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) | |
| 1147 | 1182 | out["motions"].append(done[b["id"]]) |
| 1148 | 1183 | continue |
| 1149 | 1184 | kind = b.get("type", "motion") |
| @@ -1174,13 +1209,13 @@ def compose(pid: str, only: list[str] | None = None) -> dict: | ||
| 1174 | 1209 | else: |
| 1175 | 1210 | if kind == "broll": |
| 1176 | 1211 | # 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): | |
| 1179 | 1213 | out["missing"].append(b["id"]) |
| 1180 | 1214 | 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) | |
| 1184 | 1219 | mid = next_id("motion") |
| 1185 | 1220 | timeline["motion"].append({"id": mid, "at": b["at"], "dur": b["dur"], "title": b["show"][:60], |
| 1186 | 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 | 1272 | 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 | 1294 | def brief(pid: str, ids: list[str]) -> str: |
| 1241 | 1295 | """La fiche de chaque motion à composer : ce que le storyboard validé demande.""" |
| 1242 | 1296 | board = load(pid) or {"beats": []} |
| @@ -1264,6 +1318,7 @@ def brief(pid: str, ids: list[str]) -> str: | ||
| 1264 | 1318 | lines.append(f"- {FULL_SCREEN}") |
| 1265 | 1319 | if b.get("anim"): |
| 1266 | 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 | 1322 | if b.get("enter") or b.get("exit"): |
| 1268 | 1323 | lines.append("- Le montage ajoute " + " et ".join( |
| 1269 | 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 | 1353 | "types": TYPES, "kinds": KINDS, "places": PLACES, "sfx": ["", *(x["src"] for x in sfx)], |
| 1299 | 1354 | "visuals": visuals(pid), |
| 1300 | 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 | 1357 | # les mots de la timeline [début, fin], où s'aimantent les beats qu'on pose ou tire à la main |
| 1303 | 1358 | "words": [[w.start, w.end] for w in words], "lead": LEAD, "tail": TAIL} |
| 1304 | 1359 | if board: |
| @@ -1309,6 +1364,7 @@ def view(pid: str) -> dict: | ||
| 1309 | 1364 | b["anim"] = resolve_anim(b.get("anim", ""), [a["name"] for a in anims]) |
| 1310 | 1365 | out["composed"] = animated(pid, timeline) |
| 1311 | 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 | 1368 | total = sum(c["out"] - c["in"] for c in timeline["aroll"]) |
| 1313 | 1369 | covers = [p for p in script_plans(pid, words) if not p["motion"] and p["at"] is not None] |
| 1314 | 1370 | 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"}, # zoom | 28 | "zoom": {"scale", "x", "y"}, # zoom |
| 29 | - "src", "search"}]} # broll : le média (vide : à trouver), la recherche | 29 | + "src", "search", # broll : le média (vide : à trouver), la recherche |
| 30 | + "images"}]} # motion : les images à y intégrer (facultatives) | ||
| 30 | `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 TaskContext | 42 | from ...core.tasks import TaskContext |
| 42 | from ..settings.service import language_name | 43 | from ..settings.service import language_name |
| 43 | from ..transcript import cues | 44 | 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 |
| 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 | return | 929 | 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"] = kind | 932 | b["type"] = kind |
| 930 | # ce qui ne sert qu'à l'ancien type s'en va | 933 | # 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"] = None | 940 | 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 | continue | 1176 | 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 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) | ||
| 1147 | out["motions"].append(done[b["id"]]) | 1182 | out["motions"].append(done[b["id"]]) |
| 1148 | continue | 1183 | 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 compositions | 1211 | # 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 | continue | 1214 | 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 lines | 1272 | 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 main | 1357 | # 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: | ||
| 152 | 152 | elif kind == "transition": |
| 153 | 153 | what = "transition" |
| 154 | 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 | 158 | ends = "".join(f" · {k} {b[k]['fx']}" for k in ("enter", "exit") if b.get(k)) |
| 157 | 159 | return (f"- {b['id']}{state} [{storyboard._clock(b['at'])} → {storyboard._clock(b['at'] + b['dur'])}] " |
| 158 | 160 | 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 { | ||
| 142 | 142 | askTitle: "Draft a message to Daryl, the editor, about this beat", |
| 143 | 143 | askText: "About beat {id} ({tc}): ", |
| 144 | 144 | hint: "Drag its edges on the timeline to cover its passage: they snap to words. Fine settings in the Storyboard step.", |
| 145 | + 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 | 172 | storyboard: { |
| 147 | 173 | tab: "Storyboard", |
| @@ -250,6 +276,12 @@ export default { | ||
| 250 | 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 | 285 | tasks: { |
| 254 | 286 | queued: "queued", |
| 255 | 287 | 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 { | ||
| 55 | 55 | resultsEmpty: "Results will appear here.", |
| 56 | 56 | addedTag: "Added", |
| 57 | 57 | source: "source ↗", |
| 58 | + restart: "New search", | |
| 58 | 59 | validateN: "Add | Add 1 image | Add {n} images", |
| 59 | 60 | }, |
| 60 | 61 | 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 { | ||
| 142 | 142 | askTitle: "Préparer un message à Daryl, le monteur, sur ce beat", |
| 143 | 143 | askText: "À propos du beat {id} ({tc}) : ", |
| 144 | 144 | hint: "Tire ses bords sur la timeline pour couvrir son passage : ils s’aimantent aux mots. Réglages fins à l’étape Storyboard.", |
| 145 | + 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 | 172 | storyboard: { |
| 147 | 173 | tab: "Storyboard", |
| @@ -250,6 +276,12 @@ export default { | ||
| 250 | 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 | 285 | tasks: { |
| 254 | 286 | queued: "en file", |
| 255 | 287 | 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 { | ||
| 55 | 55 | resultsEmpty: "Les résultats apparaîtront ici.", |
| 56 | 56 | addedTag: "Ajoutée", |
| 57 | 57 | source: "source ↗", |
| 58 | + restart: "Nouvelle recherche", | |
| 58 | 59 | validateN: "Valider | Valider {n} image | Valider {n} images", |
| 59 | 60 | }, |
| 60 | 61 | 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 @@ | ||
| 2 | 2 | /** |
| 3 | 3 | * Onglet Images : on dit à Claude ce qu'on cherche (logo d'une marque, photo d'une personne…), il |
| 4 | 4 | * 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 | 9 | import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"; |
| 8 | 10 | import { useI18n } from "vue-i18n"; |
| 9 | -import { api, post } from "../../lib/api"; | |
| 11 | +import { api, del, post } from "../../lib/api"; | |
| 10 | 12 | import { usePref } from "../../lib/composables"; |
| 11 | 13 | import { toast } from "../../lib/toast"; |
| 12 | 14 | import { store } from "../store"; |
| @@ -14,11 +16,15 @@ import type { ImagesState } from "../types"; | ||
| 14 | 16 | import CheckBox from "./CheckBox.vue"; |
| 15 | 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 | 22 | const { t } = useI18n(); |
| 19 | 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 | 26 | const state = ref<ImagesState>({ messages: [], results: [], busy: false, error: null, activity: null }); |
| 21 | -const input = ref(""); | |
| 27 | +const input = ref(props.prefill ?? ""); | |
| 22 | 28 | const sending = ref(false); |
| 23 | 29 | const picked = ref(new Set<string>()); |
| 24 | 30 | const scroller = ref<HTMLElement>(); |
| @@ -35,10 +41,10 @@ function receive(s: ImagesState): void { | ||
| 35 | 41 | if (first && first.query === asked) picked.value = new Set([...picked.value, first.id]); |
| 36 | 42 | } |
| 37 | 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 | 48 | onBeforeUnmount(() => clearTimeout(timer)); |
| 43 | 49 | |
| 44 | 50 | watch(() => state.value.messages.length + (state.value.busy ? 1 : 0), async () => { |
| @@ -51,7 +57,7 @@ async function send() { | ||
| 51 | 57 | if (!text || sending.value || state.value.busy) return; |
| 52 | 58 | sending.value = true; |
| 53 | 59 | try { |
| 54 | - receive(await post<ImagesState>(`${base}/images`, { text })); | |
| 60 | + receive(await post<ImagesState>(`${base}/images${scope}`, { text })); | |
| 55 | 61 | input.value = ""; |
| 56 | 62 | } catch (e) { |
| 57 | 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 | 80 | function toggle(id: string) { |
| 64 | 81 | const s = new Set(picked.value); |
| 65 | 82 | if (s.has(id)) s.delete(id); |
| @@ -75,25 +92,28 @@ const category = usePref("assets.imgCategory", "logos"); | ||
| 75 | 92 | async function validate() { |
| 76 | 93 | if (!selection.value.length) return toast(t("sourcing.images.checkOne"), true); |
| 77 | 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 | 96 | ids: selection.value.map((r) => r.id), common: common.value, category: common.value ? category.value : "images", |
| 80 | 97 | }); |
| 81 | 98 | picked.value = new Set(); |
| 82 | 99 | receive(out); |
| 83 | 100 | const dest = out.results.find((r) => r.added)?.added ?? ""; |
| 84 | 101 | toast(t("sourcing.images.added", { dest }, out.added)); |
| 85 | - if (out.added) emit("added"); | |
| 102 | + if (out.added) emit("added", out.srcs); | |
| 86 | 103 | } catch (e) { |
| 87 | 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 | 109 | </script> |
| 93 | 110 | |
| 94 | 111 | <template> |
| 95 | 112 | <div class="images"> |
| 96 | 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 | 117 | <div ref="scroller" class="messages"> |
| 98 | 118 | <div class="msg ai">{{ t("sourcing.images.hello") }}</div> |
| 99 | 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 | 162 | <style scoped> |
| 143 | 163 | .images { display: grid; grid-template-columns: 340px minmax(0, 1fr); gap: 24px; align-items: start; } |
| 144 | 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 | 167 | .messages { flex: 1; overflow: auto; padding: 14px; display: flex; flex-direction: column; gap: 10px; } |
| 146 | 168 | .msg { max-width: 88%; padding: 8px 11px; border-radius: 6px; font-size: 13px; line-height: 1.45; white-space: pre-wrap; } |
| 147 | 169 | .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…), il | 3 | * 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 coche | 4 | * 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"; | ||
| 16 | 16 | import { store } from "../store"; |
| 17 | 17 | import { usePlaylist, type Piece } from "./derush/usePlaylist"; |
| 18 | 18 | import BeatPanel from "./montage/BeatPanel.vue"; |
| 19 | +import type { ImageTab } from "./montage/BeatImageModal.vue"; | |
| 20 | +import ImageSearchQueue from "./montage/ImageSearchQueue.vue"; | |
| 19 | 21 | import Inspector from "./montage/Inspector.vue"; |
| 20 | 22 | import MonteurChat from "./montage/MonteurChat.vue"; |
| 21 | 23 | import TaskQueue from "./montage/TaskQueue.vue"; |
| @@ -171,6 +173,19 @@ function openBeat(id: string) { | ||
| 171 | 173 | selection.value = null; |
| 172 | 174 | } |
| 173 | 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 | 189 | /** un beat déplacé ou tiré sur la timeline (enregistré au lâcher) */ |
| 175 | 190 | function moveBeat(id: string, at: number, dur: number) { |
| 176 | 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 | 206 | if (!b) return; |
| 192 | 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é) */ | |
| 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]); | |
| 196 | 219 | SB.edit((list) => { |
| 197 | 220 | const b = list.find((x) => x.id === id); |
| 198 | 221 | if (!b) return; |
| @@ -200,6 +223,16 @@ function pickMedia(id: string, src: string) { | ||
| 200 | 223 | if (isImage(src) && !["full", "left", "right"].includes(b.place)) b.place = "full"; |
| 201 | 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 | 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 | 237 | async function removeBeat(id: string) { |
| 205 | 238 | const b = pending.value.find((x) => x.id === id); |
| @@ -341,7 +374,9 @@ onUnmounted(() => window.removeEventListener("keydown", onKey)); | ||
| 341 | 374 | @pick-text="(id: string) => (selection = { item: { track: 'texts', id } })" @move-text="moveText" /> |
| 342 | 375 | <BeatPanel v-if="selBeat" class="insp" :beat="selBeat" :anims="anims" :visuals="SB.view.value?.visuals ?? []" |
| 343 | 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 | 380 | <Inspector v-else v-model:selection="selection" class="insp" :tl="tl" :view="view" :available="M.available.value" |
| 346 | 381 | :t="t" :total="M.total.value" @edit="edit" @comment="comment" @seek="seek" @beat="addBeat" |
| 347 | 382 | @insert="(m: AvailableMedia) => insertClip(m, t)" @media="M.loadAvailable()" /> |
| @@ -404,6 +439,7 @@ onUnmounted(() => window.removeEventListener("keydown", onKey)); | ||
| 404 | 439 | <span class="spacer" /> |
| 405 | 440 | <button class="link-btn new" :disabled="!current" @click="newRequest">{{ tr("editing.chat.new") }}</button> |
| 406 | 441 | </div> |
| 442 | + <ImageSearchQueue v-if="imageJobs.length" :jobs="imageJobs" @open="openSearch" @stop="SB.stopSearch" @dismiss="SB.dismissSearch" /> | |
| 407 | 443 | <TaskQueue v-if="view.tasks.length" :tasks="view.tasks" :current="current" :describe="describe" |
| 408 | 444 | @open="openTask" @stop="M.stop" @close="M.close" /> |
| 409 | 445 | <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 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | 2 | /** |
| 3 | 3 | * À 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 | |
| 5 | 5 | * le zoom ou le B-roll, ou en parler à Daryl. Les réglages fins restent à l'étape Storyboard. |
| 6 | 6 | */ |
| 7 | 7 | import { computed } from "vue"; |
| 8 | 8 | import { useI18n } from "vue-i18n"; |
| 9 | +import BeatImage from "./BeatImage.vue"; | |
| 10 | +import type { ImageTab } from "./BeatImageModal.vue"; | |
| 9 | 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 | 14 | const emit = defineEmits<{ |
| 13 | 15 | /** une retouche du beat ; `delay` : regroupe les frappes */ |
| 14 | 16 | change: [(b: Beat) => void, number?]; |
| 15 | 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 | 21 | const { t: tr } = useI18n(); |
| 18 | 22 | |
| 19 | 23 | const anim = computed(() => props.anims[props.beat.id]); |
| 20 | 24 | const live = computed(() => ["queued", "running"].includes(anim.value?.status ?? "")); |
| 21 | -const shortName = (name: string) => name.replace(/\.[^.]+$/, ""); | |
| 22 | 25 | /** zoom et B-roll vidéo se posent tels quels ; un B-roll sans média attend le sien */ |
| 23 | 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 | 39 | </script> |
| 43 | 40 | |
| 44 | 41 | <template> |
| @@ -66,17 +63,8 @@ function setSrc(src: string) { | ||
| 66 | 63 | </select> |
| 67 | 64 | </label> |
| 68 | 65 | |
| 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)" /> | |
| 80 | 68 | |
| 81 | 69 | <p v-if="anim?.status === 'error'" class="err">{{ tr("editing.anims.failed", { e: anim.error }) }}</p> |
| 82 | 70 | <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 y | 3 | * À 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 |
| 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"; | ||
| 18 | 18 | import LayerVideo from "./LayerVideo.vue"; |
| 19 | 19 | import { releaseMedia } from "../../../lib/media"; |
| 20 | 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 | 22 | } from "./types"; |
| 23 | 23 | |
| 24 | 24 | const props = defineProps<{ |
| @@ -351,8 +351,8 @@ defineExpose({ capture }); | ||
| 351 | 351 | <RouterLink class="btn sm" :to="`/p/${route.params.pid}/ressources?add=youtube`">{{ tr("editing.storyboard.broll.goMedia") }}</RouterLink> |
| 352 | 352 | </span> |
| 353 | 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) }]"> | |
| 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=""> | |
| 356 | 356 | <button v-if="create" class="x-btn s-remove" :title="tr('editing.storyboard.remove')" @click.stop="emit('remove', beat.id)">×</button> |
| 357 | 357 | <span class="s-kind mono">{{ slideKind(beat) }}</span> |
| 358 | 358 | <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 { | ||
| 90 | 90 | zoom?: { scale: number; x: number; y: number }; |
| 91 | 91 | /** broll : le média (vide : à trouver), la recherche toute prête, et pour une vidéo la seconde où le plan commence */ |
| 92 | 92 | src?: string; search?: string; start?: number; |
| 93 | + /** motion : les images à intégrer à l'animation (facultatives) */ | |
| 94 | + images?: string[]; | |
| 93 | 95 | /** motion et broll : les transitions d'entrée et de sortie au montage */ |
| 94 | 96 | enter?: Fx | null; exit?: Fx | null; |
| 95 | 97 | } |
| 96 | 98 | const IMAGE = /\.(png|jpe?g|webp|gif|svg)$/i; |
| 97 | 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 | 104 | /** Le beat passe par une animation écrite par Daryl ; sinon (zoom, B-roll vidéo) il est posé tel quel. */ |
| 99 | 105 | export const needsAnim = (b: Beat) => b.type === "motion" || b.type === "transition" || (b.type === "broll" && isImage(b.src)); |
| 100 | 106 | /** La couleur de chaque type de beat (bande de rythme, cartes, timeline). */ |
| @@ -127,10 +133,14 @@ export interface StoryboardView { | ||
| 127 | 133 | chat: StoryboardChat; |
| 128 | 134 | /** la création de l'animation des beats qui en ont une en cours, en file ou ratée */ |
| 129 | 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 | 138 | /** les mots de la timeline [début, fin], où s'aimantent les beats posés à la main ; un beat commence `lead` s |
| 131 | 139 | * avant son premier mot et finit `tail` s après le dernier */ |
| 132 | 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 | 144 | export interface AnimState { status: "queued" | "running" | "error" | "cancelled"; activity: string | null; error: string | null } |
| 135 | 145 | export interface StoryboardMessage { |
| 136 | 146 | 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` s | 138 | /** 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) { | ||
| 16 | 16 | const chatBusy = computed(() => !!view.value?.chat.busy); |
| 17 | 17 | /** des animations de beats en file ou en cours */ |
| 18 | 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 | 22 | let timer: ReturnType<typeof setTimeout> | undefined; |
| 21 | 23 | let saveTimer: ReturnType<typeof setTimeout> | undefined; |
| @@ -32,7 +34,7 @@ export function useStoryboard(pid: string) { | ||
| 32 | 34 | async function poll() { |
| 33 | 35 | await load(); |
| 34 | 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 | 39 | poll(); |
| 38 | 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 | 102 | async function stopAnim(beat: string) { |
| 91 | 103 | view.value = await post<StoryboardView>(`${base}/animate/stop`, { beat }); |
| 92 | 104 | } |
| @@ -118,7 +130,7 @@ export function useStoryboard(pid: string) { | ||
| 118 | 130 | const v = await putJson<StoryboardView>(base, { |
| 119 | 131 | beats: board.beats.map((b) => ({ |
| 120 | 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 | 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 | 154 | 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>; |