feat(assets): capture a source from its row or replace it with your own image
Each source row in the Documentation tab gets its own capture button (queued onto the running capture job if there is one) and an upload to use a custom image instead of the screenshot; manual images are not overwritten by capture results.
bced829 parent: ef2bf51 modified
backend/studio/modules/projects/api.py +54 -11 | @@ -1176,29 +1176,72 @@ def candidate(pid: str, name: str): | ||
| 1176 | 1176 | |
| 1177 | 1177 | # --- Assets : captures des sources ------------------------------------------------------ |
| 1178 | 1178 | |
| 1179 | +class CaptureRequest(BaseModel): | |
| 1180 | + # des sources précises (capturées à nouveau si elles l'étaient déjà) ; sinon les cochées pas encore capturées | |
| 1181 | + urls: list[str] | None = None | |
| 1182 | + | |
| 1183 | + | |
| 1184 | +def _is_web(url: str) -> bool: | |
| 1185 | + return url.startswith(("http://", "https://")) | |
| 1186 | + | |
| 1187 | + | |
| 1179 | 1188 | @router.post("/{pid}/assets/captures") |
| 1180 | -def capture_sources(pid: str): | |
| 1181 | - """Capture les sources cochées qui ne le sont pas encore (celles en erreur sont retentées).""" | |
| 1189 | +def capture_sources(pid: str, req: CaptureRequest | None = None): | |
| 1190 | + """Capture des sources (celles en erreur sont retentées) ; en cours, le job les ajoute à sa file.""" | |
| 1182 | 1191 | _check(pid) |
| 1192 | + urls = req.urls if req else None | |
| 1183 | 1193 | project = service.load_synced(pid, runtime.sync) |
| 1184 | 1194 | a = project["assets"] |
| 1185 | - sources = [{"u": s["u"], "t": s.get("t") or s["u"]} for s in project["research"] | |
| 1186 | - if s.get("u", "").startswith(("http://", "https://")) and a["capture"].get(s["u"]) | |
| 1187 | - and not (a["captures"].get(s["u"]) or {}).get("rel")] | |
| 1195 | + sources = [{"u": s["u"], "t": s.get("t") or s["u"]} for s in project["research"] if _is_web(s.get("u", "")) | |
| 1196 | + and (s["u"] in urls if urls is not None | |
| 1197 | + else a["capture"].get(s["u"]) and not (a["captures"].get(s["u"]) or {}).get("rel"))] | |
| 1198 | + queued = set(assets.capture_queue(project)) | |
| 1199 | + sources = [s for s in sources if s["u"] not in queued] | |
| 1188 | 1200 | if not sources: |
| 1201 | + if urls and queued.issuperset(urls): | |
| 1202 | + return _view(pid) | |
| 1189 | 1203 | raise HTTPException(400, "aucune source à capturer") |
| 1190 | - folder, _ = library.destination(pid, False, "images") | |
| 1191 | - try: | |
| 1192 | - job = service.requeue(f"{pid}-captures", "project_captures", | |
| 1193 | - {"project": pid, "sources": sources, "dest": str(folder)}, {"done": {}}) | |
| 1194 | - except ValueError as e: | |
| 1195 | - raise HTTPException(409, "les captures sont déjà en cours") from e | |
| 1204 | + with service.editing(pid) as p: | |
| 1205 | + for s in sources: | |
| 1206 | + p["assets"]["capture"][s["u"]] = True | |
| 1207 | + # recapturer remplace l'image choisie à la main | |
| 1208 | + if (p["assets"]["captures"].get(s["u"]) or {}).get("manual"): | |
| 1209 | + del p["assets"]["captures"][s["u"]] | |
| 1210 | + job_id = f"{pid}-captures" | |
| 1211 | + job = jobs.get(job_id) | |
| 1212 | + if job and not job.finished: | |
| 1213 | + jobs.update(job_id, payload={**job.payload, "sources": job.payload["sources"] + sources}) | |
| 1214 | + job = jobs.get(job_id) | |
| 1215 | + if not job or job.finished: | |
| 1216 | + folder, _ = library.destination(pid, False, "images") | |
| 1217 | + job = service.requeue(job_id, "project_captures", | |
| 1218 | + {"project": pid, "sources": sources, "dest": str(folder)}, {"done": {}, "next": 0}) | |
| 1196 | 1219 | with service.editing(pid) as p: |
| 1197 | 1220 | p["jobs"]["captures"] = job.id |
| 1198 | 1221 | service.start_step(p, "assets") |
| 1199 | 1222 | return _view(pid) |
| 1200 | 1223 | |
| 1201 | 1224 | |
| 1225 | +@router.post("/{pid}/assets/captures/upload") | |
| 1226 | +def upload_capture(pid: str, url: str = Form(...), file: UploadFile = File(...)): | |
| 1227 | + """Une image choisie à la main pour une source, à la place de la capture.""" | |
| 1228 | + _check(pid) | |
| 1229 | + project = service.load(pid) | |
| 1230 | + source = next((s for s in project["research"] if s.get("u") == url), None) | |
| 1231 | + if not source: | |
| 1232 | + raise HTTPException(404, "source inconnue") | |
| 1233 | + ext = Path(file.filename or "").suffix.lower() | |
| 1234 | + if ext not in library.IMAGE_EXT or ext == ".psd": | |
| 1235 | + raise HTTPException(400, "il faut une image (png, jpg, webp, gif, svg)") | |
| 1236 | + folder, _ = library.destination(pid, False, "images") | |
| 1237 | + file.filename = f"Capture - {(source.get('t') or url)[:80]}{ext}" | |
| 1238 | + dest = _save_upload(file, folder) | |
| 1239 | + with service.editing(pid) as p: | |
| 1240 | + p["assets"]["captures"][url] = {"rel": dest.relative_to(service.project_dir(pid)).as_posix(), | |
| 1241 | + "error": None, "manual": True} | |
| 1242 | + return _view(pid) | |
| 1243 | + | |
| 1244 | + | |
| 1202 | 1245 | # --- Montage de la vidéo longue ---------------------------------------------------------- |
| 1203 | 1246 | |
| 1204 | 1247 | def _montage_tasks(pid: str) -> list[jobs.Job]: |
| @@ -1176,29 +1176,72 @@ def candidate(pid: str, name: str): | |||
| 1176 | 1176 | ||
| 1177 | # --- Assets : captures des sources ------------------------------------------------------ | 1177 | # --- Assets : captures des sources ------------------------------------------------------ |
| 1178 | 1178 | ||
| 1179 | +class CaptureRequest(BaseModel): | ||
| 1180 | + # des sources précises (capturées à nouveau si elles l'étaient déjà) ; sinon les cochées pas encore capturées | ||
| 1181 | + urls: list[str] | None = None | ||
| 1182 | + | ||
| 1183 | + | ||
| 1184 | +def _is_web(url: str) -> bool: | ||
| 1185 | + return url.startswith(("http://", "https://")) | ||
| 1186 | + | ||
| 1187 | + | ||
| 1179 | @router.post("/{pid}/assets/captures") | 1188 | @router.post("/{pid}/assets/captures") |
| 1180 | -def capture_sources(pid: str): | 1189 | +def capture_sources(pid: str, req: CaptureRequest | None = None): |
| 1181 | - """Capture les sources cochées qui ne le sont pas encore (celles en erreur sont retentées).""" | 1190 | + """Capture des sources (celles en erreur sont retentées) ; en cours, le job les ajoute à sa file.""" |
| 1182 | _check(pid) | 1191 | _check(pid) |
| 1192 | + urls = req.urls if req else None | ||
| 1183 | project = service.load_synced(pid, runtime.sync) | 1193 | project = service.load_synced(pid, runtime.sync) |
| 1184 | a = project["assets"] | 1194 | a = project["assets"] |
| 1185 | - sources = [{"u": s["u"], "t": s.get("t") or s["u"]} for s in project["research"] | 1195 | + sources = [{"u": s["u"], "t": s.get("t") or s["u"]} for s in project["research"] if _is_web(s.get("u", "")) |
| 1186 | - if s.get("u", "").startswith(("http://", "https://")) and a["capture"].get(s["u"]) | 1196 | + and (s["u"] in urls if urls is not None |
| 1187 | - and not (a["captures"].get(s["u"]) or {}).get("rel")] | 1197 | + else a["capture"].get(s["u"]) and not (a["captures"].get(s["u"]) or {}).get("rel"))] |
| 1198 | + queued = set(assets.capture_queue(project)) | ||
| 1199 | + sources = [s for s in sources if s["u"] not in queued] | ||
| 1188 | if not sources: | 1200 | if not sources: |
| 1201 | + if urls and queued.issuperset(urls): | ||
| 1202 | + return _view(pid) | ||
| 1189 | raise HTTPException(400, "aucune source à capturer") | 1203 | raise HTTPException(400, "aucune source à capturer") |
| 1190 | - folder, _ = library.destination(pid, False, "images") | 1204 | + with service.editing(pid) as p: |
| 1191 | - try: | 1205 | + for s in sources: |
| 1192 | - job = service.requeue(f"{pid}-captures", "project_captures", | 1206 | + p["assets"]["capture"][s["u"]] = True |
| 1193 | - {"project": pid, "sources": sources, "dest": str(folder)}, {"done": {}}) | 1207 | + # recapturer remplace l'image choisie à la main |
| 1194 | - except ValueError as e: | 1208 | + if (p["assets"]["captures"].get(s["u"]) or {}).get("manual"): |
| 1195 | - raise HTTPException(409, "les captures sont déjà en cours") from e | 1209 | + del p["assets"]["captures"][s["u"]] |
| 1210 | + job_id = f"{pid}-captures" | ||
| 1211 | + job = jobs.get(job_id) | ||
| 1212 | + if job and not job.finished: | ||
| 1213 | + jobs.update(job_id, payload={**job.payload, "sources": job.payload["sources"] + sources}) | ||
| 1214 | + job = jobs.get(job_id) | ||
| 1215 | + if not job or job.finished: | ||
| 1216 | + folder, _ = library.destination(pid, False, "images") | ||
| 1217 | + job = service.requeue(job_id, "project_captures", | ||
| 1218 | + {"project": pid, "sources": sources, "dest": str(folder)}, {"done": {}, "next": 0}) | ||
| 1196 | with service.editing(pid) as p: | 1219 | with service.editing(pid) as p: |
| 1197 | p["jobs"]["captures"] = job.id | 1220 | p["jobs"]["captures"] = job.id |
| 1198 | service.start_step(p, "assets") | 1221 | service.start_step(p, "assets") |
| 1199 | return _view(pid) | 1222 | return _view(pid) |
| 1200 | 1223 | ||
| 1201 | 1224 | ||
| 1225 | +@router.post("/{pid}/assets/captures/upload") | ||
| 1226 | +def upload_capture(pid: str, url: str = Form(...), file: UploadFile = File(...)): | ||
| 1227 | + """Une image choisie à la main pour une source, à la place de la capture.""" | ||
| 1228 | + _check(pid) | ||
| 1229 | + project = service.load(pid) | ||
| 1230 | + source = next((s for s in project["research"] if s.get("u") == url), None) | ||
| 1231 | + if not source: | ||
| 1232 | + raise HTTPException(404, "source inconnue") | ||
| 1233 | + ext = Path(file.filename or "").suffix.lower() | ||
| 1234 | + if ext not in library.IMAGE_EXT or ext == ".psd": | ||
| 1235 | + raise HTTPException(400, "il faut une image (png, jpg, webp, gif, svg)") | ||
| 1236 | + folder, _ = library.destination(pid, False, "images") | ||
| 1237 | + file.filename = f"Capture - {(source.get('t') or url)[:80]}{ext}" | ||
| 1238 | + dest = _save_upload(file, folder) | ||
| 1239 | + with service.editing(pid) as p: | ||
| 1240 | + p["assets"]["captures"][url] = {"rel": dest.relative_to(service.project_dir(pid)).as_posix(), | ||
| 1241 | + "error": None, "manual": True} | ||
| 1242 | + return _view(pid) | ||
| 1243 | + | ||
| 1244 | + | ||
| 1202 | # --- Montage de la vidéo longue ---------------------------------------------------------- | 1245 | # --- Montage de la vidéo longue ---------------------------------------------------------- |
| 1203 | 1246 | ||
| 1204 | def _montage_tasks(pid: str) -> list[jobs.Job]: | 1247 | def _montage_tasks(pid: str) -> list[jobs.Job]: |
modified
backend/studio/modules/projects/asset_tasks.py +11 -3 | @@ -14,6 +14,7 @@ from urllib.parse import urlparse | ||
| 14 | 14 | |
| 15 | 15 | import certifi |
| 16 | 16 | |
| 17 | +from ...core import jobs | |
| 17 | 18 | from ...core.system import browser |
| 18 | 19 | from ...core.tasks import TaskContext |
| 19 | 20 | from . import assets, prompts, service |
| @@ -116,15 +117,21 @@ def screenshot(url: str, dest: Path) -> None: | ||
| 116 | 117 | |
| 117 | 118 | |
| 118 | 119 | def captures(ctx: TaskContext) -> None: |
| 120 | + """Les sources de la file, dans l'ordre ; l'API peut en ajouter pendant qu'elle tourne (payload relu à chaque | |
| 121 | + tour, `next` : la prochaine à traiter).""" | |
| 119 | 122 | p, state = ctx.payload, ctx.state |
| 120 | 123 | root = service.project_dir(p["project"]) |
| 121 | 124 | folder = Path(p["dest"]) |
| 122 | 125 | folder.mkdir(parents=True, exist_ok=True) |
| 123 | 126 | done = state.setdefault("done", {}) |
| 124 | - todo = [s for s in p["sources"] if s["u"] not in done] | |
| 125 | 127 | ctx.set_status("capturing") |
| 126 | - for i, s in enumerate(todo): | |
| 128 | + while True: | |
| 129 | + sources = (jobs.get(ctx.job.id) or ctx.job).payload["sources"] | |
| 130 | + i = state.setdefault("next", 0) | |
| 131 | + if i >= len(sources): | |
| 132 | + break | |
| 127 | 133 | ctx.check_cancel() |
| 134 | + s = sources[i] | |
| 128 | 135 | state["activity"] = f"capture {urlparse(s['u']).netloc or s['u']}" |
| 129 | 136 | ctx.save(force=True) |
| 130 | 137 | dest = service.unique_path(folder, service.safe_name(f"Capture - {s['t'][:80]}.png")) |
| @@ -133,6 +140,7 @@ def captures(ctx: TaskContext) -> None: | ||
| 133 | 140 | done[s["u"]] = {"rel": dest.relative_to(root).as_posix(), "error": None} |
| 134 | 141 | except Exception as e: |
| 135 | 142 | done[s["u"]] = {"rel": None, "error": str(e)[:200]} |
| 136 | - ctx.progress = (i + 1) / len(todo) | |
| 143 | + state["next"] = i + 1 | |
| 144 | + ctx.progress = (i + 1) / len(sources) | |
| 137 | 145 | ctx.save(force=True) |
| 138 | 146 | state["activity"] = None |
| @@ -14,6 +14,7 @@ from urllib.parse import urlparse | |||
| 14 | 14 | ||
| 15 | import certifi | 15 | import certifi |
| 16 | 16 | ||
| 17 | +from ...core import jobs | ||
| 17 | from ...core.system import browser | 18 | from ...core.system import browser |
| 18 | from ...core.tasks import TaskContext | 19 | from ...core.tasks import TaskContext |
| 19 | from . import assets, prompts, service | 20 | from . import assets, prompts, service |
| @@ -116,15 +117,21 @@ def screenshot(url: str, dest: Path) -> None: | |||
| 116 | 117 | ||
| 117 | 118 | ||
| 118 | def captures(ctx: TaskContext) -> None: | 119 | def captures(ctx: TaskContext) -> None: |
| 120 | + """Les sources de la file, dans l'ordre ; l'API peut en ajouter pendant qu'elle tourne (payload relu à chaque | ||
| 121 | + tour, `next` : la prochaine à traiter).""" | ||
| 119 | p, state = ctx.payload, ctx.state | 122 | p, state = ctx.payload, ctx.state |
| 120 | root = service.project_dir(p["project"]) | 123 | root = service.project_dir(p["project"]) |
| 121 | folder = Path(p["dest"]) | 124 | folder = Path(p["dest"]) |
| 122 | folder.mkdir(parents=True, exist_ok=True) | 125 | folder.mkdir(parents=True, exist_ok=True) |
| 123 | done = state.setdefault("done", {}) | 126 | done = state.setdefault("done", {}) |
| 124 | - todo = [s for s in p["sources"] if s["u"] not in done] | ||
| 125 | ctx.set_status("capturing") | 127 | ctx.set_status("capturing") |
| 126 | - for i, s in enumerate(todo): | 128 | + while True: |
| 129 | + sources = (jobs.get(ctx.job.id) or ctx.job).payload["sources"] | ||
| 130 | + i = state.setdefault("next", 0) | ||
| 131 | + if i >= len(sources): | ||
| 132 | + break | ||
| 127 | ctx.check_cancel() | 133 | ctx.check_cancel() |
| 134 | + s = sources[i] | ||
| 128 | state["activity"] = f"capture {urlparse(s['u']).netloc or s['u']}" | 135 | state["activity"] = f"capture {urlparse(s['u']).netloc or s['u']}" |
| 129 | ctx.save(force=True) | 136 | ctx.save(force=True) |
| 130 | dest = service.unique_path(folder, service.safe_name(f"Capture - {s['t'][:80]}.png")) | 137 | dest = service.unique_path(folder, service.safe_name(f"Capture - {s['t'][:80]}.png")) |
| @@ -133,6 +140,7 @@ def captures(ctx: TaskContext) -> None: | |||
| 133 | done[s["u"]] = {"rel": dest.relative_to(root).as_posix(), "error": None} | 140 | done[s["u"]] = {"rel": dest.relative_to(root).as_posix(), "error": None} |
| 134 | except Exception as e: | 141 | except Exception as e: |
| 135 | done[s["u"]] = {"rel": None, "error": str(e)[:200]} | 142 | done[s["u"]] = {"rel": None, "error": str(e)[:200]} |
| 136 | - ctx.progress = (i + 1) / len(todo) | 143 | + state["next"] = i + 1 |
| 144 | + ctx.progress = (i + 1) / len(sources) | ||
| 137 | ctx.save(force=True) | 145 | ctx.save(force=True) |
| 138 | state["activity"] = None | 146 | state["activity"] = None |
modified
backend/studio/modules/projects/assets.py +13 -2 | @@ -87,14 +87,25 @@ def sync(project: dict) -> bool: | ||
| 87 | 87 | capture_job = jobs.get(project["jobs"].get("captures") or "") |
| 88 | 88 | if capture_job: |
| 89 | 89 | for url, result in (capture_job.state.get("done") or {}).items(): |
| 90 | - if a["captures"].get(url) != result: | |
| 90 | + current = a["captures"].get(url) or {} | |
| 91 | + # une image choisie à la main n'est pas écrasée par une capture d'avant | |
| 92 | + if current != result and not current.get("manual"): | |
| 91 | 93 | a["captures"][url] = result |
| 92 | 94 | changed = True |
| 93 | 95 | return changed |
| 94 | 96 | |
| 95 | 97 | |
| 98 | +def capture_queue(project: dict) -> list[str]: | |
| 99 | + """Les sources que le job de captures n'a pas encore traitées.""" | |
| 100 | + job = jobs.get(project["jobs"].get("captures") or "") | |
| 101 | + if not job or job.finished: | |
| 102 | + return [] | |
| 103 | + return [s["u"] for s in job.payload["sources"][job.state.get("next", 0):]] | |
| 104 | + | |
| 105 | + | |
| 96 | 106 | def view(project: dict) -> dict: |
| 97 | - return {"downloads": [v for v in map(_download_view, project["assets"]["downloads"]) if v]} | |
| 107 | + return {"downloads": [v for v in map(_download_view, project["assets"]["downloads"]) if v], | |
| 108 | + "capturing": capture_queue(project)} | |
| 98 | 109 | |
| 99 | 110 | |
| 100 | 111 | # --- Images ------------------------------------------------------------------------------ |
| @@ -87,14 +87,25 @@ def sync(project: dict) -> bool: | |||
| 87 | capture_job = jobs.get(project["jobs"].get("captures") or "") | 87 | capture_job = jobs.get(project["jobs"].get("captures") or "") |
| 88 | if capture_job: | 88 | if capture_job: |
| 89 | for url, result in (capture_job.state.get("done") or {}).items(): | 89 | for url, result in (capture_job.state.get("done") or {}).items(): |
| 90 | - if a["captures"].get(url) != result: | 90 | + current = a["captures"].get(url) or {} |
| 91 | + # une image choisie à la main n'est pas écrasée par une capture d'avant | ||
| 92 | + if current != result and not current.get("manual"): | ||
| 91 | a["captures"][url] = result | 93 | a["captures"][url] = result |
| 92 | changed = True | 94 | changed = True |
| 93 | return changed | 95 | return changed |
| 94 | 96 | ||
| 95 | 97 | ||
| 98 | +def capture_queue(project: dict) -> list[str]: | ||
| 99 | + """Les sources que le job de captures n'a pas encore traitées.""" | ||
| 100 | + job = jobs.get(project["jobs"].get("captures") or "") | ||
| 101 | + if not job or job.finished: | ||
| 102 | + return [] | ||
| 103 | + return [s["u"] for s in job.payload["sources"][job.state.get("next", 0):]] | ||
| 104 | + | ||
| 105 | + | ||
| 96 | def view(project: dict) -> dict: | 106 | def view(project: dict) -> dict: |
| 97 | - return {"downloads": [v for v in map(_download_view, project["assets"]["downloads"]) if v]} | 107 | + return {"downloads": [v for v in map(_download_view, project["assets"]["downloads"]) if v], |
| 108 | + "capturing": capture_queue(project)} | ||
| 98 | 109 | ||
| 99 | 110 | ||
| 100 | # --- Images ------------------------------------------------------------------------------ | 111 | # --- Images ------------------------------------------------------------------------------ |
modified
web/src/i18n/locales/en/sourcing.ts +6 -0 | @@ -25,7 +25,13 @@ export default { | ||
| 25 | 25 | running: "In progress…", |
| 26 | 26 | failed: "Failed: {e}", |
| 27 | 27 | todo: "To capture", |
| 28 | + queued: "Queued", | |
| 29 | + manual: "Own image", | |
| 28 | 30 | }, |
| 31 | + captureOne: "Capture", | |
| 32 | + recapture: "Recapture", | |
| 33 | + ownImage: "My image…", | |
| 34 | + ownImageTitle: "Replace with your own image", | |
| 29 | 35 | capturing: "Capturing · {p}%", |
| 30 | 36 | captureN: "Capture | Capture 1 source | Capture {n} sources", |
| 31 | 37 | sources: "Script sources · {n}", |
| @@ -25,7 +25,13 @@ export default { | |||
| 25 | running: "In progress…", | 25 | running: "In progress…", |
| 26 | failed: "Failed: {e}", | 26 | failed: "Failed: {e}", |
| 27 | todo: "To capture", | 27 | todo: "To capture", |
| 28 | + queued: "Queued", | ||
| 29 | + manual: "Own image", | ||
| 28 | }, | 30 | }, |
| 31 | + captureOne: "Capture", | ||
| 32 | + recapture: "Recapture", | ||
| 33 | + ownImage: "My image…", | ||
| 34 | + ownImageTitle: "Replace with your own image", | ||
| 29 | capturing: "Capturing · {p}%", | 35 | capturing: "Capturing · {p}%", |
| 30 | captureN: "Capture | Capture 1 source | Capture {n} sources", | 36 | captureN: "Capture | Capture 1 source | Capture {n} sources", |
| 31 | sources: "Script sources · {n}", | 37 | sources: "Script sources · {n}", |
modified
web/src/i18n/locales/fr/sourcing.ts +6 -0 | @@ -25,7 +25,13 @@ export default { | ||
| 25 | 25 | running: "En cours…", |
| 26 | 26 | failed: "Échec : {e}", |
| 27 | 27 | todo: "À capturer", |
| 28 | + queued: "En file", | |
| 29 | + manual: "Image choisie", | |
| 28 | 30 | }, |
| 31 | + captureOne: "Capturer", | |
| 32 | + recapture: "Recapturer", | |
| 33 | + ownImage: "Mon image…", | |
| 34 | + ownImageTitle: "Remplacer par une image à toi", | |
| 29 | 35 | capturing: "Capture · {p} %", |
| 30 | 36 | captureN: "Capturer | Capturer {n} source | Capturer {n} sources", |
| 31 | 37 | sources: "Sources du script · {n}", |
| @@ -25,7 +25,13 @@ export default { | |||
| 25 | running: "En cours…", | 25 | running: "En cours…", |
| 26 | failed: "Échec : {e}", | 26 | failed: "Échec : {e}", |
| 27 | todo: "À capturer", | 27 | todo: "À capturer", |
| 28 | + queued: "En file", | ||
| 29 | + manual: "Image choisie", | ||
| 28 | }, | 30 | }, |
| 31 | + captureOne: "Capturer", | ||
| 32 | + recapture: "Recapturer", | ||
| 33 | + ownImage: "Mon image…", | ||
| 34 | + ownImageTitle: "Remplacer par une image à toi", | ||
| 29 | capturing: "Capture · {p} %", | 35 | capturing: "Capture · {p} %", |
| 30 | captureN: "Capturer | Capturer {n} source | Capturer {n} sources", | 36 | captureN: "Capturer | Capturer {n} source | Capturer {n} sources", |
| 31 | sources: "Sources du script · {n}", | 37 | sources: "Sources du script · {n}", |
modified
web/src/project/steps/assets/DocTab.vue +59 -7 | @@ -2,11 +2,14 @@ | ||
| 2 | 2 | /** |
| 3 | 3 | * Onglet Documentation : les sources du script (tableau de l'onglet Recherche), avec pour chacune |
| 4 | 4 | * une case « capture automatique » ; les captures (haut de page en 16:9) vont dans les images du projet. |
| 5 | + * Chaque ligne se capture aussi seule (ajoutée à la file si des captures tournent), ou reçoit une image à soi. | |
| 5 | 6 | */ |
| 6 | -import { computed } from "vue"; | |
| 7 | +import { computed, ref } from "vue"; | |
| 7 | 8 | import { useI18n } from "vue-i18n"; |
| 8 | -import { act, running, store, update } from "../../store"; | |
| 9 | -import type { ResearchSource } from "../../types"; | |
| 9 | +import { toast } from "../../../lib/toast"; | |
| 10 | +import { upload } from "../../../lib/upload"; | |
| 11 | +import { act, receive, running, store, update } from "../../store"; | |
| 12 | +import type { Project, ResearchSource } from "../../types"; | |
| 10 | 13 | import CheckBox from "./CheckBox.vue"; |
| 11 | 14 | |
| 12 | 15 | const { t } = useI18n(); |
| @@ -14,8 +17,40 @@ const project = computed(() => store.project!); | ||
| 14 | 17 | const sources = computed(() => project.value.research); |
| 15 | 18 | const capture = computed(() => project.value.assets.capture); |
| 16 | 19 | const captures = computed(() => project.value.assets.captures); |
| 20 | +const queue = computed(() => project.value.runtime.assets.capturing ?? []); | |
| 17 | 21 | const job = computed(() => project.value.runtime.jobs.captures); |
| 18 | 22 | const busy = computed(() => running("captures")); |
| 23 | +const inQueue = (s: ResearchSource) => busy.value && queue.value.includes(s.u); | |
| 24 | + | |
| 25 | +function captureOne(s: ResearchSource) { | |
| 26 | + act("/assets/captures", { urls: [s.u] }); | |
| 27 | +} | |
| 28 | + | |
| 29 | +// l'image à soi : un seul champ fichier, la source visée est retenue au clic | |
| 30 | +const fileInput = ref<HTMLInputElement | null>(null); | |
| 31 | +const uploadFor = ref<string | null>(null); | |
| 32 | +const uploading = ref<string | null>(null); | |
| 33 | + | |
| 34 | +function pickImage(s: ResearchSource) { | |
| 35 | + uploadFor.value = s.u; | |
| 36 | + fileInput.value?.click(); | |
| 37 | +} | |
| 38 | + | |
| 39 | +async function onFile(e: Event) { | |
| 40 | + const input = e.target as HTMLInputElement; | |
| 41 | + const file = input.files?.[0]; | |
| 42 | + const url = uploadFor.value; | |
| 43 | + input.value = ""; | |
| 44 | + if (!file || !url) return; | |
| 45 | + uploading.value = url; | |
| 46 | + try { | |
| 47 | + receive(await upload<Project>(`/api/projects/${project.value.id}/assets/captures/upload`, [file], "file", { url })); | |
| 48 | + } catch (err) { | |
| 49 | + toast((err as Error).message, true); | |
| 50 | + } finally { | |
| 51 | + uploading.value = null; | |
| 52 | + } | |
| 53 | +} | |
| 19 | 54 | |
| 20 | 55 | const isWeb = (s: ResearchSource) => /^https?:\/\//.test(s.u); |
| 21 | 56 | const web = computed(() => sources.value.filter(isWeb)); |
| @@ -35,9 +70,12 @@ function setAll() { | ||
| 35 | 70 | function status(s: ResearchSource): { text: string; tone: string } { |
| 36 | 71 | if (!isWeb(s)) return { text: t("sourcing.doc.status.notLink"), tone: "faint" }; |
| 37 | 72 | const c = captures.value[s.u]; |
| 38 | - if (c?.rel) return { text: t("sourcing.doc.status.captured"), tone: "ok" }; | |
| 73 | + if (inQueue(s)) { | |
| 74 | + const now = queue.value[0] === s.u; | |
| 75 | + return { text: t(now ? "sourcing.doc.status.running" : "sourcing.doc.status.queued"), tone: "muted" }; | |
| 76 | + } | |
| 77 | + if (c?.rel) return { text: t(c.manual ? "sourcing.doc.status.manual" : "sourcing.doc.status.captured"), tone: "ok" }; | |
| 39 | 78 | if (!capture.value[s.u]) return { text: t("sourcing.doc.status.noCapture"), tone: "faint" }; |
| 40 | - if (busy.value) return { text: t("sourcing.doc.status.running"), tone: "muted" }; | |
| 41 | 79 | if (c?.error) return { text: t("sourcing.doc.status.failed", { e: c.error }), tone: "err" }; |
| 42 | 80 | return { text: t("sourcing.doc.status.todo"), tone: "muted" }; |
| 43 | 81 | } |
| @@ -75,7 +113,16 @@ const thumb = (rel: string) => `/api/projects/${project.value.id}/files/${rel.sp | ||
| 75 | 113 | <span v-if="s.n" class="n muted">{{ s.n }}</span> |
| 76 | 114 | </div> |
| 77 | 115 | <span class="st" :class="status(s).tone" :title="status(s).text">{{ status(s).text }}</span> |
| 78 | - <a v-if="captures[s.u]?.rel" :href="thumb(captures[s.u].rel!)" target="_blank" class="th"> | |
| 116 | + <div class="acts"> | |
| 117 | + <button v-if="isWeb(s)" class="btn sm" :disabled="inQueue(s)" @click="captureOne(s)"> | |
| 118 | + {{ captures[s.u]?.rel || captures[s.u]?.error ? t("sourcing.doc.recapture") : t("sourcing.doc.captureOne") }} | |
| 119 | + </button> | |
| 120 | + <button class="link-btn" :disabled="uploading === s.u" :title="t('sourcing.doc.ownImageTitle')" | |
| 121 | + @click="pickImage(s)"> | |
| 122 | + {{ t("sourcing.doc.ownImage") }} | |
| 123 | + </button> | |
| 124 | + </div> | |
| 125 | + <a v-if="captures[s.u]?.rel":href="thumb(captures[s.u].rel!)" target="_blank" class="th"> | |
| 79 | 126 | <img :src="thumb(captures[s.u].rel!)" alt=""> |
| 80 | 127 | </a> |
| 81 | 128 | <div v-else class="th hatch mono faint">{{ capture[s.u] ? t("sourcing.doc.pending") : "—" }}</div> |
| @@ -84,6 +131,8 @@ const thumb = (rel: string) => `/api/projects/${project.value.id}/files/${rel.sp | ||
| 84 | 131 | <div v-else class="dashed-box empty faint"> |
| 85 | 132 | {{ t("sourcing.doc.empty") }} |
| 86 | 133 | </div> |
| 134 | + <input ref="fileInput" type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml" hidden | |
| 135 | + @change="onFile"> | |
| 87 | 136 | </div> |
| 88 | 137 | </template> |
| 89 | 138 | |
| @@ -96,12 +145,15 @@ const thumb = (rel: string) => `/api/projects/${project.value.id}/files/${rel.sp | ||
| 96 | 145 | .cap-progress { position: absolute; left: 0; bottom: 0; height: 2px; background: var(--red); transition: width .3s; } |
| 97 | 146 | .activity { font-size: 11.5px; margin-top: -6px; } |
| 98 | 147 | .list { display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: 4px; } |
| 99 | -.row { display: grid; grid-template-columns: 28px minmax(0, 1fr) 130px 96px; gap: 14px; align-items: center; padding: 12px 14px; border-top: 1px solid var(--line-soft); } | |
| 148 | +.row { display: grid; grid-template-columns: 28px minmax(0, 1fr) 130px 110px 96px; gap: 14px; align-items: center; padding: 12px 14px; border-top: 1px solid var(--line-soft); } | |
| 100 | 149 | .row:first-child { border-top: none; } |
| 101 | 150 | .text { display: flex; flex-direction: column; gap: 3px; min-width: 0; } |
| 102 | 151 | .t { font-size: 13.5px; font-weight: 500; } |
| 103 | 152 | .u { font-size: 11.5px; color: var(--faint) !important; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 104 | 153 | .n { font-size: 12.5px; line-height: 1.45; } |
| 154 | +.acts { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; } | |
| 155 | +.acts .link-btn { color: var(--muted); } | |
| 156 | +.acts .link-btn:disabled { opacity: .5; } | |
| 105 | 157 | .st { font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 106 | 158 | .st.ok { color: var(--ink); font-weight: 500; } |
| 107 | 159 | .st.muted { color: var(--muted); } |
| @@ -2,11 +2,14 @@ | |||
| 2 | /** | 2 | /** |
| 3 | * Onglet Documentation : les sources du script (tableau de l'onglet Recherche), avec pour chacune | 3 | * Onglet Documentation : les sources du script (tableau de l'onglet Recherche), avec pour chacune |
| 4 | * une case « capture automatique » ; les captures (haut de page en 16:9) vont dans les images du projet. | 4 | * une case « capture automatique » ; les captures (haut de page en 16:9) vont dans les images du projet. |
| 5 | + * Chaque ligne se capture aussi seule (ajoutée à la file si des captures tournent), ou reçoit une image à soi. | ||
| 5 | */ | 6 | */ |
| 6 | -import { computed } from "vue"; | 7 | +import { computed, ref } from "vue"; |
| 7 | import { useI18n } from "vue-i18n"; | 8 | import { useI18n } from "vue-i18n"; |
| 8 | -import { act, running, store, update } from "../../store"; | 9 | +import { toast } from "../../../lib/toast"; |
| 9 | -import type { ResearchSource } from "../../types"; | 10 | +import { upload } from "../../../lib/upload"; |
| 11 | +import { act, receive, running, store, update } from "../../store"; | ||
| 12 | +import type { Project, ResearchSource } from "../../types"; | ||
| 10 | import CheckBox from "./CheckBox.vue"; | 13 | import CheckBox from "./CheckBox.vue"; |
| 11 | 14 | ||
| 12 | const { t } = useI18n(); | 15 | const { t } = useI18n(); |
| @@ -14,8 +17,40 @@ const project = computed(() => store.project!); | |||
| 14 | const sources = computed(() => project.value.research); | 17 | const sources = computed(() => project.value.research); |
| 15 | const capture = computed(() => project.value.assets.capture); | 18 | const capture = computed(() => project.value.assets.capture); |
| 16 | const captures = computed(() => project.value.assets.captures); | 19 | const captures = computed(() => project.value.assets.captures); |
| 20 | +const queue = computed(() => project.value.runtime.assets.capturing ?? []); | ||
| 17 | const job = computed(() => project.value.runtime.jobs.captures); | 21 | const job = computed(() => project.value.runtime.jobs.captures); |
| 18 | const busy = computed(() => running("captures")); | 22 | const busy = computed(() => running("captures")); |
| 23 | +const inQueue = (s: ResearchSource) => busy.value && queue.value.includes(s.u); | ||
| 24 | + | ||
| 25 | +function captureOne(s: ResearchSource) { | ||
| 26 | + act("/assets/captures", { urls: [s.u] }); | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +// l'image à soi : un seul champ fichier, la source visée est retenue au clic | ||
| 30 | +const fileInput = ref<HTMLInputElement | null>(null); | ||
| 31 | +const uploadFor = ref<string | null>(null); | ||
| 32 | +const uploading = ref<string | null>(null); | ||
| 33 | + | ||
| 34 | +function pickImage(s: ResearchSource) { | ||
| 35 | + uploadFor.value = s.u; | ||
| 36 | + fileInput.value?.click(); | ||
| 37 | +} | ||
| 38 | + | ||
| 39 | +async function onFile(e: Event) { | ||
| 40 | + const input = e.target as HTMLInputElement; | ||
| 41 | + const file = input.files?.[0]; | ||
| 42 | + const url = uploadFor.value; | ||
| 43 | + input.value = ""; | ||
| 44 | + if (!file || !url) return; | ||
| 45 | + uploading.value = url; | ||
| 46 | + try { | ||
| 47 | + receive(await upload<Project>(`/api/projects/${project.value.id}/assets/captures/upload`, [file], "file", { url })); | ||
| 48 | + } catch (err) { | ||
| 49 | + toast((err as Error).message, true); | ||
| 50 | + } finally { | ||
| 51 | + uploading.value = null; | ||
| 52 | + } | ||
| 53 | +} | ||
| 19 | 54 | ||
| 20 | const isWeb = (s: ResearchSource) => /^https?:\/\//.test(s.u); | 55 | const isWeb = (s: ResearchSource) => /^https?:\/\//.test(s.u); |
| 21 | const web = computed(() => sources.value.filter(isWeb)); | 56 | const web = computed(() => sources.value.filter(isWeb)); |
| @@ -35,9 +70,12 @@ function setAll() { | |||
| 35 | function status(s: ResearchSource): { text: string; tone: string } { | 70 | function status(s: ResearchSource): { text: string; tone: string } { |
| 36 | if (!isWeb(s)) return { text: t("sourcing.doc.status.notLink"), tone: "faint" }; | 71 | if (!isWeb(s)) return { text: t("sourcing.doc.status.notLink"), tone: "faint" }; |
| 37 | const c = captures.value[s.u]; | 72 | const c = captures.value[s.u]; |
| 38 | - if (c?.rel) return { text: t("sourcing.doc.status.captured"), tone: "ok" }; | 73 | + if (inQueue(s)) { |
| 74 | + const now = queue.value[0] === s.u; | ||
| 75 | + return { text: t(now ? "sourcing.doc.status.running" : "sourcing.doc.status.queued"), tone: "muted" }; | ||
| 76 | + } | ||
| 77 | + if (c?.rel) return { text: t(c.manual ? "sourcing.doc.status.manual" : "sourcing.doc.status.captured"), tone: "ok" }; | ||
| 39 | if (!capture.value[s.u]) return { text: t("sourcing.doc.status.noCapture"), tone: "faint" }; | 78 | if (!capture.value[s.u]) return { text: t("sourcing.doc.status.noCapture"), tone: "faint" }; |
| 40 | - if (busy.value) return { text: t("sourcing.doc.status.running"), tone: "muted" }; | ||
| 41 | if (c?.error) return { text: t("sourcing.doc.status.failed", { e: c.error }), tone: "err" }; | 79 | if (c?.error) return { text: t("sourcing.doc.status.failed", { e: c.error }), tone: "err" }; |
| 42 | return { text: t("sourcing.doc.status.todo"), tone: "muted" }; | 80 | return { text: t("sourcing.doc.status.todo"), tone: "muted" }; |
| 43 | } | 81 | } |
| @@ -75,7 +113,16 @@ const thumb = (rel: string) => `/api/projects/${project.value.id}/files/${rel.sp | |||
| 75 | <span v-if="s.n" class="n muted">{{ s.n }}</span> | 113 | <span v-if="s.n" class="n muted">{{ s.n }}</span> |
| 76 | </div> | 114 | </div> |
| 77 | <span class="st" :class="status(s).tone" :title="status(s).text">{{ status(s).text }}</span> | 115 | <span class="st" :class="status(s).tone" :title="status(s).text">{{ status(s).text }}</span> |
| 78 | - <a v-if="captures[s.u]?.rel" :href="thumb(captures[s.u].rel!)" target="_blank" class="th"> | 116 | + <div class="acts"> |
| 117 | + <button v-if="isWeb(s)" class="btn sm" :disabled="inQueue(s)" @click="captureOne(s)"> | ||
| 118 | + {{ captures[s.u]?.rel || captures[s.u]?.error ? t("sourcing.doc.recapture") : t("sourcing.doc.captureOne") }} | ||
| 119 | + </button> | ||
| 120 | + <button class="link-btn" :disabled="uploading === s.u" :title="t('sourcing.doc.ownImageTitle')" | ||
| 121 | + @click="pickImage(s)"> | ||
| 122 | + {{ t("sourcing.doc.ownImage") }} | ||
| 123 | + </button> | ||
| 124 | + </div> | ||
| 125 | + <a v-if="captures[s.u]?.rel":href="thumb(captures[s.u].rel!)" target="_blank" class="th"> | ||
| 79 | <img :src="thumb(captures[s.u].rel!)" alt=""> | 126 | <img :src="thumb(captures[s.u].rel!)" alt=""> |
| 80 | </a> | 127 | </a> |
| 81 | <div v-else class="th hatch mono faint">{{ capture[s.u] ? t("sourcing.doc.pending") : "—" }}</div> | 128 | <div v-else class="th hatch mono faint">{{ capture[s.u] ? t("sourcing.doc.pending") : "—" }}</div> |
| @@ -84,6 +131,8 @@ const thumb = (rel: string) => `/api/projects/${project.value.id}/files/${rel.sp | |||
| 84 | <div v-else class="dashed-box empty faint"> | 131 | <div v-else class="dashed-box empty faint"> |
| 85 | {{ t("sourcing.doc.empty") }} | 132 | {{ t("sourcing.doc.empty") }} |
| 86 | </div> | 133 | </div> |
| 134 | + <input ref="fileInput" type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml" hidden | ||
| 135 | + @change="onFile"> | ||
| 87 | </div> | 136 | </div> |
| 88 | </template> | 137 | </template> |
| 89 | 138 | ||
| @@ -96,12 +145,15 @@ const thumb = (rel: string) => `/api/projects/${project.value.id}/files/${rel.sp | |||
| 96 | .cap-progress { position: absolute; left: 0; bottom: 0; height: 2px; background: var(--red); transition: width .3s; } | 145 | .cap-progress { position: absolute; left: 0; bottom: 0; height: 2px; background: var(--red); transition: width .3s; } |
| 97 | .activity { font-size: 11.5px; margin-top: -6px; } | 146 | .activity { font-size: 11.5px; margin-top: -6px; } |
| 98 | .list { display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: 4px; } | 147 | .list { display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: 4px; } |
| 99 | -.row { display: grid; grid-template-columns: 28px minmax(0, 1fr) 130px 96px; gap: 14px; align-items: center; padding: 12px 14px; border-top: 1px solid var(--line-soft); } | 148 | +.row { display: grid; grid-template-columns: 28px minmax(0, 1fr) 130px 110px 96px; gap: 14px; align-items: center; padding: 12px 14px; border-top: 1px solid var(--line-soft); } |
| 100 | .row:first-child { border-top: none; } | 149 | .row:first-child { border-top: none; } |
| 101 | .text { display: flex; flex-direction: column; gap: 3px; min-width: 0; } | 150 | .text { display: flex; flex-direction: column; gap: 3px; min-width: 0; } |
| 102 | .t { font-size: 13.5px; font-weight: 500; } | 151 | .t { font-size: 13.5px; font-weight: 500; } |
| 103 | .u { font-size: 11.5px; color: var(--faint) !important; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | 152 | .u { font-size: 11.5px; color: var(--faint) !important; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 104 | .n { font-size: 12.5px; line-height: 1.45; } | 153 | .n { font-size: 12.5px; line-height: 1.45; } |
| 154 | +.acts { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; } | ||
| 155 | +.acts .link-btn { color: var(--muted); } | ||
| 156 | +.acts .link-btn:disabled { opacity: .5; } | ||
| 105 | .st { font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | 157 | .st { font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 106 | .st.ok { color: var(--ink); font-weight: 500; } | 158 | .st.ok { color: var(--ink); font-weight: 500; } |
| 107 | .st.muted { color: var(--muted); } | 159 | .st.muted { color: var(--muted); } |
modified
web/src/project/types.ts +3 -2 | @@ -84,7 +84,8 @@ export interface Runtime { | ||
| 84 | 84 | /** la note du dernier roast terminé */ |
| 85 | 85 | roast_score: number | null; |
| 86 | 86 | render_current: boolean; |
| 87 | - assets: { downloads: AssetDownload[] }; | |
| 87 | + /** capturing : les sources que le job de captures doit encore traiter (la première est en cours) */ | |
| 88 | + assets: { downloads: AssetDownload[]; capturing: string[] }; | |
| 88 | 89 | /** les modèles d'image de fal.ai ; `ready` : la clé FAL_KEY est définie */ |
| 89 | 90 | thumbs: { models: { id: string; label: string }[]; ready: boolean }; |
| 90 | 91 | /** la dernière sélection de shorts sur le transcript de la vidéo source, et ses shorts en montage */ |
| @@ -110,7 +111,7 @@ export interface Assets { | ||
| 110 | 111 | /** sources à capturer (url -> coché) */ |
| 111 | 112 | capture: Record<string, boolean>; |
| 112 | 113 | /** captures faites (url -> fichier du projet, ou erreur) */ |
| 113 | - captures: Record<string, { rel: string | null; error: string | null }>; | |
| 114 | + captures: Record<string, { rel: string | null; error: string | null; manual?: boolean }>; | |
| 114 | 115 | } |
| 115 | 116 | |
| 116 | 117 | /** Une image proposée par Claude ; `added` : où elle a été rangée (« Projet · Images »). */ |
| @@ -84,7 +84,8 @@ export interface Runtime { | |||
| 84 | /** la note du dernier roast terminé */ | 84 | /** la note du dernier roast terminé */ |
| 85 | roast_score: number | null; | 85 | roast_score: number | null; |
| 86 | render_current: boolean; | 86 | render_current: boolean; |
| 87 | - assets: { downloads: AssetDownload[] }; | 87 | + /** capturing : les sources que le job de captures doit encore traiter (la première est en cours) */ |
| 88 | + assets: { downloads: AssetDownload[]; capturing: string[] }; | ||
| 88 | /** les modèles d'image de fal.ai ; `ready` : la clé FAL_KEY est définie */ | 89 | /** les modèles d'image de fal.ai ; `ready` : la clé FAL_KEY est définie */ |
| 89 | thumbs: { models: { id: string; label: string }[]; ready: boolean }; | 90 | thumbs: { models: { id: string; label: string }[]; ready: boolean }; |
| 90 | /** la dernière sélection de shorts sur le transcript de la vidéo source, et ses shorts en montage */ | 91 | /** la dernière sélection de shorts sur le transcript de la vidéo source, et ses shorts en montage */ |
| @@ -110,7 +111,7 @@ export interface Assets { | |||
| 110 | /** sources à capturer (url -> coché) */ | 111 | /** sources à capturer (url -> coché) */ |
| 111 | capture: Record<string, boolean>; | 112 | capture: Record<string, boolean>; |
| 112 | /** captures faites (url -> fichier du projet, ou erreur) */ | 113 | /** captures faites (url -> fichier du projet, ou erreur) */ |
| 113 | - captures: Record<string, { rel: string | null; error: string | null }>; | 114 | + captures: Record<string, { rel: string | null; error: string | null; manual?: boolean }>; |
| 114 | } | 115 | } |
| 115 | 116 | ||
| 116 | /** Une image proposée par Claude ; `added` : où elle a été rangée (« Projet · Images »). */ | 117 | /** Une image proposée par Claude ; `added` : où elle a été rangée (« Projet · Images »). */ |