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.
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): | ||
| 736 | 736 | class ThumbEditRequest(BaseModel): |
| 737 | 737 | instruction: str |
| 738 | 738 | models: list[str] |
| 739 | + refs: list[str] = [] | |
| 739 | 740 | |
| 740 | 741 | |
| 741 | 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 | 747 | with service.editing(pid) as project: |
| 747 | 748 | runtime.sync(project) |
| 748 | 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 | 751 | except ValueError as e: |
| 751 | 752 | raise HTTPException(400, str(e)) from e |
| 752 | 753 | 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: str | 737 | 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 e | 752 | 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: | ||
| 89 | 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: | |
| 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.""" | |
| 94 | 99 | lines = ["Image 1 is a YouTube video thumbnail (16:9). Edit it as asked below and change nothing else: " |
| 95 | 100 | "same composition, framing, person, pose, colors, lighting and text, unless the request says otherwise."] |
| 96 | 101 | 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 " | |
| 99 | 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 | 108 | lines += ["", f"Requested change: {instruction.strip()}", |
| 101 | 109 | "", "Any text in the image must stay perfectly spelled."] |
| 102 | 110 | return "\n".join(lines) |
| @@ -140,8 +148,10 @@ def start(project: dict, tid: str, models: list[str]) -> None: | ||
| 140 | 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: | |
| 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).""" | |
| 145 | 155 | thumb = _thumb(project, tid) |
| 146 | 156 | if file not in {i["file"] for i in project.get("thumb_images", {}).get(tid, [])}: |
| 147 | 157 | raise ValueError("image inconnue") |
| @@ -150,9 +160,13 @@ def start_edit(project: dict, tid: str, file: str, instruction: str, models: lis | ||
| 150 | 160 | base = service.project_dir(project["id"]) / service.VIGNETTES_DIR / file |
| 151 | 161 | if not base.is_file(): |
| 152 | 162 | 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()) | |
| 156 | 170 | |
| 157 | 171 | |
| 158 | 172 | 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 / file | 160 | 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 { | ||
| 45 | 45 | removeTitle: "Remove from the gallery", |
| 46 | 46 | retouchPlaceholder: "What to change, everything else stays. E.g. more surprised look, darker background, yellow text", |
| 47 | 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 | 50 | retouchStarted: "Touch-up started: it will appear at the top of the gallery", |
| 49 | 51 | ideas: { |
| 50 | 52 | 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 { | ||
| 45 | 45 | removeTitle: "Retirer de la galerie", |
| 46 | 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 | 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 | 50 | retouchStarted: "Retouche lancée : elle arrivera en tête de la galerie", |
| 49 | 51 | ideas: { |
| 50 | 52 | 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"; | ||
| 198 | 198 | const editChosen = ref<string[]>(JSON.parse(localStorage.getItem(EDIT_MODELS_KEY) ?? '["nano-banana-2"]')); |
| 199 | 199 | watch(editChosen, (v) => localStorage.setItem(EDIT_MODELS_KEY, JSON.stringify(v))); |
| 200 | 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 | 205 | function toggleEditModel(id: string) { |
| 203 | 206 | editChosen.value = editChosen.value.includes(id) ? editChosen.value.filter((m) => m !== id) : [...editChosen.value, id]; |
| @@ -213,16 +216,17 @@ async function retouch() { | ||
| 213 | 216 | const t = current.value, img = zoomed.value; |
| 214 | 217 | if (!t?.id || !img || !canEdit.value) return; |
| 215 | 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 | 220 | toast(tr("thumbnail.retouchStarted")); |
| 218 | 221 | instruction.value = ""; |
| 222 | + editRefs.value = []; | |
| 219 | 223 | zoom.value = null; |
| 220 | 224 | } |
| 221 | 225 | } |
| 222 | 226 | |
| 223 | 227 | function onKey(e: KeyboardEvent) { |
| 224 | 228 | if (ideasOpen.value && e.key === "Escape") ideasOpen.value = false; |
| 225 | - if (!zoomed.value) return; | |
| 229 | + if (!zoomed.value || pickingEditRefs.value) return; | |
| 226 | 230 | const typing = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement; |
| 227 | 231 | if (e.key === "Escape") zoom.value = null; |
| 228 | 232 | else if (typing) return; |
| @@ -475,6 +479,19 @@ const big = computed(() => feed.value === "desktop"); | ||
| 475 | 479 | v-model="instruction" class="textarea soft" rows="2" |
| 476 | 480 | :placeholder="tr('thumbnail.retouchPlaceholder')" |
| 477 | 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 | 495 | <div class="retouch-run"> |
| 479 | 496 | <button |
| 480 | 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 | 504 | </div> |
| 488 | 505 | </div> |
| 489 | 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 | 512 | </div> |
| 491 | 513 | </template> |
| 492 | 514 | |
| @@ -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 | <button | 496 | <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 | ||