feat(vignette): pick reference thumbnails from the project's media
The reference picker only listed the shared library, so thumbnails downloaded into the project never showed up. It now also lists the project's images (assets and added files), referenced as projet/<rel> and resolved against the project folder when generating.
456658e parent: baa3967 modified
backend/studio/modules/projects/thumbs.py +18 -12 | @@ -3,8 +3,8 @@ L'étape Vignette côté API : les compositions proposées par Claude, la géné | ||
| 3 | 3 | |
| 4 | 4 | Chaque variante (project.thumbs, éditée par l'interface) est associée à l'un des titres, et a sa |
| 5 | 5 | description, le texte que le modèle écrit dans l'image (vide : aucun), ses portraits et ses |
| 6 | -vignettes de style, choisis dans la bibliothèque (toutes catégories). Une génération lance | |
| 7 | -un job par modèle (`<pid>-thumb-<variante>-<modèle>`), les modèles tournent en parallèle ; une retouche | |
| 6 | +vignettes de style, choisis dans la bibliothèque (toutes catégories) ou les médias du projet. | |
| 7 | +Une génération lance un job par modèle (`<pid>-thumb-<variante>-<modèle>`), les modèles tournent en parallèle ; une retouche | |
| 8 | 8 | (`start_edit`) fait de même en partant d'une image de la galerie et d'une consigne. Les images produites |
| 9 | 9 | vont dans vignettes/ ; leur liste, project.thumb_images (variante -> images), n'est écrite que |
| 10 | 10 | par l'API (`sync`, `remove_image`) : l'interface, qui renvoie project.thumbs en entier, ne peut |
| @@ -43,11 +43,17 @@ def ready() -> bool: | ||
| 43 | 43 | return bool(fal.key()) |
| 44 | 44 | |
| 45 | 45 | |
| 46 | -def _ref_path(rel: str) -> Path: | |
| 47 | - """Une image de la bibliothèque, quelle que soit sa catégorie (le sélecteur les propose toutes).""" | |
| 48 | - path = (library.LIBRARY_DIR / rel).resolve() | |
| 49 | - if library.LIBRARY_DIR.resolve() not in path.parents or not path.is_file(): | |
| 50 | - raise ValueError(f"image introuvable dans la bibliothèque : {rel}") | |
| 46 | +# une référence prise dans les médias du projet plutôt que dans la bibliothèque | |
| 47 | +PROJECT_PREFIX = "projet/" | |
| 48 | + | |
| 49 | + | |
| 50 | +def _ref_path(project: dict, rel: str) -> Path: | |
| 51 | + """Une image de la bibliothèque, quelle que soit sa catégorie (le sélecteur les propose toutes), ou | |
| 52 | + « projet/<rel> » : une image des médias du projet.""" | |
| 53 | + root = service.project_dir(project["id"]) if rel.startswith(PROJECT_PREFIX) else library.LIBRARY_DIR | |
| 54 | + path = (root / rel.removeprefix(PROJECT_PREFIX)).resolve() | |
| 55 | + if root.resolve() not in path.parents or not path.is_file(): | |
| 56 | + raise ValueError(f"image de référence introuvable : {rel}") | |
| 51 | 57 | return path |
| 52 | 58 | |
| 53 | 59 | |
| @@ -126,8 +132,8 @@ def _launch(project: dict, tid: str, models: list[str], prompt: str, refs: list[ | ||
| 126 | 132 | def start(project: dict, tid: str, models: list[str]) -> None: |
| 127 | 133 | """Génère des vignettes neuves pour la variante `tid`, à partir de sa description et de ses références.""" |
| 128 | 134 | thumb = _thumb(project, tid) |
| 129 | - portraits = [_ref_path(r) for r in thumb.get("portraits") or []] | |
| 130 | - styles = [_ref_path(r) for r in thumb.get("styles") or []] | |
| 135 | + portraits = [_ref_path(project, r) for r in thumb.get("portraits") or []] | |
| 136 | + styles = [_ref_path(project, r) for r in thumb.get("styles") or []] | |
| 131 | 137 | if not portraits and not styles: |
| 132 | 138 | raise ValueError("choisis au moins un portrait ou une vignette de référence") |
| 133 | 139 | prompt = build_prompt(thumb, video_title(project, thumb), len(portraits), len(styles)) |
| @@ -144,7 +150,7 @@ def start_edit(project: dict, tid: str, file: str, instruction: str, models: lis | ||
| 144 | 150 | base = service.project_dir(project["id"]) / service.VIGNETTES_DIR / file |
| 145 | 151 | if not base.is_file(): |
| 146 | 152 | raise ValueError(f"fichier introuvable : {file}") |
| 147 | - portraits = [_ref_path(r) for r in thumb.get("portraits") or []][:MAX_REFS - 1] | |
| 153 | + portraits = [_ref_path(project, r) for r in thumb.get("portraits") or []][:MAX_REFS - 1] | |
| 148 | 154 | _launch(project, tid, models, build_edit_prompt(instruction, len(portraits)), [base, *portraits], |
| 149 | 155 | source=file, edit=instruction.strip()) |
| 150 | 156 | |
| @@ -158,8 +164,8 @@ def ideas_payload(project: dict, tid: str, note: str) -> dict: | ||
| 158 | 164 | angle = project["angles"][sel] if sel is not None and 0 <= sel < len(project["angles"]) else None |
| 159 | 165 | return {"thumb": tid, "note": note, "angle": angle, "current": thumb, "variants": project["thumbs"], |
| 160 | 166 | "titles": [t["t"] for t in project["titles"] if t.get("pick")], "title": video_title(project, thumb), |
| 161 | - "portraits": [str(_ref_path(r)) for r in thumb.get("portraits") or []], | |
| 162 | - "styles": [str(_ref_path(r)) for r in thumb.get("styles") or []]} | |
| 167 | + "portraits": [str(_ref_path(project, r)) for r in thumb.get("portraits") or []], | |
| 168 | + "styles": [str(_ref_path(project, r)) for r in thumb.get("styles") or []]} | |
| 163 | 169 | |
| 164 | 170 | |
| 165 | 171 | def apply_ideas(project: dict, result: dict, tid: str) -> None: |
| @@ -3,8 +3,8 @@ L'étape Vignette côté API : les compositions proposées par Claude, la géné | |||
| 3 | 3 | ||
| 4 | Chaque variante (project.thumbs, éditée par l'interface) est associée à l'un des titres, et a sa | 4 | Chaque variante (project.thumbs, éditée par l'interface) est associée à l'un des titres, et a sa |
| 5 | description, le texte que le modèle écrit dans l'image (vide : aucun), ses portraits et ses | 5 | description, le texte que le modèle écrit dans l'image (vide : aucun), ses portraits et ses |
| 6 | -vignettes de style, choisis dans la bibliothèque (toutes catégories). Une génération lance | 6 | +vignettes de style, choisis dans la bibliothèque (toutes catégories) ou les médias du projet. |
| 7 | -un job par modèle (`<pid>-thumb-<variante>-<modèle>`), les modèles tournent en parallèle ; une retouche | 7 | +Une génération lance un job par modèle (`<pid>-thumb-<variante>-<modèle>`), les modèles tournent en parallèle ; une retouche |
| 8 | (`start_edit`) fait de même en partant d'une image de la galerie et d'une consigne. Les images produites | 8 | (`start_edit`) fait de même en partant d'une image de la galerie et d'une consigne. Les images produites |
| 9 | vont dans vignettes/ ; leur liste, project.thumb_images (variante -> images), n'est écrite que | 9 | vont dans vignettes/ ; leur liste, project.thumb_images (variante -> images), n'est écrite que |
| 10 | par l'API (`sync`, `remove_image`) : l'interface, qui renvoie project.thumbs en entier, ne peut | 10 | par l'API (`sync`, `remove_image`) : l'interface, qui renvoie project.thumbs en entier, ne peut |
| @@ -43,11 +43,17 @@ def ready() -> bool: | |||
| 43 | return bool(fal.key()) | 43 | return bool(fal.key()) |
| 44 | 44 | ||
| 45 | 45 | ||
| 46 | -def _ref_path(rel: str) -> Path: | 46 | +# une référence prise dans les médias du projet plutôt que dans la bibliothèque |
| 47 | - """Une image de la bibliothèque, quelle que soit sa catégorie (le sélecteur les propose toutes).""" | 47 | +PROJECT_PREFIX = "projet/" |
| 48 | - path = (library.LIBRARY_DIR / rel).resolve() | 48 | + |
| 49 | - if library.LIBRARY_DIR.resolve() not in path.parents or not path.is_file(): | 49 | + |
| 50 | - raise ValueError(f"image introuvable dans la bibliothèque : {rel}") | 50 | +def _ref_path(project: dict, rel: str) -> Path: |
| 51 | + """Une image de la bibliothèque, quelle que soit sa catégorie (le sélecteur les propose toutes), ou | ||
| 52 | + « projet/<rel> » : une image des médias du projet.""" | ||
| 53 | + root = service.project_dir(project["id"]) if rel.startswith(PROJECT_PREFIX) else library.LIBRARY_DIR | ||
| 54 | + path = (root / rel.removeprefix(PROJECT_PREFIX)).resolve() | ||
| 55 | + if root.resolve() not in path.parents or not path.is_file(): | ||
| 56 | + raise ValueError(f"image de référence introuvable : {rel}") | ||
| 51 | return path | 57 | return path |
| 52 | 58 | ||
| 53 | 59 | ||
| @@ -126,8 +132,8 @@ def _launch(project: dict, tid: str, models: list[str], prompt: str, refs: list[ | |||
| 126 | def start(project: dict, tid: str, models: list[str]) -> None: | 132 | def start(project: dict, tid: str, models: list[str]) -> None: |
| 127 | """Génère des vignettes neuves pour la variante `tid`, à partir de sa description et de ses références.""" | 133 | """Génère des vignettes neuves pour la variante `tid`, à partir de sa description et de ses références.""" |
| 128 | thumb = _thumb(project, tid) | 134 | thumb = _thumb(project, tid) |
| 129 | - portraits = [_ref_path(r) for r in thumb.get("portraits") or []] | 135 | + portraits = [_ref_path(project, r) for r in thumb.get("portraits") or []] |
| 130 | - styles = [_ref_path(r) for r in thumb.get("styles") or []] | 136 | + styles = [_ref_path(project, r) for r in thumb.get("styles") or []] |
| 131 | if not portraits and not styles: | 137 | if not portraits and not styles: |
| 132 | raise ValueError("choisis au moins un portrait ou une vignette de référence") | 138 | raise ValueError("choisis au moins un portrait ou une vignette de référence") |
| 133 | prompt = build_prompt(thumb, video_title(project, thumb), len(portraits), len(styles)) | 139 | prompt = build_prompt(thumb, video_title(project, thumb), len(portraits), len(styles)) |
| @@ -144,7 +150,7 @@ def start_edit(project: dict, tid: str, file: str, instruction: str, models: lis | |||
| 144 | base = service.project_dir(project["id"]) / service.VIGNETTES_DIR / file | 150 | base = service.project_dir(project["id"]) / service.VIGNETTES_DIR / file |
| 145 | if not base.is_file(): | 151 | if not base.is_file(): |
| 146 | raise ValueError(f"fichier introuvable : {file}") | 152 | raise ValueError(f"fichier introuvable : {file}") |
| 147 | - portraits = [_ref_path(r) for r in thumb.get("portraits") or []][:MAX_REFS - 1] | 153 | + portraits = [_ref_path(project, r) for r in thumb.get("portraits") or []][:MAX_REFS - 1] |
| 148 | _launch(project, tid, models, build_edit_prompt(instruction, len(portraits)), [base, *portraits], | 154 | _launch(project, tid, models, build_edit_prompt(instruction, len(portraits)), [base, *portraits], |
| 149 | source=file, edit=instruction.strip()) | 155 | source=file, edit=instruction.strip()) |
| 150 | 156 | ||
| @@ -158,8 +164,8 @@ def ideas_payload(project: dict, tid: str, note: str) -> dict: | |||
| 158 | angle = project["angles"][sel] if sel is not None and 0 <= sel < len(project["angles"]) else None | 164 | angle = project["angles"][sel] if sel is not None and 0 <= sel < len(project["angles"]) else None |
| 159 | return {"thumb": tid, "note": note, "angle": angle, "current": thumb, "variants": project["thumbs"], | 165 | return {"thumb": tid, "note": note, "angle": angle, "current": thumb, "variants": project["thumbs"], |
| 160 | "titles": [t["t"] for t in project["titles"] if t.get("pick")], "title": video_title(project, thumb), | 166 | "titles": [t["t"] for t in project["titles"] if t.get("pick")], "title": video_title(project, thumb), |
| 161 | - "portraits": [str(_ref_path(r)) for r in thumb.get("portraits") or []], | 167 | + "portraits": [str(_ref_path(project, r)) for r in thumb.get("portraits") or []], |
| 162 | - "styles": [str(_ref_path(r)) for r in thumb.get("styles") or []]} | 168 | + "styles": [str(_ref_path(project, r)) for r in thumb.get("styles") or []]} |
| 163 | 169 | ||
| 164 | 170 | ||
| 165 | def apply_ideas(project: dict, result: dict, tid: str) -> None: | 171 | def apply_ideas(project: dict, result: dict, tid: str) -> None: |
modified
web/src/i18n/locales/en/thumbnail.ts +1 -0 | @@ -72,6 +72,7 @@ export default { | ||
| 72 | 72 | max: "{n} images max", |
| 73 | 73 | picked: "Library · none selected | Library · {n} selected | Library · {n} selected", |
| 74 | 74 | search: "Search (name, project)…", |
| 75 | + project: "This project", | |
| 75 | 76 | all: "All · {n}", |
| 76 | 77 | noMatch: "No matching images.", |
| 77 | 78 | emptyCategory: "No images in this category yet.", |
| @@ -72,6 +72,7 @@ export default { | |||
| 72 | max: "{n} images max", | 72 | max: "{n} images max", |
| 73 | picked: "Library · none selected | Library · {n} selected | Library · {n} selected", | 73 | picked: "Library · none selected | Library · {n} selected | Library · {n} selected", |
| 74 | search: "Search (name, project)…", | 74 | search: "Search (name, project)…", |
| 75 | + project: "This project", | ||
| 75 | all: "All · {n}", | 76 | all: "All · {n}", |
| 76 | noMatch: "No matching images.", | 77 | noMatch: "No matching images.", |
| 77 | emptyCategory: "No images in this category yet.", | 78 | emptyCategory: "No images in this category yet.", |
modified
web/src/i18n/locales/fr/thumbnail.ts +1 -0 | @@ -72,6 +72,7 @@ export default { | ||
| 72 | 72 | max: "{n} images au plus", |
| 73 | 73 | picked: "Bibliothèque · {n} choisie | Bibliothèque · {n} choisie | Bibliothèque · {n} choisies", |
| 74 | 74 | search: "Rechercher (nom, projet)…", |
| 75 | + project: "Ce projet", | |
| 75 | 76 | all: "Toutes · {n}", |
| 76 | 77 | noMatch: "Aucune image ne correspond.", |
| 77 | 78 | emptyCategory: "Aucune image dans cette catégorie pour l’instant.", |
| @@ -72,6 +72,7 @@ export default { | |||
| 72 | max: "{n} images au plus", | 72 | max: "{n} images au plus", |
| 73 | picked: "Bibliothèque · {n} choisie | Bibliothèque · {n} choisie | Bibliothèque · {n} choisies", | 73 | picked: "Bibliothèque · {n} choisie | Bibliothèque · {n} choisie | Bibliothèque · {n} choisies", |
| 74 | search: "Rechercher (nom, projet)…", | 74 | search: "Rechercher (nom, projet)…", |
| 75 | + project: "Ce projet", | ||
| 75 | all: "Toutes · {n}", | 76 | all: "Toutes · {n}", |
| 76 | noMatch: "Aucune image ne correspond.", | 77 | noMatch: "Aucune image ne correspond.", |
| 77 | emptyCategory: "Aucune image dans cette catégorie pour l’instant.", | 78 | emptyCategory: "Aucune image dans cette catégorie pour l’instant.", |
modified
web/src/project/steps/VignetteStep.vue +3 -3 | @@ -16,6 +16,7 @@ import { act, failed, running, store, update } from "../store"; | ||
| 16 | 16 | import type { Thumb, ThumbIdea, ThumbImage } from "../types"; |
| 17 | 17 | import LibraryPicker from "./vignette/LibraryPicker.vue"; |
| 18 | 18 | import VariantTile from "./vignette/VariantTile.vue"; |
| 19 | +import { refUrl } from "./vignette/refs"; | |
| 19 | 20 | |
| 20 | 21 | // le plafond d'images de référence de fal (backend projects/thumbs.py) |
| 21 | 22 | const MAX_REFS = 10; |
| @@ -246,7 +247,6 @@ const PICKERS = { | ||
| 246 | 247 | portraits: { category: "portraits", title: "thumbnail.myPortraits" }, |
| 247 | 248 | styles: { category: "vignettes", title: "thumbnail.styleRefs" }, |
| 248 | 249 | } as const; |
| 249 | -const libUrl = (rel: string) => `/api/projects/library/file/${rel.split("/").map(encodeURIComponent).join("/")}`; | |
| 250 | 250 | const pickerMax = computed(() => { |
| 251 | 251 | const t = current.value; |
| 252 | 252 | if (!t || !picking.value) return MAX_REFS; |
| @@ -328,7 +328,7 @@ const big = computed(() => feed.value === "desktop"); | ||
| 328 | 328 | </div> |
| 329 | 329 | <div class="chips"> |
| 330 | 330 | <div v-for="rel in current[kind] ?? []" :key="rel" class="chip"> |
| 331 | - <img :src="libUrl(rel)" alt="" :title="rel.split('/').pop()"> | |
| 331 | + <img :src="refUrl(project.id, rel)" alt="" :title="rel.split('/').pop()"> | |
| 332 | 332 | <button class="x-btn" :title="tr('common.remove')" @click="unref(kind, rel)">×</button> |
| 333 | 333 | </div> |
| 334 | 334 | <span v-if="!(current[kind] ?? []).length" class="faint none"> |
| @@ -409,7 +409,7 @@ const big = computed(() => feed.value === "desktop"); | ||
| 409 | 409 | </section> |
| 410 | 410 | |
| 411 | 411 | <LibraryPicker |
| 412 | - v-if="picking && current" :category="PICKERS[picking].category" :title="tr(PICKERS[picking].title)" | |
| 412 | + v-if="picking && current" :pid="project.id" :category="PICKERS[picking].category" :title="tr(PICKERS[picking].title)" | |
| 413 | 413 | :selected="current[picking] ?? []" :max="pickerMax" @done="picked" @cancel="picking = null" /> |
| 414 | 414 | |
| 415 | 415 | <template v-if="ideasOpen && current"> |
| @@ -16,6 +16,7 @@ import { act, failed, running, store, update } from "../store"; | |||
| 16 | import type { Thumb, ThumbIdea, ThumbImage } from "../types"; | 16 | import type { Thumb, ThumbIdea, ThumbImage } from "../types"; |
| 17 | import LibraryPicker from "./vignette/LibraryPicker.vue"; | 17 | import LibraryPicker from "./vignette/LibraryPicker.vue"; |
| 18 | import VariantTile from "./vignette/VariantTile.vue"; | 18 | import VariantTile from "./vignette/VariantTile.vue"; |
| 19 | +import { refUrl } from "./vignette/refs"; | ||
| 19 | 20 | ||
| 20 | // le plafond d'images de référence de fal (backend projects/thumbs.py) | 21 | // le plafond d'images de référence de fal (backend projects/thumbs.py) |
| 21 | const MAX_REFS = 10; | 22 | const MAX_REFS = 10; |
| @@ -246,7 +247,6 @@ const PICKERS = { | |||
| 246 | portraits: { category: "portraits", title: "thumbnail.myPortraits" }, | 247 | portraits: { category: "portraits", title: "thumbnail.myPortraits" }, |
| 247 | styles: { category: "vignettes", title: "thumbnail.styleRefs" }, | 248 | styles: { category: "vignettes", title: "thumbnail.styleRefs" }, |
| 248 | } as const; | 249 | } as const; |
| 249 | -const libUrl = (rel: string) => `/api/projects/library/file/${rel.split("/").map(encodeURIComponent).join("/")}`; | ||
| 250 | const pickerMax = computed(() => { | 250 | const pickerMax = computed(() => { |
| 251 | const t = current.value; | 251 | const t = current.value; |
| 252 | if (!t || !picking.value) return MAX_REFS; | 252 | if (!t || !picking.value) return MAX_REFS; |
| @@ -328,7 +328,7 @@ const big = computed(() => feed.value === "desktop"); | |||
| 328 | </div> | 328 | </div> |
| 329 | <div class="chips"> | 329 | <div class="chips"> |
| 330 | <div v-for="rel in current[kind] ?? []" :key="rel" class="chip"> | 330 | <div v-for="rel in current[kind] ?? []" :key="rel" class="chip"> |
| 331 | - <img :src="libUrl(rel)" alt="" :title="rel.split('/').pop()"> | 331 | + <img :src="refUrl(project.id, rel)" alt="" :title="rel.split('/').pop()"> |
| 332 | <button class="x-btn" :title="tr('common.remove')" @click="unref(kind, rel)">×</button> | 332 | <button class="x-btn" :title="tr('common.remove')" @click="unref(kind, rel)">×</button> |
| 333 | </div> | 333 | </div> |
| 334 | <span v-if="!(current[kind] ?? []).length" class="faint none"> | 334 | <span v-if="!(current[kind] ?? []).length" class="faint none"> |
| @@ -409,7 +409,7 @@ const big = computed(() => feed.value === "desktop"); | |||
| 409 | </section> | 409 | </section> |
| 410 | 410 | ||
| 411 | <LibraryPicker | 411 | <LibraryPicker |
| 412 | - v-if="picking && current" :category="PICKERS[picking].category" :title="tr(PICKERS[picking].title)" | 412 | + v-if="picking && current" :pid="project.id" :category="PICKERS[picking].category" :title="tr(PICKERS[picking].title)" |
| 413 | :selected="current[picking] ?? []" :max="pickerMax" @done="picked" @cancel="picking = null" /> | 413 | :selected="current[picking] ?? []" :max="pickerMax" @done="picked" @cancel="picking = null" /> |
| 414 | 414 | ||
| 415 | <template v-if="ideasOpen && current"> | 415 | <template v-if="ideasOpen && current"> |
modified
web/src/project/steps/vignette/LibraryPicker.vue +19 -8 | @@ -1,21 +1,24 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | 2 | /** |
| 3 | - * Choix d'images d'une catégorie de la bibliothèque (plusieurs à la fois) ; on peut y glisser de | |
| 4 | - * nouvelles images, rangées dans la catégorie et cochées d'emblée. | |
| 3 | + * Choix d'images d'une catégorie de la bibliothèque (plusieurs à la fois), ou parmi les images du projet | |
| 4 | + * (assets et fichiers ajoutés) ; on peut y glisser de nouvelles images, rangées dans la catégorie et | |
| 5 | + * cochées d'emblée. | |
| 5 | 6 | */ |
| 6 | 7 | import { computed, onMounted, ref } from "vue"; |
| 7 | 8 | import { useI18n } from "vue-i18n"; |
| 8 | 9 | import { api } from "../../../lib/api"; |
| 9 | 10 | import { toast } from "../../../lib/toast"; |
| 10 | 11 | import { droppedFiles, upload } from "../../../lib/upload"; |
| 12 | +import { PROJECT_PREFIX, refUrl } from "./refs"; | |
| 11 | 13 | |
| 12 | 14 | interface Item { name: string; rel: string; category: string; type: string; project: string | null; kind: string } |
| 13 | 15 | |
| 14 | -const props = defineProps<{ category: string; title: string; selected: string[]; max: number }>(); | |
| 16 | +const props = defineProps<{ pid: string; category: string; title: string; selected: string[]; max: number }>(); | |
| 15 | 17 | const emit = defineEmits<{ done: [rels: string[]]; cancel: [] }>(); |
| 16 | 18 | |
| 17 | 19 | const { t } = useI18n(); |
| 18 | 20 | const items = ref<Item[]>([]); |
| 21 | +const own = ref<Item[]>([]); | |
| 19 | 22 | const picked = ref<string[]>([...props.selected]); |
| 20 | 23 | const over = ref(false); |
| 21 | 24 | const uploading = ref<number | null>(null); |
| @@ -23,7 +26,7 @@ const uploading = ref<number | null>(null); | ||
| 23 | 26 | const filter = ref<string>(props.category); |
| 24 | 27 | const query = ref(""); |
| 25 | 28 | |
| 26 | -const images = computed(() => items.value.filter((x) => x.kind === "Images")); | |
| 29 | +const images = computed(() => [...own.value, ...items.value].filter((x) => x.kind === "Images")); | |
| 27 | 30 | const categories = computed(() => { |
| 28 | 31 | const counts = new Map<string, { type: string; n: number }>(); |
| 29 | 32 | for (const x of images.value) { |
| @@ -38,8 +41,18 @@ const shown = computed(() => { | ||
| 38 | 41 | && (!q || `${x.name} ${x.project ?? ""} ${x.type}`.toLowerCase().includes(q))); |
| 39 | 42 | }); |
| 40 | 43 | |
| 44 | +// les médias du projet, comme le panneau Médias du script | |
| 45 | +const PROJECT_GROUPS = ["Assets", "Fichiers ajoutés"]; | |
| 46 | + | |
| 41 | 47 | onMounted(async () => { |
| 42 | - items.value = (await api<{ items: Item[] }>("/api/projects/library")).items; | |
| 48 | + const [lib, res] = await Promise.all([ | |
| 49 | + api<{ items: Item[] }>("/api/projects/library"), | |
| 50 | + api<{ groups: { k: string; items: { name: string; rel: string; kind: string }[] }[] }>(`/api/projects/${props.pid}/resources`), | |
| 51 | + ]); | |
| 52 | + items.value = lib.items; | |
| 53 | + own.value = res.groups.filter((g) => PROJECT_GROUPS.includes(g.k)).flatMap((g) => g.items).map((x) => ({ | |
| 54 | + ...x, rel: `${PROJECT_PREFIX}${x.rel}`, category: PROJECT_PREFIX, type: t("thumbnail.picker.project"), project: null, | |
| 55 | + })); | |
| 43 | 56 | if (!images.value.some((x) => x.category === props.category)) filter.value = "all"; |
| 44 | 57 | }); |
| 45 | 58 | |
| @@ -68,8 +81,6 @@ async function onDrop(e: DragEvent) { | ||
| 68 | 81 | uploading.value = null; |
| 69 | 82 | } |
| 70 | 83 | } |
| 71 | - | |
| 72 | -const src = (rel: string) => `/api/projects/library/file/${rel.split("/").map(encodeURIComponent).join("/")}`; | |
| 73 | 84 | </script> |
| 74 | 85 | |
| 75 | 86 | <template> |
| @@ -92,7 +103,7 @@ const src = (rel: string) => `/api/projects/library/file/${rel.split("/").map(en | ||
| 92 | 103 | </div> |
| 93 | 104 | <div class="grid scroll"> |
| 94 | 105 | <button v-for="x in shown" :key="x.rel" class="cell" :class="{ on: picked.includes(x.rel) }" :title="`${x.type} · ${x.name}`" @click="toggle(x.rel)"> |
| 95 | - <img :src="src(x.rel)" alt=""> | |
| 106 | + <img :src="refUrl(pid, x.rel)" alt=""> | |
| 96 | 107 | <span v-if="picked.includes(x.rel)" class="n">{{ picked.indexOf(x.rel) + 1 }}</span> |
| 97 | 108 | </button> |
| 98 | 109 | <span v-if="!shown.length" class="nothing"> |
| @@ -1,21 +1,24 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | /** | 2 | /** |
| 3 | - * Choix d'images d'une catégorie de la bibliothèque (plusieurs à la fois) ; on peut y glisser de | 3 | + * Choix d'images d'une catégorie de la bibliothèque (plusieurs à la fois), ou parmi les images du projet |
| 4 | - * nouvelles images, rangées dans la catégorie et cochées d'emblée. | 4 | + * (assets et fichiers ajoutés) ; on peut y glisser de nouvelles images, rangées dans la catégorie et |
| 5 | + * cochées d'emblée. | ||
| 5 | */ | 6 | */ |
| 6 | import { computed, onMounted, ref } from "vue"; | 7 | import { computed, onMounted, ref } from "vue"; |
| 7 | import { useI18n } from "vue-i18n"; | 8 | import { useI18n } from "vue-i18n"; |
| 8 | import { api } from "../../../lib/api"; | 9 | import { api } from "../../../lib/api"; |
| 9 | import { toast } from "../../../lib/toast"; | 10 | import { toast } from "../../../lib/toast"; |
| 10 | import { droppedFiles, upload } from "../../../lib/upload"; | 11 | import { droppedFiles, upload } from "../../../lib/upload"; |
| 12 | +import { PROJECT_PREFIX, refUrl } from "./refs"; | ||
| 11 | 13 | ||
| 12 | interface Item { name: string; rel: string; category: string; type: string; project: string | null; kind: string } | 14 | interface Item { name: string; rel: string; category: string; type: string; project: string | null; kind: string } |
| 13 | 15 | ||
| 14 | -const props = defineProps<{ category: string; title: string; selected: string[]; max: number }>(); | 16 | +const props = defineProps<{ pid: string; category: string; title: string; selected: string[]; max: number }>(); |
| 15 | const emit = defineEmits<{ done: [rels: string[]]; cancel: [] }>(); | 17 | const emit = defineEmits<{ done: [rels: string[]]; cancel: [] }>(); |
| 16 | 18 | ||
| 17 | const { t } = useI18n(); | 19 | const { t } = useI18n(); |
| 18 | const items = ref<Item[]>([]); | 20 | const items = ref<Item[]>([]); |
| 21 | +const own = ref<Item[]>([]); | ||
| 19 | const picked = ref<string[]>([...props.selected]); | 22 | const picked = ref<string[]>([...props.selected]); |
| 20 | const over = ref(false); | 23 | const over = ref(false); |
| 21 | const uploading = ref<number | null>(null); | 24 | const uploading = ref<number | null>(null); |
| @@ -23,7 +26,7 @@ const uploading = ref<number | null>(null); | |||
| 23 | const filter = ref<string>(props.category); | 26 | const filter = ref<string>(props.category); |
| 24 | const query = ref(""); | 27 | const query = ref(""); |
| 25 | 28 | ||
| 26 | -const images = computed(() => items.value.filter((x) => x.kind === "Images")); | 29 | +const images = computed(() => [...own.value, ...items.value].filter((x) => x.kind === "Images")); |
| 27 | const categories = computed(() => { | 30 | const categories = computed(() => { |
| 28 | const counts = new Map<string, { type: string; n: number }>(); | 31 | const counts = new Map<string, { type: string; n: number }>(); |
| 29 | for (const x of images.value) { | 32 | for (const x of images.value) { |
| @@ -38,8 +41,18 @@ const shown = computed(() => { | |||
| 38 | && (!q || `${x.name} ${x.project ?? ""} ${x.type}`.toLowerCase().includes(q))); | 41 | && (!q || `${x.name} ${x.project ?? ""} ${x.type}`.toLowerCase().includes(q))); |
| 39 | }); | 42 | }); |
| 40 | 43 | ||
| 44 | +// les médias du projet, comme le panneau Médias du script | ||
| 45 | +const PROJECT_GROUPS = ["Assets", "Fichiers ajoutés"]; | ||
| 46 | + | ||
| 41 | onMounted(async () => { | 47 | onMounted(async () => { |
| 42 | - items.value = (await api<{ items: Item[] }>("/api/projects/library")).items; | 48 | + const [lib, res] = await Promise.all([ |
| 49 | + api<{ items: Item[] }>("/api/projects/library"), | ||
| 50 | + api<{ groups: { k: string; items: { name: string; rel: string; kind: string }[] }[] }>(`/api/projects/${props.pid}/resources`), | ||
| 51 | + ]); | ||
| 52 | + items.value = lib.items; | ||
| 53 | + own.value = res.groups.filter((g) => PROJECT_GROUPS.includes(g.k)).flatMap((g) => g.items).map((x) => ({ | ||
| 54 | + ...x, rel: `${PROJECT_PREFIX}${x.rel}`, category: PROJECT_PREFIX, type: t("thumbnail.picker.project"), project: null, | ||
| 55 | + })); | ||
| 43 | if (!images.value.some((x) => x.category === props.category)) filter.value = "all"; | 56 | if (!images.value.some((x) => x.category === props.category)) filter.value = "all"; |
| 44 | }); | 57 | }); |
| 45 | 58 | ||
| @@ -68,8 +81,6 @@ async function onDrop(e: DragEvent) { | |||
| 68 | uploading.value = null; | 81 | uploading.value = null; |
| 69 | } | 82 | } |
| 70 | } | 83 | } |
| 71 | - | ||
| 72 | -const src = (rel: string) => `/api/projects/library/file/${rel.split("/").map(encodeURIComponent).join("/")}`; | ||
| 73 | </script> | 84 | </script> |
| 74 | 85 | ||
| 75 | <template> | 86 | <template> |
| @@ -92,7 +103,7 @@ const src = (rel: string) => `/api/projects/library/file/${rel.split("/").map(en | |||
| 92 | </div> | 103 | </div> |
| 93 | <div class="grid scroll"> | 104 | <div class="grid scroll"> |
| 94 | <button v-for="x in shown" :key="x.rel" class="cell" :class="{ on: picked.includes(x.rel) }" :title="`${x.type} · ${x.name}`" @click="toggle(x.rel)"> | 105 | <button v-for="x in shown" :key="x.rel" class="cell" :class="{ on: picked.includes(x.rel) }" :title="`${x.type} · ${x.name}`" @click="toggle(x.rel)"> |
| 95 | - <img :src="src(x.rel)" alt=""> | 106 | + <img :src="refUrl(pid, x.rel)" alt=""> |
| 96 | <span v-if="picked.includes(x.rel)" class="n">{{ picked.indexOf(x.rel) + 1 }}</span> | 107 | <span v-if="picked.includes(x.rel)" class="n">{{ picked.indexOf(x.rel) + 1 }}</span> |
| 97 | </button> | 108 | </button> |
| 98 | <span v-if="!shown.length" class="nothing"> | 109 | <span v-if="!shown.length" class="nothing"> |
added
web/src/project/steps/vignette/refs.ts +8 -0 | new file mode 100644 | ||
| @@ -0,0 +1,8 @@ | ||
| 1 | +/** Une image de référence : un chemin de la bibliothèque, ou « projet/<rel> » pour un média du projet (backend thumbs.py). */ | |
| 2 | +export const PROJECT_PREFIX = "projet/"; | |
| 3 | + | |
| 4 | +const enc = (rel: string) => rel.split("/").map(encodeURIComponent).join("/"); | |
| 5 | + | |
| 6 | +export const refUrl = (pid: string, rel: string) => rel.startsWith(PROJECT_PREFIX) | |
| 7 | + ? `/api/projects/${pid}/files/${enc(rel.slice(PROJECT_PREFIX.length))}` | |
| 8 | + : `/api/projects/library/file/${enc(rel)}`; | |
| new file mode 100644 | |||
| @@ -0,0 +1,8 @@ | |||
| 1 | +/** Une image de référence : un chemin de la bibliothèque, ou « projet/<rel> » pour un média du projet (backend thumbs.py). */ | ||
| 2 | +export const PROJECT_PREFIX = "projet/"; | ||
| 3 | + | ||
| 4 | +const enc = (rel: string) => rel.split("/").map(encodeURIComponent).join("/"); | ||
| 5 | + | ||
| 6 | +export const refUrl = (pid: string, rel: string) => rel.startsWith(PROJECT_PREFIX) | ||
| 7 | + ? `/api/projects/${pid}/files/${enc(rel.slice(PROJECT_PREFIX.length))}` | ||
| 8 | + : `/api/projects/library/file/${enc(rel)}`; | ||