hlassiege/darylpublic Fork
⑂ 8effbe8
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(thumbnail): add reference images to a touch-up

The touch-up lightbox can now send extra images (library or project media)
along with the instruction, e.g. a logo to add or an object to copy. They
apply to that touch-up only and come after the portraits in the prompt.
hlassiege committed 2026-10-24T17:39:13+02:00 Browse files
8effbe8 parent: d325948
modified backend/studio/modules/projects/api.py +2 -1
@@ -736,6 +736,7 @@ def generate_thumb(pid: str, tid: str, req: ThumbGenerateRequest):
736736 class ThumbEditRequest(BaseModel):
737737 instruction: str
738738 models: list[str]
739+ refs: list[str] = []
739740
740741
741742 @router.post("/{pid}/thumbs/{tid}/images/{name}/edit")
@@ -746,7 +747,7 @@ def edit_thumb_image(pid: str, tid: str, name: str, req: ThumbEditRequest):
746747 with service.editing(pid) as project:
747748 runtime.sync(project)
748749 try:
749- thumbs.start_edit(project, tid, name, req.instruction, req.models)
750+ thumbs.start_edit(project, tid, name, req.instruction, req.models, req.refs)
750751 except ValueError as e:
751752 raise HTTPException(400, str(e)) from e
752753 return _view(pid)
@@ -736,6 +736,7 @@ def generate_thumb(pid: str, tid: str, req: ThumbGenerateRequest):
736 class ThumbEditRequest(BaseModel):736 class ThumbEditRequest(BaseModel):
737 instruction: str737 instruction: str
738 models: list[str]738 models: list[str]
739+ refs: list[str] = []
739 740
740 741
741 @router.post("/{pid}/thumbs/{tid}/images/{name}/edit")742 @router.post("/{pid}/thumbs/{tid}/images/{name}/edit")
@@ -746,7 +747,7 @@ def edit_thumb_image(pid: str, tid: str, name: str, req: ThumbEditRequest):
746 with service.editing(pid) as project:747 with service.editing(pid) as project:
747 runtime.sync(project)748 runtime.sync(project)
748 try:749 try:
749- thumbs.start_edit(project, tid, name, req.instruction, req.models)750+ thumbs.start_edit(project, tid, name, req.instruction, req.models, req.refs)
750 except ValueError as e:751 except ValueError as e:
751 raise HTTPException(400, str(e)) from e752 raise HTTPException(400, str(e)) from e
752 return _view(pid)753 return _view(pid)
modified backend/studio/modules/projects/thumbs.py +23 -9
@@ -89,14 +89,22 @@ def video_title(project: dict, thumb: dict) -> str:
8989 or next((t["t"] for t in project["titles"] if t.get("pick")), None) or project["title"]
9090
9191
92-def build_edit_prompt(instruction: str, portraits: int) -> str:
93- """Le prompt d'une retouche : l'image 1 est la vignette à modifier, le reste ne bouge pas."""
92+def _span(first: int, n: int) -> str:
93+ return f"Image {first}" if n == 1 else f"Images {first} to {first + n - 1}"
94+
95+
96+def build_edit_prompt(instruction: str, portraits: int, refs: int = 0) -> str:
97+ """Le prompt d'une retouche : l'image 1 est la vignette à modifier, le reste ne bouge pas. Suivent les
98+ portraits, puis les images de référence données pour cette retouche."""
9499 lines = ["Image 1 is a YouTube video thumbnail (16:9). Edit it as asked below and change nothing else: "
95100 "same composition, framing, person, pose, colors, lighting and text, unless the request says otherwise."]
96101 if portraits:
97- span = "Image 2" if portraits == 1 else f"Images 2 to {portraits + 1}"
98- lines.append(f"{span}: photos of the person in the thumbnail, only to keep their face exactly "
102+ lines.append(f"{_span(2, portraits)}: photos of the person in the thumbnail, only to keep their face exactly "
99103 "recognizable if it has to change. Do not add them as new elements.")
104+ if refs:
105+ what = "a reference image" if refs == 1 else "reference images"
106+ lines.append(f"{_span(2 + portraits, refs)}: {what} for the requested change. Use only what the request "
107+ "describes from them (an element to add or replace, a logo, an object, a style).")
100108 lines += ["", f"Requested change: {instruction.strip()}",
101109 "", "Any text in the image must stay perfectly spelled."]
102110 return "\n".join(lines)
@@ -140,8 +148,10 @@ def start(project: dict, tid: str, models: list[str]) -> None:
140148 _launch(project, tid, models, prompt, portraits + styles)
141149
142150
143-def start_edit(project: dict, tid: str, file: str, instruction: str, models: list[str]) -> None:
144- """Retouche une image de la galerie de `tid` : elle part en image 1, avec les portraits pour garder le visage."""
151+def start_edit(project: dict, tid: str, file: str, instruction: str, models: list[str],
152+ refs: list[str] | None = None) -> None:
153+ """Retouche une image de la galerie de `tid` : elle part en image 1, avec les portraits pour garder le visage
154+ et les images de référence de cette retouche (bibliothèque ou médias du projet, comme les références)."""
145155 thumb = _thumb(project, tid)
146156 if file not in {i["file"] for i in project.get("thumb_images", {}).get(tid, [])}:
147157 raise ValueError("image inconnue")
@@ -150,9 +160,13 @@ def start_edit(project: dict, tid: str, file: str, instruction: str, models: lis
150160 base = service.project_dir(project["id"]) / service.VIGNETTES_DIR / file
151161 if not base.is_file():
152162 raise ValueError(f"fichier introuvable : {file}")
153- portraits = [_ref_path(project, r) for r in thumb.get("portraits") or []][:MAX_REFS - 1]
154- _launch(project, tid, models, build_edit_prompt(instruction, len(portraits)), [base, *portraits],
155- source=file, edit=instruction.strip())
163+ extra = [_ref_path(project, r) for r in refs or []]
164+ if len(extra) > MAX_REFS - 1:
165+ raise ValueError(f"{MAX_REFS - 1} images de référence au plus pour une retouche")
166+ # les références demandées passent avant les portraits : ceux-ci ne font que garder le visage
167+ portraits = [_ref_path(project, r) for r in thumb.get("portraits") or []][:MAX_REFS - 1 - len(extra)]
168+ _launch(project, tid, models, build_edit_prompt(instruction, len(portraits), len(extra)),
169+ [base, *portraits, *extra], source=file, edit=instruction.strip())
156170
157171
158172 def ideas_payload(project: dict, tid: str, note: str) -> dict:
@@ -89,14 +89,22 @@ def video_title(project: dict, thumb: dict) -> str:
89 or next((t["t"] for t in project["titles"] if t.get("pick")), None) or project["title"]89 or next((t["t"] for t in project["titles"] if t.get("pick")), None) or project["title"]
90 90
91 91
92-def build_edit_prompt(instruction: str, portraits: int) -> str:92+def _span(first: int, n: int) -> str:
93- """Le prompt d'une retouche : l'image 1 est la vignette à modifier, le reste ne bouge pas."""93+ return f"Image {first}" if n == 1 else f"Images {first} to {first + n - 1}"
94+
95+
96+def build_edit_prompt(instruction: str, portraits: int, refs: int = 0) -> str:
97+ """Le prompt d'une retouche : l'image 1 est la vignette à modifier, le reste ne bouge pas. Suivent les
98+ portraits, puis les images de référence données pour cette retouche."""
94 lines = ["Image 1 is a YouTube video thumbnail (16:9). Edit it as asked below and change nothing else: "99 lines = ["Image 1 is a YouTube video thumbnail (16:9). Edit it as asked below and change nothing else: "
95 "same composition, framing, person, pose, colors, lighting and text, unless the request says otherwise."]100 "same composition, framing, person, pose, colors, lighting and text, unless the request says otherwise."]
96 if portraits:101 if portraits:
97- span = "Image 2" if portraits == 1 else f"Images 2 to {portraits + 1}"102+ lines.append(f"{_span(2, portraits)}: photos of the person in the thumbnail, only to keep their face exactly "
98- lines.append(f"{span}: photos of the person in the thumbnail, only to keep their face exactly "
99 "recognizable if it has to change. Do not add them as new elements.")103 "recognizable if it has to change. Do not add them as new elements.")
104+ if refs:
105+ what = "a reference image" if refs == 1 else "reference images"
106+ lines.append(f"{_span(2 + portraits, refs)}: {what} for the requested change. Use only what the request "
107+ "describes from them (an element to add or replace, a logo, an object, a style).")
100 lines += ["", f"Requested change: {instruction.strip()}",108 lines += ["", f"Requested change: {instruction.strip()}",
101 "", "Any text in the image must stay perfectly spelled."]109 "", "Any text in the image must stay perfectly spelled."]
102 return "\n".join(lines)110 return "\n".join(lines)
@@ -140,8 +148,10 @@ def start(project: dict, tid: str, models: list[str]) -> None:
140 _launch(project, tid, models, prompt, portraits + styles)148 _launch(project, tid, models, prompt, portraits + styles)
141 149
142 150
143-def start_edit(project: dict, tid: str, file: str, instruction: str, models: list[str]) -> None:151+def start_edit(project: dict, tid: str, file: str, instruction: str, models: list[str],
144- """Retouche une image de la galerie de `tid` : elle part en image 1, avec les portraits pour garder le visage."""152+ refs: list[str] | None = None) -> None:
153+ """Retouche une image de la galerie de `tid` : elle part en image 1, avec les portraits pour garder le visage
154+ et les images de référence de cette retouche (bibliothèque ou médias du projet, comme les références)."""
145 thumb = _thumb(project, tid)155 thumb = _thumb(project, tid)
146 if file not in {i["file"] for i in project.get("thumb_images", {}).get(tid, [])}:156 if file not in {i["file"] for i in project.get("thumb_images", {}).get(tid, [])}:
147 raise ValueError("image inconnue")157 raise ValueError("image inconnue")
@@ -150,9 +160,13 @@ def start_edit(project: dict, tid: str, file: str, instruction: str, models: lis
150 base = service.project_dir(project["id"]) / service.VIGNETTES_DIR / file160 base = service.project_dir(project["id"]) / service.VIGNETTES_DIR / file
151 if not base.is_file():161 if not base.is_file():
152 raise ValueError(f"fichier introuvable : {file}")162 raise ValueError(f"fichier introuvable : {file}")
153- portraits = [_ref_path(project, r) for r in thumb.get("portraits") or []][:MAX_REFS - 1]163+ extra = [_ref_path(project, r) for r in refs or []]
154- _launch(project, tid, models, build_edit_prompt(instruction, len(portraits)), [base, *portraits],164+ if len(extra) > MAX_REFS - 1:
155- source=file, edit=instruction.strip())165+ raise ValueError(f"{MAX_REFS - 1} images de référence au plus pour une retouche")
166+ # les références demandées passent avant les portraits : ceux-ci ne font que garder le visage
167+ portraits = [_ref_path(project, r) for r in thumb.get("portraits") or []][:MAX_REFS - 1 - len(extra)]
168+ _launch(project, tid, models, build_edit_prompt(instruction, len(portraits), len(extra)),
169+ [base, *portraits, *extra], source=file, edit=instruction.strip())
156 170
157 171
158 def ideas_payload(project: dict, tid: str, note: str) -> dict:172 def ideas_payload(project: dict, tid: str, note: str) -> dict:
modified web/src/i18n/locales/en/thumbnail.ts +2 -0
@@ -45,6 +45,8 @@ export default {
4545 removeTitle: "Remove from the gallery",
4646 retouchPlaceholder: "What to change, everything else stays. E.g. more surprised look, darker background, yellow text",
4747 retouch: "Touch up this image",
48+ retouchRefs: "Reference images for the touch-up",
49+ retouchRefsNone: "None: the instruction only. E.g. a logo to add, an object to copy",
4850 retouchStarted: "Touch-up started: it will appear at the top of the gallery",
4951 ideas: {
5052 title: "Layouts suggested by Daryl",
@@ -45,6 +45,8 @@ export default {
45 removeTitle: "Remove from the gallery",45 removeTitle: "Remove from the gallery",
46 retouchPlaceholder: "What to change, everything else stays. E.g. more surprised look, darker background, yellow text",46 retouchPlaceholder: "What to change, everything else stays. E.g. more surprised look, darker background, yellow text",
47 retouch: "Touch up this image",47 retouch: "Touch up this image",
48+ retouchRefs: "Reference images for the touch-up",
49+ retouchRefsNone: "None: the instruction only. E.g. a logo to add, an object to copy",
48 retouchStarted: "Touch-up started: it will appear at the top of the gallery",50 retouchStarted: "Touch-up started: it will appear at the top of the gallery",
49 ideas: {51 ideas: {
50 title: "Layouts suggested by Daryl",52 title: "Layouts suggested by Daryl",
modified web/src/i18n/locales/fr/thumbnail.ts +2 -0
@@ -45,6 +45,8 @@ export default {
4545 removeTitle: "Retirer de la galerie",
4646 retouchPlaceholder: "Ce qu’il faut changer, le reste ne bouge pas. Ex. : l’air plus surpris, fond plus sombre, le texte en jaune",
4747 retouch: "Retoucher cette image",
48+ retouchRefs: "Images de référence pour la retouche",
49+ retouchRefsNone: "Aucune : seulement la consigne. Ex. : un logo à mettre, un objet à reprendre",
4850 retouchStarted: "Retouche lancée : elle arrivera en tête de la galerie",
4951 ideas: {
5052 title: "Compositions proposées par Daryl",
@@ -45,6 +45,8 @@ export default {
45 removeTitle: "Retirer de la galerie",45 removeTitle: "Retirer de la galerie",
46 retouchPlaceholder: "Ce qu’il faut changer, le reste ne bouge pas. Ex. : l’air plus surpris, fond plus sombre, le texte en jaune",46 retouchPlaceholder: "Ce qu’il faut changer, le reste ne bouge pas. Ex. : l’air plus surpris, fond plus sombre, le texte en jaune",
47 retouch: "Retoucher cette image",47 retouch: "Retoucher cette image",
48+ retouchRefs: "Images de référence pour la retouche",
49+ retouchRefsNone: "Aucune : seulement la consigne. Ex. : un logo à mettre, un objet à reprendre",
48 retouchStarted: "Retouche lancée : elle arrivera en tête de la galerie",50 retouchStarted: "Retouche lancée : elle arrivera en tête de la galerie",
49 ideas: {51 ideas: {
50 title: "Compositions proposées par Daryl",52 title: "Compositions proposées par Daryl",
modified web/src/project/steps/VignetteStep.vue +24 -2
@@ -198,6 +198,9 @@ const EDIT_MODELS_KEY = "studio.thumbEditModels";
198198 const editChosen = ref<string[]>(JSON.parse(localStorage.getItem(EDIT_MODELS_KEY) ?? '["nano-banana-2"]'));
199199 watch(editChosen, (v) => localStorage.setItem(EDIT_MODELS_KEY, JSON.stringify(v)));
200200 const instruction = ref("");
201+// des images en plus pour cette retouche seulement (un logo à mettre, un objet à reprendre…)
202+const editRefs = ref<string[]>([]);
203+const pickingEditRefs = ref(false);
201204
202205 function toggleEditModel(id: string) {
203206 editChosen.value = editChosen.value.includes(id) ? editChosen.value.filter((m) => m !== id) : [...editChosen.value, id];
@@ -213,16 +216,17 @@ async function retouch() {
213216 const t = current.value, img = zoomed.value;
214217 if (!t?.id || !img || !canEdit.value) return;
215218 if (await act(`/thumbs/${t.id}/images/${encodeURIComponent(img.file)}/edit`,
216- { instruction: instruction.value, models: editChosen.value })) {
219+ { instruction: instruction.value, models: editChosen.value, refs: editRefs.value })) {
217220 toast(tr("thumbnail.retouchStarted"));
218221 instruction.value = "";
222+ editRefs.value = [];
219223 zoom.value = null;
220224 }
221225 }
222226
223227 function onKey(e: KeyboardEvent) {
224228 if (ideasOpen.value && e.key === "Escape") ideasOpen.value = false;
225- if (!zoomed.value) return;
229+ if (!zoomed.value || pickingEditRefs.value) return;
226230 const typing = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;
227231 if (e.key === "Escape") zoom.value = null;
228232 else if (typing) return;
@@ -475,6 +479,19 @@ const big = computed(() => feed.value === "desktop");
475479 v-model="instruction" class="textarea soft" rows="2"
476480 :placeholder="tr('thumbnail.retouchPlaceholder')"
477481 @keydown.ctrl.enter="retouch" />
482+ <div class="refs">
483+ <div class="refs-head">
484+ <label class="label">{{ tr("thumbnail.retouchRefs") }}</label>
485+ <button class="link-btn" @click="pickingEditRefs = true">{{ tr("thumbnail.pickFromLibrary") }}</button>
486+ </div>
487+ <div class="chips">
488+ <div v-for="rel in editRefs" :key="rel" class="chip">
489+ <img :src="refUrl(project.id, rel)" alt="" :title="rel.split('/').pop()">
490+ <button class="x-btn" :title="tr('common.remove')" @click="editRefs = editRefs.filter((r) => r !== rel)">×</button>
491+ </div>
492+ <span v-if="!editRefs.length" class="faint none">{{ tr("thumbnail.retouchRefsNone") }}</span>
493+ </div>
494+ </div>
478495 <div class="retouch-run">
479496 <button
480497 v-for="m in models" :key="m.id" class="model" :class="{ on: editChosen.includes(m.id) }"
@@ -487,6 +504,11 @@ const big = computed(() => feed.value === "desktop");
487504 </div>
488505 </div>
489506 </template>
507+
508+ <!-- après la visionneuse, pour passer par-dessus -->
509+ <LibraryPicker
510+ v-if="pickingEditRefs" :pid="project.id" category="images" :title="tr('thumbnail.retouchRefs')"
511+ :selected="editRefs" :max="MAX_REFS - 1" @done="editRefs = $event; pickingEditRefs = false" @cancel="pickingEditRefs = false" />
490512 </div>
491513 </template>
492514
@@ -198,6 +198,9 @@ const EDIT_MODELS_KEY = "studio.thumbEditModels";
198 const editChosen = ref<string[]>(JSON.parse(localStorage.getItem(EDIT_MODELS_KEY) ?? '["nano-banana-2"]'));198 const editChosen = ref<string[]>(JSON.parse(localStorage.getItem(EDIT_MODELS_KEY) ?? '["nano-banana-2"]'));
199 watch(editChosen, (v) => localStorage.setItem(EDIT_MODELS_KEY, JSON.stringify(v)));199 watch(editChosen, (v) => localStorage.setItem(EDIT_MODELS_KEY, JSON.stringify(v)));
200 const instruction = ref("");200 const instruction = ref("");
201+// des images en plus pour cette retouche seulement (un logo à mettre, un objet à reprendre…)
202+const editRefs = ref<string[]>([]);
203+const pickingEditRefs = ref(false);
201 204
202 function toggleEditModel(id: string) {205 function toggleEditModel(id: string) {
203 editChosen.value = editChosen.value.includes(id) ? editChosen.value.filter((m) => m !== id) : [...editChosen.value, id];206 editChosen.value = editChosen.value.includes(id) ? editChosen.value.filter((m) => m !== id) : [...editChosen.value, id];
@@ -213,16 +216,17 @@ async function retouch() {
213 const t = current.value, img = zoomed.value;216 const t = current.value, img = zoomed.value;
214 if (!t?.id || !img || !canEdit.value) return;217 if (!t?.id || !img || !canEdit.value) return;
215 if (await act(`/thumbs/${t.id}/images/${encodeURIComponent(img.file)}/edit`,218 if (await act(`/thumbs/${t.id}/images/${encodeURIComponent(img.file)}/edit`,
216- { instruction: instruction.value, models: editChosen.value })) {219+ { instruction: instruction.value, models: editChosen.value, refs: editRefs.value })) {
217 toast(tr("thumbnail.retouchStarted"));220 toast(tr("thumbnail.retouchStarted"));
218 instruction.value = "";221 instruction.value = "";
222+ editRefs.value = [];
219 zoom.value = null;223 zoom.value = null;
220 }224 }
221 }225 }
222 226
223 function onKey(e: KeyboardEvent) {227 function onKey(e: KeyboardEvent) {
224 if (ideasOpen.value && e.key === "Escape") ideasOpen.value = false;228 if (ideasOpen.value && e.key === "Escape") ideasOpen.value = false;
225- if (!zoomed.value) return;229+ if (!zoomed.value || pickingEditRefs.value) return;
226 const typing = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;230 const typing = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;
227 if (e.key === "Escape") zoom.value = null;231 if (e.key === "Escape") zoom.value = null;
228 else if (typing) return;232 else if (typing) return;
@@ -475,6 +479,19 @@ const big = computed(() => feed.value === "desktop");
475 v-model="instruction" class="textarea soft" rows="2"479 v-model="instruction" class="textarea soft" rows="2"
476 :placeholder="tr('thumbnail.retouchPlaceholder')"480 :placeholder="tr('thumbnail.retouchPlaceholder')"
477 @keydown.ctrl.enter="retouch" />481 @keydown.ctrl.enter="retouch" />
482+ <div class="refs">
483+ <div class="refs-head">
484+ <label class="label">{{ tr("thumbnail.retouchRefs") }}</label>
485+ <button class="link-btn" @click="pickingEditRefs = true">{{ tr("thumbnail.pickFromLibrary") }}</button>
486+ </div>
487+ <div class="chips">
488+ <div v-for="rel in editRefs" :key="rel" class="chip">
489+ <img :src="refUrl(project.id, rel)" alt="" :title="rel.split('/').pop()">
490+ <button class="x-btn" :title="tr('common.remove')" @click="editRefs = editRefs.filter((r) => r !== rel)">×</button>
491+ </div>
492+ <span v-if="!editRefs.length" class="faint none">{{ tr("thumbnail.retouchRefsNone") }}</span>
493+ </div>
494+ </div>
478 <div class="retouch-run">495 <div class="retouch-run">
479 <button496 <button
480 v-for="m in models" :key="m.id" class="model" :class="{ on: editChosen.includes(m.id) }"497 v-for="m in models" :key="m.id" class="model" :class="{ on: editChosen.includes(m.id) }"
@@ -487,6 +504,11 @@ const big = computed(() => feed.value === "desktop");
487 </div>504 </div>
488 </div>505 </div>
489 </template>506 </template>
507+
508+ <!-- après la visionneuse, pour passer par-dessus -->
509+ <LibraryPicker
510+ v-if="pickingEditRefs" :pid="project.id" category="images" :title="tr('thumbnail.retouchRefs')"
511+ :selected="editRefs" :max="MAX_REFS - 1" @done="editRefs = $event; pickingEditRefs = false" @cancel="pickingEditRefs = false" />
490 </div>512 </div>
491 </template>513 </template>
492 514