hlassiege/darylpublic Fork
⑂ bced829
Commits
⬇ Clone ▾
git clone https://git.rickub.com/hlassiege/daryl.git
git clone ssh://git@rickub.com/hlassiege/daryl.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat(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.
hlassiege committed 2026-10-11T17:36:11+02:00 Browse files
bced829 parent: ef2bf51
modified backend/studio/modules/projects/api.py +54 -11
@@ -1176,29 +1176,72 @@ def candidate(pid: str, name: str):
11761176
11771177 # --- Assets : captures des sources ------------------------------------------------------
11781178
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+
11791188 @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."""
11821191 _check(pid)
1192+ urls = req.urls if req else None
11831193 project = service.load_synced(pid, runtime.sync)
11841194 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]
11881200 if not sources:
1201+ if urls and queued.issuperset(urls):
1202+ return _view(pid)
11891203 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})
11961219 with service.editing(pid) as p:
11971220 p["jobs"]["captures"] = job.id
11981221 service.start_step(p, "assets")
11991222 return _view(pid)
12001223
12011224
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+
12021245 # --- Montage de la vidéo longue ----------------------------------------------------------
12031246
12041247 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 e1209+ 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.id1220 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
1414
1515 import certifi
1616
17+from ...core import jobs
1718 from ...core.system import browser
1819 from ...core.tasks import TaskContext
1920 from . import assets, prompts, service
@@ -116,15 +117,21 @@ def screenshot(url: str, dest: Path) -> None:
116117
117118
118119 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)."""
119122 p, state = ctx.payload, ctx.state
120123 root = service.project_dir(p["project"])
121124 folder = Path(p["dest"])
122125 folder.mkdir(parents=True, exist_ok=True)
123126 done = state.setdefault("done", {})
124- todo = [s for s in p["sources"] if s["u"] not in done]
125127 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
127133 ctx.check_cancel()
134+ s = sources[i]
128135 state["activity"] = f"capture {urlparse(s['u']).netloc or s['u']}"
129136 ctx.save(force=True)
130137 dest = service.unique_path(folder, service.safe_name(f"Capture - {s['t'][:80]}.png"))
@@ -133,6 +140,7 @@ def captures(ctx: TaskContext) -> None:
133140 done[s["u"]] = {"rel": dest.relative_to(root).as_posix(), "error": None}
134141 except Exception as e:
135142 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)
137145 ctx.save(force=True)
138146 state["activity"] = None
@@ -14,6 +14,7 @@ from urllib.parse import urlparse
14 14
15 import certifi15 import certifi
16 16
17+from ...core import jobs
17 from ...core.system import browser18 from ...core.system import browser
18 from ...core.tasks import TaskContext19 from ...core.tasks import TaskContext
19 from . import assets, prompts, service20 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.state122 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"] = None146 state["activity"] = None
modified backend/studio/modules/projects/assets.py +13 -2
@@ -87,14 +87,25 @@ def sync(project: dict) -> bool:
8787 capture_job = jobs.get(project["jobs"].get("captures") or "")
8888 if capture_job:
8989 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"):
9193 a["captures"][url] = result
9294 changed = True
9395 return changed
9496
9597
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+
96106 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)}
98109
99110
100111 # --- 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] = result93 a["captures"][url] = result
92 changed = True94 changed = True
93 return changed95 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 {
2525 running: "In progress…",
2626 failed: "Failed: {e}",
2727 todo: "To capture",
28+ queued: "Queued",
29+ manual: "Own image",
2830 },
31+ captureOne: "Capture",
32+ recapture: "Recapture",
33+ ownImage: "My image…",
34+ ownImageTitle: "Replace with your own image",
2935 capturing: "Capturing · {p}%",
3036 captureN: "Capture | Capture 1 source | Capture {n} sources",
3137 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 {
2525 running: "En cours…",
2626 failed: "Échec : {e}",
2727 todo: "À capturer",
28+ queued: "En file",
29+ manual: "Image choisie",
2830 },
31+ captureOne: "Capturer",
32+ recapture: "Recapturer",
33+ ownImage: "Mon image…",
34+ ownImageTitle: "Remplacer par une image à toi",
2935 capturing: "Capture · {p} %",
3036 captureN: "Capturer | Capturer {n} source | Capturer {n} sources",
3137 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 @@
22 /**
33 * Onglet Documentation : les sources du script (tableau de l'onglet Recherche), avec pour chacune
44 * 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.
56 */
6-import { computed } from "vue";
7+import { computed, ref } from "vue";
78 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";
1013 import CheckBox from "./CheckBox.vue";
1114
1215 const { t } = useI18n();
@@ -14,8 +17,40 @@ const project = computed(() => store.project!);
1417 const sources = computed(() => project.value.research);
1518 const capture = computed(() => project.value.assets.capture);
1619 const captures = computed(() => project.value.assets.captures);
20+const queue = computed(() => project.value.runtime.assets.capturing ?? []);
1721 const job = computed(() => project.value.runtime.jobs.captures);
1822 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+}
1954
2055 const isWeb = (s: ResearchSource) => /^https?:\/\//.test(s.u);
2156 const web = computed(() => sources.value.filter(isWeb));
@@ -35,9 +70,12 @@ function setAll() {
3570 function status(s: ResearchSource): { text: string; tone: string } {
3671 if (!isWeb(s)) return { text: t("sourcing.doc.status.notLink"), tone: "faint" };
3772 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" };
3978 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" };
4179 if (c?.error) return { text: t("sourcing.doc.status.failed", { e: c.error }), tone: "err" };
4280 return { text: t("sourcing.doc.status.todo"), tone: "muted" };
4381 }
@@ -75,7 +113,16 @@ const thumb = (rel: string) => `/api/projects/${project.value.id}/files/${rel.sp
75113 <span v-if="s.n" class="n muted">{{ s.n }}</span>
76114 </div>
77115 <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">
79126 <img :src="thumb(captures[s.u].rel!)" alt="">
80127 </a>
81128 <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
84131 <div v-else class="dashed-box empty faint">
85132 {{ t("sourcing.doc.empty") }}
86133 </div>
134+ <input ref="fileInput" type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml" hidden
135+ @change="onFile">
87136 </div>
88137 </template>
89138
@@ -96,12 +145,15 @@ const thumb = (rel: string) => `/api/projects/${project.value.id}/files/${rel.sp
96145 .cap-progress { position: absolute; left: 0; bottom: 0; height: 2px; background: var(--red); transition: width .3s; }
97146 .activity { font-size: 11.5px; margin-top: -6px; }
98147 .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); }
100149 .row:first-child { border-top: none; }
101150 .text { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
102151 .t { font-size: 13.5px; font-weight: 500; }
103152 .u { font-size: 11.5px; color: var(--faint) !important; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
104153 .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; }
105157 .st { font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
106158 .st.ok { color: var(--ink); font-weight: 500; }
107159 .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 chacune3 * 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 {
8484 /** la note du dernier roast terminé */
8585 roast_score: number | null;
8686 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[] };
8889 /** les modèles d'image de fal.ai ; `ready` : la clé FAL_KEY est définie */
8990 thumbs: { models: { id: string; label: string }[]; ready: boolean };
9091 /** 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 {
110111 /** sources à capturer (url -> coché) */
111112 capture: Record<string, boolean>;
112113 /** 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 }>;
114115 }
115116
116117 /** 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 »). */