hlassiege/darylpublic Fork
⑂ c57e0c2
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): import a thumbnail made outside the studio

A variant's gallery accepts an uploaded png, jpg or gif (button or drag and drop); the image becomes the variant's chosen thumbnail and can be touched up like a generated one.
hlassiege committed 2026-10-24T18:00:19+02:00 Browse files
c57e0c2 parent: 326702f
modified backend/studio/modules/projects/api.py +21 -0
@@ -788,6 +788,27 @@ def propose_thumbs(pid: str, tid: str, req: ThumbIdeasRequest):
788788 return _view(pid)
789789
790790
791+@router.post("/{pid}/thumbs/{tid}/upload")
792+def upload_thumb_image(pid: str, tid: str, file: UploadFile = File(...)):
793+ """Une vignette faite hors du studio, ajoutée à la galerie de la variante (et retenue pour elle)."""
794+ _check(pid)
795+ ext = Path(file.filename or "").suffix.lower()
796+ # envoyée telle quelle à YouTube si elle est assez légère : seulement les formats qu'il accepte
797+ if ext not in (".png", ".jpg", ".jpeg", ".gif"):
798+ raise HTTPException(400, "il faut une image png, jpg ou gif")
799+ if not any(t.get("id") == tid for t in service.load(pid)["thumbs"]):
800+ raise HTTPException(404, "variante inconnue")
801+ dest = _save_upload(file, service.project_dir(pid) / service.VIGNETTES_DIR)
802+ with service.editing(pid) as project:
803+ runtime.sync(project)
804+ try:
805+ thumbs.add_upload(project, tid, dest.name)
806+ except ValueError as e:
807+ dest.unlink(missing_ok=True)
808+ raise HTTPException(404, str(e)) from e
809+ return _view(pid)
810+
811+
791812 @router.delete("/{pid}/thumbs/{tid}/images/{name}")
792813 def remove_thumb_image(pid: str, tid: str, name: str):
793814 _check(pid)
@@ -788,6 +788,27 @@ def propose_thumbs(pid: str, tid: str, req: ThumbIdeasRequest):
788 return _view(pid)788 return _view(pid)
789 789
790 790
791+@router.post("/{pid}/thumbs/{tid}/upload")
792+def upload_thumb_image(pid: str, tid: str, file: UploadFile = File(...)):
793+ """Une vignette faite hors du studio, ajoutée à la galerie de la variante (et retenue pour elle)."""
794+ _check(pid)
795+ ext = Path(file.filename or "").suffix.lower()
796+ # envoyée telle quelle à YouTube si elle est assez légère : seulement les formats qu'il accepte
797+ if ext not in (".png", ".jpg", ".jpeg", ".gif"):
798+ raise HTTPException(400, "il faut une image png, jpg ou gif")
799+ if not any(t.get("id") == tid for t in service.load(pid)["thumbs"]):
800+ raise HTTPException(404, "variante inconnue")
801+ dest = _save_upload(file, service.project_dir(pid) / service.VIGNETTES_DIR)
802+ with service.editing(pid) as project:
803+ runtime.sync(project)
804+ try:
805+ thumbs.add_upload(project, tid, dest.name)
806+ except ValueError as e:
807+ dest.unlink(missing_ok=True)
808+ raise HTTPException(404, str(e)) from e
809+ return _view(pid)
810+
811+
791 @router.delete("/{pid}/thumbs/{tid}/images/{name}")812 @router.delete("/{pid}/thumbs/{tid}/images/{name}")
792 def remove_thumb_image(pid: str, tid: str, name: str):813 def remove_thumb_image(pid: str, tid: str, name: str):
793 _check(pid)814 _check(pid)
modified backend/studio/modules/projects/thumbs.py +12 -0
@@ -207,6 +207,18 @@ def sync(project: dict) -> bool:
207207 return changed
208208
209209
210+# le « modèle » d'une image faite hors du studio et importée telle quelle
211+UPLOAD_MODEL = "upload"
212+
213+
214+def add_upload(project: dict, tid: str, file: str) -> None:
215+ """Une image importée (déjà copiée dans vignettes/) rejoint la galerie de la variante et devient
216+ son image retenue : on l'importe pour s'en servir."""
217+ thumb = _thumb(project, tid)
218+ project.setdefault("thumb_images", {}).setdefault(tid, []).insert(0, {"file": file, "model": UPLOAD_MODEL})
219+ thumb["image"] = file
220+
221+
210222 def remove_image(project: dict, tid: str, file: str) -> None:
211223 """Retire une image de la galerie de la variante (le fichier reste dans vignettes/)."""
212224 images = project.setdefault("thumb_images", {})
@@ -207,6 +207,18 @@ def sync(project: dict) -> bool:
207 return changed207 return changed
208 208
209 209
210+# le « modèle » d'une image faite hors du studio et importée telle quelle
211+UPLOAD_MODEL = "upload"
212+
213+
214+def add_upload(project: dict, tid: str, file: str) -> None:
215+ """Une image importée (déjà copiée dans vignettes/) rejoint la galerie de la variante et devient
216+ son image retenue : on l'importe pour s'en servir."""
217+ thumb = _thumb(project, tid)
218+ project.setdefault("thumb_images", {}).setdefault(tid, []).insert(0, {"file": file, "model": UPLOAD_MODEL})
219+ thumb["image"] = file
220+
221+
210 def remove_image(project: dict, tid: str, file: str) -> None:222 def remove_image(project: dict, tid: str, file: str) -> None:
211 """Retire une image de la galerie de la variante (le fichier reste dans vignettes/)."""223 """Retire une image de la galerie de la variante (le fichier reste dans vignettes/)."""
212 images = project.setdefault("thumb_images", {})224 images = project.setdefault("thumb_images", {})
modified web/src/i18n/locales/en/thumbnail.ts +4 -0
@@ -26,6 +26,10 @@ export default {
2626 pickModel: "Pick at least one model.",
2727 results: "Results",
2828 resultsEmpty: "Images generated for {v} will show up here",
29+ import: "↑ Import an image",
30+ importing: "Importing…",
31+ importTitle: "Add a thumbnail made elsewhere (png, jpg, gif): it becomes the chosen image. You can also drop it here.",
32+ uploaded: "Imported",
2933 zoom: "View full size, touch up",
3034 retouchOf: "Touch-up: {edit}",
3135 retouchSuffix: " · touch-up",
@@ -26,6 +26,10 @@ export default {
26 pickModel: "Pick at least one model.",26 pickModel: "Pick at least one model.",
27 results: "Results",27 results: "Results",
28 resultsEmpty: "Images generated for {v} will show up here",28 resultsEmpty: "Images generated for {v} will show up here",
29+ import: "↑ Import an image",
30+ importing: "Importing…",
31+ importTitle: "Add a thumbnail made elsewhere (png, jpg, gif): it becomes the chosen image. You can also drop it here.",
32+ uploaded: "Imported",
29 zoom: "View full size, touch up",33 zoom: "View full size, touch up",
30 retouchOf: "Touch-up: {edit}",34 retouchOf: "Touch-up: {edit}",
31 retouchSuffix: " · touch-up",35 retouchSuffix: " · touch-up",
modified web/src/i18n/locales/fr/thumbnail.ts +4 -0
@@ -26,6 +26,10 @@ export default {
2626 pickModel: "Choisis au moins un modèle.",
2727 results: "Résultats",
2828 resultsEmpty: "Les images générées pour {v} arriveront ici",
29+ import: "↑ Importer une image",
30+ importing: "Import…",
31+ importTitle: "Ajouter une vignette faite ailleurs (png, jpg, gif) : elle devient l’image choisie. Tu peux aussi la glisser ici.",
32+ uploaded: "Importée",
2933 zoom: "Voir en grand, retoucher",
3034 retouchOf: "Retouche : {edit}",
3135 retouchSuffix: " · retouche",
@@ -26,6 +26,10 @@ export default {
26 pickModel: "Choisis au moins un modèle.",26 pickModel: "Choisis au moins un modèle.",
27 results: "Résultats",27 results: "Résultats",
28 resultsEmpty: "Les images générées pour {v} arriveront ici",28 resultsEmpty: "Les images générées pour {v} arriveront ici",
29+ import: "↑ Importer une image",
30+ importing: "Import…",
31+ importTitle: "Ajouter une vignette faite ailleurs (png, jpg, gif) : elle devient l’image choisie. Tu peux aussi la glisser ici.",
32+ uploaded: "Importée",
29 zoom: "Voir en grand, retoucher",33 zoom: "Voir en grand, retoucher",
30 retouchOf: "Retouche : {edit}",34 retouchOf: "Retouche : {edit}",
31 retouchSuffix: " · retouche",35 retouchSuffix: " · retouche",
modified web/src/project/steps/VignetteStep.vue +46 -5
@@ -12,8 +12,9 @@ import ConfirmX from "../../components/ConfirmX.vue";
1212 import Seg from "../../components/Seg.vue";
1313 import { post } from "../../lib/api";
1414 import { toast } from "../../lib/toast";
15-import { act, failed, running, store, update } from "../store";
16-import type { Thumb, ThumbIdea, ThumbImage } from "../types";
15+import { droppedFiles, upload } from "../../lib/upload";
16+import { act, failed, receive, running, store, update } from "../store";
17+import type { Project, Thumb, ThumbIdea, ThumbImage } from "../types";
1718 import LibraryPicker from "./vignette/LibraryPicker.vue";
1819 import VariantTile from "./vignette/VariantTile.vue";
1920 import { refUrl } from "./vignette/refs";
@@ -134,7 +135,8 @@ function ideaAsVariant(idea: ThumbIdea) {
134135
135136 // --- génération -----------------------------------------------------------------------
136137 const models = computed(() => project.value.runtime.thumbs.models);
137-const modelLabel = (id: string) => models.value.find((m) => m.id === id)?.label ?? id;
138+// « upload » : une image faite hors du studio (backend thumbs.UPLOAD_MODEL)
139+const modelLabel = (id: string) => id === "upload" ? tr("thumbnail.uploaded") : models.value.find((m) => m.id === id)?.label ?? id;
138140 const chosen = ref<string[]>(JSON.parse(localStorage.getItem(MODELS_KEY) ?? '["nano-banana-pro", "seedream-5-pro"]'));
139141 watch(chosen, (v) => localStorage.setItem(MODELS_KEY, JSON.stringify(v)));
140142
@@ -236,6 +238,35 @@ function onKey(e: KeyboardEvent) {
236238 onMounted(() => window.addEventListener("keydown", onKey));
237239 onUnmounted(() => window.removeEventListener("keydown", onKey));
238240
241+// --- une vignette faite ailleurs : importée dans la galerie, et retenue pour la variante ---
242+const fileInput = ref<HTMLInputElement | null>(null);
243+const uploading = ref(false);
244+const dragging = ref(false);
245+
246+async function importImage(file: File | undefined) {
247+ const t = current.value;
248+ if (!t?.id || !file || uploading.value) return;
249+ uploading.value = true;
250+ try {
251+ receive(await upload<Project>(`/api/projects/${project.value.id}/thumbs/${t.id}/upload`, [file], "file"));
252+ } catch (e) {
253+ toast((e as Error).message, true);
254+ } finally {
255+ uploading.value = false;
256+ }
257+}
258+
259+function onFile(e: Event) {
260+ const input = e.target as HTMLInputElement;
261+ importImage(input.files?.[0]);
262+ input.value = "";
263+}
264+
265+function onDrop(e: DragEvent) {
266+ dragging.value = false;
267+ importImage(droppedFiles(e)[0]);
268+}
269+
239270 async function toLibrary(file: string) {
240271 try {
241272 await post(`/api/projects/${project.value.id}/resources/to-library`, { rel: `vignettes/${file}`, category: "vignettes" });
@@ -367,8 +398,16 @@ const big = computed(() => feed.value === "desktop");
367398 <span v-else-if="!chosen.length" class="hint">{{ tr("thumbnail.pickModel") }}</span>
368399 </div>
369400
370- <div class="col results">
371- <span class="col-title">{{ tr("thumbnail.results") }}<span v-if="images(current).length" class="faint"> · {{ images(current).length }}</span></span>
401+ <div
402+ class="col results" :class="{ dragging }"
403+ @dragover.prevent="dragging = true" @dragleave.self="dragging = false" @drop.prevent="onDrop">
404+ <div class="results-head">
405+ <span class="col-title">{{ tr("thumbnail.results") }}<span v-if="images(current).length" class="faint"> · {{ images(current).length }}</span></span>
406+ <button class="link-btn small" :disabled="uploading" :title="tr('thumbnail.importTitle')" @click="fileInput?.click()">
407+ {{ uploading ? tr("thumbnail.importing") : tr("thumbnail.import") }}
408+ </button>
409+ <input ref="fileInput" type="file" accept=".png,.jpg,.jpeg,.gif" hidden @change="onFile">
410+ </div>
372411 <div v-if="!images(current).length && !statuses.length" class="empty hatch">
373412 <span class="faint">{{ tr("thumbnail.resultsEmpty", { v: current.v }) }}</span>
374413 </div>
@@ -560,6 +599,8 @@ const big = computed(() => feed.value === "desktop");
560599 .brief { padding-right: 28px; border-right: 1px solid var(--line-soft); }
561600 @media (max-width: 1100px) { .brief { padding-right: 0; border-right: 0; } }
562601 .col-title { font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
602+.results-head { display: flex; justify-content: space-between; align-items: baseline; }
603+.results.dragging { outline: 2px dashed var(--ink); outline-offset: 6px; border-radius: 4px; }
563604 .ideas-link { align-self: flex-start; font-size: 12.5px; margin-top: 4px; }
564605 .brief .run { padding-top: 14px; margin-top: 4px; border-top: 1px solid var(--line-soft); }
565606 .empty { aspect-ratio: 16 / 9; max-width: 480px; display: grid; place-items: center; border-radius: 4px; font-size: 13px; }
@@ -12,8 +12,9 @@ import ConfirmX from "../../components/ConfirmX.vue";
12 import Seg from "../../components/Seg.vue";12 import Seg from "../../components/Seg.vue";
13 import { post } from "../../lib/api";13 import { post } from "../../lib/api";
14 import { toast } from "../../lib/toast";14 import { toast } from "../../lib/toast";
15-import { act, failed, running, store, update } from "../store";15+import { droppedFiles, upload } from "../../lib/upload";
16-import type { Thumb, ThumbIdea, ThumbImage } from "../types";16+import { act, failed, receive, running, store, update } from "../store";
17+import type { Project, Thumb, ThumbIdea, ThumbImage } from "../types";
17 import LibraryPicker from "./vignette/LibraryPicker.vue";18 import LibraryPicker from "./vignette/LibraryPicker.vue";
18 import VariantTile from "./vignette/VariantTile.vue";19 import VariantTile from "./vignette/VariantTile.vue";
19 import { refUrl } from "./vignette/refs";20 import { refUrl } from "./vignette/refs";
@@ -134,7 +135,8 @@ function ideaAsVariant(idea: ThumbIdea) {
134 135
135 // --- génération -----------------------------------------------------------------------136 // --- génération -----------------------------------------------------------------------
136 const models = computed(() => project.value.runtime.thumbs.models);137 const models = computed(() => project.value.runtime.thumbs.models);
137-const modelLabel = (id: string) => models.value.find((m) => m.id === id)?.label ?? id;138+// « upload » : une image faite hors du studio (backend thumbs.UPLOAD_MODEL)
139+const modelLabel = (id: string) => id === "upload" ? tr("thumbnail.uploaded") : models.value.find((m) => m.id === id)?.label ?? id;
138 const chosen = ref<string[]>(JSON.parse(localStorage.getItem(MODELS_KEY) ?? '["nano-banana-pro", "seedream-5-pro"]'));140 const chosen = ref<string[]>(JSON.parse(localStorage.getItem(MODELS_KEY) ?? '["nano-banana-pro", "seedream-5-pro"]'));
139 watch(chosen, (v) => localStorage.setItem(MODELS_KEY, JSON.stringify(v)));141 watch(chosen, (v) => localStorage.setItem(MODELS_KEY, JSON.stringify(v)));
140 142
@@ -236,6 +238,35 @@ function onKey(e: KeyboardEvent) {
236 onMounted(() => window.addEventListener("keydown", onKey));238 onMounted(() => window.addEventListener("keydown", onKey));
237 onUnmounted(() => window.removeEventListener("keydown", onKey));239 onUnmounted(() => window.removeEventListener("keydown", onKey));
238 240
241+// --- une vignette faite ailleurs : importée dans la galerie, et retenue pour la variante ---
242+const fileInput = ref<HTMLInputElement | null>(null);
243+const uploading = ref(false);
244+const dragging = ref(false);
245+
246+async function importImage(file: File | undefined) {
247+ const t = current.value;
248+ if (!t?.id || !file || uploading.value) return;
249+ uploading.value = true;
250+ try {
251+ receive(await upload<Project>(`/api/projects/${project.value.id}/thumbs/${t.id}/upload`, [file], "file"));
252+ } catch (e) {
253+ toast((e as Error).message, true);
254+ } finally {
255+ uploading.value = false;
256+ }
257+}
258+
259+function onFile(e: Event) {
260+ const input = e.target as HTMLInputElement;
261+ importImage(input.files?.[0]);
262+ input.value = "";
263+}
264+
265+function onDrop(e: DragEvent) {
266+ dragging.value = false;
267+ importImage(droppedFiles(e)[0]);
268+}
269+
239 async function toLibrary(file: string) {270 async function toLibrary(file: string) {
240 try {271 try {
241 await post(`/api/projects/${project.value.id}/resources/to-library`, { rel: `vignettes/${file}`, category: "vignettes" });272 await post(`/api/projects/${project.value.id}/resources/to-library`, { rel: `vignettes/${file}`, category: "vignettes" });
@@ -367,8 +398,16 @@ const big = computed(() => feed.value === "desktop");
367 <span v-else-if="!chosen.length" class="hint">{{ tr("thumbnail.pickModel") }}</span>398 <span v-else-if="!chosen.length" class="hint">{{ tr("thumbnail.pickModel") }}</span>
368 </div>399 </div>
369 400
370- <div class="col results">401+ <div
371- <span class="col-title">{{ tr("thumbnail.results") }}<span v-if="images(current).length" class="faint"> · {{ images(current).length }}</span></span>402+ class="col results" :class="{ dragging }"
403+ @dragover.prevent="dragging = true" @dragleave.self="dragging = false" @drop.prevent="onDrop">
404+ <div class="results-head">
405+ <span class="col-title">{{ tr("thumbnail.results") }}<span v-if="images(current).length" class="faint"> · {{ images(current).length }}</span></span>
406+ <button class="link-btn small" :disabled="uploading" :title="tr('thumbnail.importTitle')" @click="fileInput?.click()">
407+ {{ uploading ? tr("thumbnail.importing") : tr("thumbnail.import") }}
408+ </button>
409+ <input ref="fileInput" type="file" accept=".png,.jpg,.jpeg,.gif" hidden @change="onFile">
410+ </div>
372 <div v-if="!images(current).length && !statuses.length" class="empty hatch">411 <div v-if="!images(current).length && !statuses.length" class="empty hatch">
373 <span class="faint">{{ tr("thumbnail.resultsEmpty", { v: current.v }) }}</span>412 <span class="faint">{{ tr("thumbnail.resultsEmpty", { v: current.v }) }}</span>
374 </div>413 </div>
@@ -560,6 +599,8 @@ const big = computed(() => feed.value === "desktop");
560 .brief { padding-right: 28px; border-right: 1px solid var(--line-soft); }599 .brief { padding-right: 28px; border-right: 1px solid var(--line-soft); }
561 @media (max-width: 1100px) { .brief { padding-right: 0; border-right: 0; } }600 @media (max-width: 1100px) { .brief { padding-right: 0; border-right: 0; } }
562 .col-title { font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }601 .col-title { font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
602+.results-head { display: flex; justify-content: space-between; align-items: baseline; }
603+.results.dragging { outline: 2px dashed var(--ink); outline-offset: 6px; border-radius: 4px; }
563 .ideas-link { align-self: flex-start; font-size: 12.5px; margin-top: 4px; }604 .ideas-link { align-self: flex-start; font-size: 12.5px; margin-top: 4px; }
564 .brief .run { padding-top: 14px; margin-top: 4px; border-top: 1px solid var(--line-soft); }605 .brief .run { padding-top: 14px; margin-top: 4px; border-top: 1px solid var(--line-soft); }
565 .empty { aspect-ratio: 16 / 9; max-width: 480px; display: grid; place-items: center; border-radius: 4px; font-size: 13px; }606 .empty { aspect-ratio: 16 / 9; max-width: 480px; display: grid; place-items: center; border-radius: 4px; font-size: 13px; }