feat(montage): give a B-roll beat a video or a YouTube clip
The beat media popup gets a "YouTube video" tab (the Media page's clip picker): the clip, or the whole video, is set on the B-roll as soon as its download is done. The import tab takes video files too, saved in the project's B-roll, and the media grid shows video thumbnails. A finished download now reports its timeline src, and a B-roll switched to another media starts from its beginning.
18b8800 parent: a675e4e modified
backend/studio/modules/projects/api.py +6 -4 | @@ -1301,13 +1301,15 @@ def add_images(pid: str, req: AddImagesRequest, beat: str = ""): | ||
| 1301 | 1301 | @router.post("/{pid}/assets/image") |
| 1302 | 1302 | def import_image(pid: str, file: UploadFile | None = File(None), url: str = Form("")): |
| 1303 | 1303 | """Une image ajoutée au projet d'un geste (fichier envoyé, ou téléchargée depuis son adresse), rangée dans ses |
| 1304 | - images ; rend son `src` (comme la timeline), pour l'attacher tout de suite à un beat.""" | |
| 1304 | + images ; ou un fichier vidéo, rangé dans ses B-roll. Rend son `src` (comme la timeline), pour l'attacher tout de | |
| 1305 | + suite à un beat.""" | |
| 1305 | 1306 | _check(pid) |
| 1306 | 1307 | folder = service.project_dir(pid) / service.ASSETS_DIR / "images" |
| 1307 | 1308 | if file is not None: |
| 1308 | - if storyboard.media_kind(file.filename or "") != "image": | |
| 1309 | - raise HTTPException(400, "pas une image (png, jpg, webp, gif, svg…)") | |
| 1310 | - dest = _save_upload(file, folder) | |
| 1309 | + kind = storyboard.media_kind(file.filename or "") | |
| 1310 | + if not kind: | |
| 1311 | + raise HTTPException(400, "ni une image (png, jpg, webp, gif, svg…) ni une vidéo") | |
| 1312 | + dest = _save_upload(file, folder if kind == "image" else folder.parent / "broll") | |
| 1311 | 1313 | elif url.strip(): |
| 1312 | 1314 | try: |
| 1313 | 1315 | got = asset_tasks.fetch_image(url.strip(), folder / f".import-{uuid.uuid4().hex[:10]}") |
| @@ -1301,13 +1301,15 @@ def add_images(pid: str, req: AddImagesRequest, beat: str = ""): | |||
| 1301 | @router.post("/{pid}/assets/image") | 1301 | @router.post("/{pid}/assets/image") |
| 1302 | def import_image(pid: str, file: UploadFile | None = File(None), url: str = Form("")): | 1302 | def import_image(pid: str, file: UploadFile | None = File(None), url: str = Form("")): |
| 1303 | """Une image ajoutée au projet d'un geste (fichier envoyé, ou téléchargée depuis son adresse), rangée dans ses | 1303 | """Une image ajoutée au projet d'un geste (fichier envoyé, ou téléchargée depuis son adresse), rangée dans ses |
| 1304 | - images ; rend son `src` (comme la timeline), pour l'attacher tout de suite à un beat.""" | 1304 | + images ; ou un fichier vidéo, rangé dans ses B-roll. Rend son `src` (comme la timeline), pour l'attacher tout de |
| 1305 | + suite à un beat.""" | ||
| 1305 | _check(pid) | 1306 | _check(pid) |
| 1306 | folder = service.project_dir(pid) / service.ASSETS_DIR / "images" | 1307 | folder = service.project_dir(pid) / service.ASSETS_DIR / "images" |
| 1307 | if file is not None: | 1308 | if file is not None: |
| 1308 | - if storyboard.media_kind(file.filename or "") != "image": | 1309 | + kind = storyboard.media_kind(file.filename or "") |
| 1309 | - raise HTTPException(400, "pas une image (png, jpg, webp, gif, svg…)") | 1310 | + if not kind: |
| 1310 | - dest = _save_upload(file, folder) | 1311 | + raise HTTPException(400, "ni une image (png, jpg, webp, gif, svg…) ni une vidéo") |
| 1312 | + dest = _save_upload(file, folder if kind == "image" else folder.parent / "broll") | ||
| 1311 | elif url.strip(): | 1313 | elif url.strip(): |
| 1312 | try: | 1314 | try: |
| 1313 | got = asset_tasks.fetch_image(url.strip(), folder / f".import-{uuid.uuid4().hex[:10]}") | 1315 | got = asset_tasks.fetch_image(url.strip(), folder / f".import-{uuid.uuid4().hex[:10]}") |
modified
backend/studio/modules/projects/assets.py +5 -1 | @@ -62,10 +62,14 @@ def _download_view(d: dict) -> dict | None: | ||
| 62 | 62 | if not job: |
| 63 | 63 | return None |
| 64 | 64 | path = job.state.get("filepath") |
| 65 | + name = Path(path).name if path else None | |
| 66 | + # le fichier comme la timeline le désigne (pour le poser aussitôt sur un plan) | |
| 67 | + category = d.get("category", "broll") | |
| 68 | + src = name and (f"library/{category}/{name}" if d.get("common") else f"{service.ASSETS_DIR}/{category}/{name}") | |
| 65 | 69 | return {"id": job.id, "video": d["video"], "dest": d["dest"], "status": job.status, "progress": job.progress, |
| 66 | 70 | "error": job.error, "finished": job.finished, "mode": job.payload.get("mode"), |
| 67 | 71 | "start": job.payload.get("start"), "end": job.payload.get("end"), |
| 68 | - "name": Path(path).name if path else None, "transcribe": job.payload.get("transcribe", False)} | |
| 72 | + "name": name, "src": src, "transcribe": job.payload.get("transcribe", False)} | |
| 69 | 73 | |
| 70 | 74 | |
| 71 | 75 | # --- Résultats des jobs ---------------------------------------------------------------- |
| @@ -62,10 +62,14 @@ def _download_view(d: dict) -> dict | None: | |||
| 62 | if not job: | 62 | if not job: |
| 63 | return None | 63 | return None |
| 64 | path = job.state.get("filepath") | 64 | path = job.state.get("filepath") |
| 65 | + name = Path(path).name if path else None | ||
| 66 | + # le fichier comme la timeline le désigne (pour le poser aussitôt sur un plan) | ||
| 67 | + category = d.get("category", "broll") | ||
| 68 | + src = name and (f"library/{category}/{name}" if d.get("common") else f"{service.ASSETS_DIR}/{category}/{name}") | ||
| 65 | return {"id": job.id, "video": d["video"], "dest": d["dest"], "status": job.status, "progress": job.progress, | 69 | return {"id": job.id, "video": d["video"], "dest": d["dest"], "status": job.status, "progress": job.progress, |
| 66 | "error": job.error, "finished": job.finished, "mode": job.payload.get("mode"), | 70 | "error": job.error, "finished": job.finished, "mode": job.payload.get("mode"), |
| 67 | "start": job.payload.get("start"), "end": job.payload.get("end"), | 71 | "start": job.payload.get("start"), "end": job.payload.get("end"), |
| 68 | - "name": Path(path).name if path else None, "transcribe": job.payload.get("transcribe", False)} | 72 | + "name": name, "src": src, "transcribe": job.payload.get("transcribe", False)} |
| 69 | 73 | ||
| 70 | 74 | ||
| 71 | # --- Résultats des jobs ---------------------------------------------------------------- | 75 | # --- Résultats des jobs ---------------------------------------------------------------- |
modified
web/src/i18n/locales/en/editing.ts +6 -1 | @@ -153,20 +153,25 @@ export default { | ||
| 153 | 153 | addBroll: "Choose the media", |
| 154 | 154 | change: "Change", |
| 155 | 155 | remove: "Remove", |
| 156 | - tabs: { media: "Media", import: "Import", search: "Search the web" }, | |
| 156 | + tabs: { media: "Media", import: "Import", video: "YouTube video", search: "Search the web" }, | |
| 157 | 157 | tabsDesc: { |
| 158 | 158 | media: "What is already in the project or the library", |
| 159 | 159 | import: "A file, a pasted screenshot, a URL", |
| 160 | + video: "A clip or the whole video, from a link", | |
| 160 | 161 | search: "Daryl searches from the beat’s brief", |
| 161 | 162 | }, |
| 162 | 163 | foot: "An imported image is saved in the project’s images.", |
| 164 | + footBroll: "An imported image is saved in the project’s images, a video in its B-roll.", | |
| 163 | 165 | noMedia: "No media yet: import an image or have one searched.", |
| 164 | 166 | drop: "Drop an image here or click to choose one — or paste a screenshot (Ctrl+V)", |
| 167 | + dropBroll: "Drop an image or a video here or click to choose one — or paste a screenshot (Ctrl+V)", | |
| 165 | 168 | url: "From a URL", |
| 166 | 169 | urlPh: "https://… (direct link to the image)", |
| 167 | 170 | add: "Add", |
| 168 | 171 | importing: "Importing…", |
| 169 | 172 | notImage: "This file is not an image.", |
| 173 | + notMedia: "This file is neither an image nor a video.", | |
| 174 | + downloaded: "Video downloaded: set on the B-roll", | |
| 170 | 175 | }, |
| 171 | 176 | }, |
| 172 | 177 | storyboard: { |
| @@ -153,20 +153,25 @@ export default { | |||
| 153 | addBroll: "Choose the media", | 153 | addBroll: "Choose the media", |
| 154 | change: "Change", | 154 | change: "Change", |
| 155 | remove: "Remove", | 155 | remove: "Remove", |
| 156 | - tabs: { media: "Media", import: "Import", search: "Search the web" }, | 156 | + tabs: { media: "Media", import: "Import", video: "YouTube video", search: "Search the web" }, |
| 157 | tabsDesc: { | 157 | tabsDesc: { |
| 158 | media: "What is already in the project or the library", | 158 | media: "What is already in the project or the library", |
| 159 | import: "A file, a pasted screenshot, a URL", | 159 | import: "A file, a pasted screenshot, a URL", |
| 160 | + video: "A clip or the whole video, from a link", | ||
| 160 | search: "Daryl searches from the beat’s brief", | 161 | search: "Daryl searches from the beat’s brief", |
| 161 | }, | 162 | }, |
| 162 | foot: "An imported image is saved in the project’s images.", | 163 | foot: "An imported image is saved in the project’s images.", |
| 164 | + footBroll: "An imported image is saved in the project’s images, a video in its B-roll.", | ||
| 163 | noMedia: "No media yet: import an image or have one searched.", | 165 | noMedia: "No media yet: import an image or have one searched.", |
| 164 | drop: "Drop an image here or click to choose one — or paste a screenshot (Ctrl+V)", | 166 | drop: "Drop an image here or click to choose one — or paste a screenshot (Ctrl+V)", |
| 167 | + dropBroll: "Drop an image or a video here or click to choose one — or paste a screenshot (Ctrl+V)", | ||
| 165 | url: "From a URL", | 168 | url: "From a URL", |
| 166 | urlPh: "https://… (direct link to the image)", | 169 | urlPh: "https://… (direct link to the image)", |
| 167 | add: "Add", | 170 | add: "Add", |
| 168 | importing: "Importing…", | 171 | importing: "Importing…", |
| 169 | notImage: "This file is not an image.", | 172 | notImage: "This file is not an image.", |
| 173 | + notMedia: "This file is neither an image nor a video.", | ||
| 174 | + downloaded: "Video downloaded: set on the B-roll", | ||
| 170 | }, | 175 | }, |
| 171 | }, | 176 | }, |
| 172 | storyboard: { | 177 | storyboard: { |
modified
web/src/i18n/locales/fr/editing.ts +6 -1 | @@ -153,20 +153,25 @@ export default { | ||
| 153 | 153 | addBroll: "Choisir le média", |
| 154 | 154 | change: "Changer", |
| 155 | 155 | remove: "Retirer", |
| 156 | - tabs: { media: "Médias", import: "Importer", search: "Chercher sur le web" }, | |
| 156 | + tabs: { media: "Médias", import: "Importer", video: "Vidéo YouTube", search: "Chercher sur le web" }, | |
| 157 | 157 | tabsDesc: { |
| 158 | 158 | media: "Ce qui est déjà dans le projet ou la bibliothèque", |
| 159 | 159 | import: "Un fichier, une capture collée, une adresse", |
| 160 | + video: "Un extrait ou la vidéo entière, depuis un lien", | |
| 160 | 161 | search: "Daryl cherche à partir de la consigne du beat", |
| 161 | 162 | }, |
| 162 | 163 | foot: "Une image importée est rangée dans les images du projet.", |
| 164 | + footBroll: "Une image importée est rangée dans les images du projet, une vidéo dans ses B-roll.", | |
| 163 | 165 | noMedia: "Aucun média pour l’instant : importe une image ou fais-la chercher.", |
| 164 | 166 | drop: "Glisse une image ici ou clique pour en choisir une — ou colle une capture (Ctrl+V)", |
| 167 | + dropBroll: "Glisse une image ou une vidéo ici ou clique pour en choisir une — ou colle une capture (Ctrl+V)", | |
| 165 | 168 | url: "Depuis une adresse", |
| 166 | 169 | urlPh: "https://… (lien direct vers l’image)", |
| 167 | 170 | add: "Ajouter", |
| 168 | 171 | importing: "Import…", |
| 169 | 172 | notImage: "Ce fichier n’est pas une image.", |
| 173 | + notMedia: "Ce fichier n’est ni une image ni une vidéo.", | |
| 174 | + downloaded: "Vidéo téléchargée : posée sur le B-roll", | |
| 170 | 175 | }, |
| 171 | 176 | }, |
| 172 | 177 | storyboard: { |
| @@ -153,20 +153,25 @@ export default { | |||
| 153 | addBroll: "Choisir le média", | 153 | addBroll: "Choisir le média", |
| 154 | change: "Changer", | 154 | change: "Changer", |
| 155 | remove: "Retirer", | 155 | remove: "Retirer", |
| 156 | - tabs: { media: "Médias", import: "Importer", search: "Chercher sur le web" }, | 156 | + tabs: { media: "Médias", import: "Importer", video: "Vidéo YouTube", search: "Chercher sur le web" }, |
| 157 | tabsDesc: { | 157 | tabsDesc: { |
| 158 | media: "Ce qui est déjà dans le projet ou la bibliothèque", | 158 | media: "Ce qui est déjà dans le projet ou la bibliothèque", |
| 159 | import: "Un fichier, une capture collée, une adresse", | 159 | import: "Un fichier, une capture collée, une adresse", |
| 160 | + video: "Un extrait ou la vidéo entière, depuis un lien", | ||
| 160 | search: "Daryl cherche à partir de la consigne du beat", | 161 | search: "Daryl cherche à partir de la consigne du beat", |
| 161 | }, | 162 | }, |
| 162 | foot: "Une image importée est rangée dans les images du projet.", | 163 | foot: "Une image importée est rangée dans les images du projet.", |
| 164 | + footBroll: "Une image importée est rangée dans les images du projet, une vidéo dans ses B-roll.", | ||
| 163 | noMedia: "Aucun média pour l’instant : importe une image ou fais-la chercher.", | 165 | noMedia: "Aucun média pour l’instant : importe une image ou fais-la chercher.", |
| 164 | drop: "Glisse une image ici ou clique pour en choisir une — ou colle une capture (Ctrl+V)", | 166 | drop: "Glisse une image ici ou clique pour en choisir une — ou colle une capture (Ctrl+V)", |
| 167 | + dropBroll: "Glisse une image ou une vidéo ici ou clique pour en choisir une — ou colle une capture (Ctrl+V)", | ||
| 165 | url: "Depuis une adresse", | 168 | url: "Depuis une adresse", |
| 166 | urlPh: "https://… (lien direct vers l’image)", | 169 | urlPh: "https://… (lien direct vers l’image)", |
| 167 | add: "Ajouter", | 170 | add: "Ajouter", |
| 168 | importing: "Import…", | 171 | importing: "Import…", |
| 169 | notImage: "Ce fichier n’est pas une image.", | 172 | notImage: "Ce fichier n’est pas une image.", |
| 173 | + notMedia: "Ce fichier n’est ni une image ni une vidéo.", | ||
| 174 | + downloaded: "Vidéo téléchargée : posée sur le B-roll", | ||
| 170 | }, | 175 | }, |
| 171 | }, | 176 | }, |
| 172 | storyboard: { | 177 | storyboard: { |
modified
web/src/project/steps/MontageStep.vue +2 -0 | @@ -219,6 +219,8 @@ async function pickMedia(id: string, src: string) { | ||
| 219 | 219 | SB.edit((list) => { |
| 220 | 220 | const b = list.find((x) => x.id === id); |
| 221 | 221 | if (!b) return; |
| 222 | + // un autre média : le plan le montre depuis son début | |
| 223 | + if (b.src !== src) b.start = 0; | |
| 222 | 224 | b.src = src; |
| 223 | 225 | if (isImage(src) && !["full", "left", "right"].includes(b.place)) b.place = "full"; |
| 224 | 226 | }, 150); |
| @@ -219,6 +219,8 @@ async function pickMedia(id: string, src: string) { | |||
| 219 | SB.edit((list) => { | 219 | SB.edit((list) => { |
| 220 | const b = list.find((x) => x.id === id); | 220 | const b = list.find((x) => x.id === id); |
| 221 | if (!b) return; | 221 | if (!b) return; |
| 222 | + // un autre média : le plan le montre depuis son début | ||
| 223 | + if (b.src !== src) b.start = 0; | ||
| 222 | b.src = src; | 224 | b.src = src; |
| 223 | if (isImage(src) && !["full", "left", "right"].includes(b.place)) b.place = "full"; | 225 | if (isImage(src) && !["full", "left", "right"].includes(b.place)) b.place = "full"; |
| 224 | }, 150); | 226 | }, 150); |
modified
web/src/project/steps/montage/BeatImageModal.vue +35 -13 | @@ -3,19 +3,20 @@ | ||
| 3 | 3 | * Choisir les images d'un beat sans passer par Médias : parmi les médias déjà là, en les important (fichier, |
| 4 | 4 | * glisser-déposer, capture collée, adresse web), ou en les faisant chercher par Daryl à partir de la consigne du beat. |
| 5 | 5 | * Une image importée est rangée dans les images du projet. Un motion en prend plusieurs (`multi` : la popup reste |
| 6 | - * ouverte, chaque choix s'ajoute) ; un B-roll un seul média (le choix ferme la popup). | |
| 6 | + * ouverte, chaque choix s'ajoute) ; un B-roll un seul média (le choix ferme la popup), qui peut aussi être une vidéo : | |
| 7 | + * un fichier importé, ou un extrait (ou la vidéo entière) d'un lien YouTube, posé dès son téléchargement fini. | |
| 7 | 8 | */ |
| 8 | -import { computed, onMounted, onUnmounted, ref } from "vue"; | |
| 9 | +import { computed, onMounted, onUnmounted, ref, watch } from "vue"; | |
| 9 | 10 | import { useI18n } from "vue-i18n"; |
| 10 | 11 | import Modal from "../../../components/Modal.vue"; |
| 11 | 12 | import { toast } from "../../../lib/toast"; |
| 12 | 13 | import { upload } from "../../../lib/upload"; |
| 13 | 14 | import ImagesTab from "../../media/ImagesTab.vue"; |
| 15 | +import YoutubeTab from "../../media/YoutubeTab.vue"; | |
| 14 | 16 | import { store } from "../../store"; |
| 15 | 17 | import { isImage, type Beat, type Visual } from "./types"; |
| 16 | 18 | |
| 17 | -export type ImageTab = "media" | "import" | "search"; | |
| 18 | -const TABS: ImageTab[] = ["media", "import", "search"]; | |
| 19 | +export type ImageTab = "media" | "import" | "video" | "search"; | |
| 19 | 20 | |
| 20 | 21 | /** `tab` : l'onglet à l'ouverture (la recherche, rouverte depuis la file) */ |
| 21 | 22 | const props = defineProps<{ beat: Beat; visuals: Visual[]; multi: boolean; selected: string[]; tab?: ImageTab }>(); |
| @@ -29,7 +30,9 @@ const emit = defineEmits<{ | ||
| 29 | 30 | const { t: tr } = useI18n(); |
| 30 | 31 | |
| 31 | 32 | /** un motion n'intègre qu'une image ; un B-roll montre aussi une vidéo */ |
| 32 | -const choices = computed(() => props.visuals.filter((v) => props.beat.type === "broll" || v.kind === "image")); | |
| 33 | +const broll = computed(() => props.beat.type === "broll"); | |
| 34 | +const tabs = computed<ImageTab[]>(() => broll.value ? ["media", "import", "video", "search"] : ["media", "import", "search"]); | |
| 35 | +const choices = computed(() => props.visuals.filter((v) => broll.value || v.kind === "image")); | |
| 33 | 36 | const tab = ref<ImageTab>(props.tab ?? (choices.value.length ? "media" : "import")); |
| 34 | 37 | const shortName = (name: string) => name.replace(/\.[^.]+$/, ""); |
| 35 | 38 | |
| @@ -38,9 +41,13 @@ const over = ref(false); | ||
| 38 | 41 | const link = ref(""); |
| 39 | 42 | const fileInput = ref<HTMLInputElement>(); |
| 40 | 43 | |
| 44 | +/** les vidéos que le studio lit (backend service.VIDEO_EXTENSIONS) */ | |
| 45 | +const VIDEO = /\.(mov|mp4|mxf|mkv|avi|webm|m4v|mts)$/i; | |
| 41 | 46 | async function importImage(file: File | null, url = "") { |
| 42 | 47 | if (busy.value) return; |
| 43 | - if (file && !isImage(file.name)) return toast(tr("editing.beat.image.notImage"), true); | |
| 48 | + if (file && !isImage(file.name) && !(broll.value && VIDEO.test(file.name))) { | |
| 49 | + return toast(tr(broll.value ? "editing.beat.image.notMedia" : "editing.beat.image.notImage"), true); | |
| 50 | + } | |
| 44 | 51 | busy.value = true; |
| 45 | 52 | try { |
| 46 | 53 | const { src } = await upload<{ src: string }>(`/api/projects/${store.project!.id}/assets/image`, |
| @@ -92,13 +99,24 @@ function click(src: string) { | ||
| 92 | 99 | if (props.multi) emit("toggle", src); |
| 93 | 100 | else emit("pick", src); |
| 94 | 101 | } |
| 102 | + | |
| 103 | +/** une vidéo téléchargée depuis la popup est posée sur le B-roll dès qu'elle est là (pas celles d'avant) */ | |
| 104 | +const downloads = computed(() => store.project?.runtime.assets.downloads ?? []); | |
| 105 | +const before = new Set(downloads.value.map((d) => d.id)); | |
| 106 | +watch(downloads, (list) => { | |
| 107 | + const got = list.find((d) => !before.has(d.id) && d.status === "done" && d.mode === "video" && d.src); | |
| 108 | + if (!got) return; | |
| 109 | + before.add(got.id); | |
| 110 | + toast(tr("editing.beat.image.downloaded")); | |
| 111 | + choose([got.src!]); | |
| 112 | +}); | |
| 95 | 113 | </script> |
| 96 | 114 | |
| 97 | 115 | <template> |
| 98 | 116 | <Modal :title="tr(`editing.beat.image.tabs.${tab}`)" @close="emit('close')"> |
| 99 | 117 | <template #nav> |
| 100 | 118 | <span class="nav-title">{{ beat.type === "broll" ? tr("editing.beat.image.titleBroll") : tr("editing.beat.image.title") }}</span> |
| 101 | - <button v-for="k in TABS" :key="k" class="src" :class="{ on: k === tab }" @click="tab = k"> | |
| 119 | + <button v-for="k in tabs" :key="k" class="src" :class="{ on: k === tab }" @click="tab = k"> | |
| 102 | 120 | <span class="label">{{ tr(`editing.beat.image.tabs.${k}`) }}</span> |
| 103 | 121 | <span class="desc">{{ tr(`editing.beat.image.tabsDesc.${k}`) }}</span> |
| 104 | 122 | </button> |
| @@ -107,7 +125,7 @@ function click(src: string) { | ||
| 107 | 125 | <span class="mono count">{{ tr("editing.beat.image.chosen", selected.length) }}</span> |
| 108 | 126 | <button class="btn dark md done" @click="emit('close')">{{ tr("editing.beat.image.done") }}</button> |
| 109 | 127 | </template> |
| 110 | - <span class="foot">{{ tr("editing.beat.image.foot") }}</span> | |
| 128 | + <span class="foot">{{ tr(broll ? "editing.beat.image.footBroll" : "editing.beat.image.foot") }}</span> | |
| 111 | 129 | </template> |
| 112 | 130 | |
| 113 | 131 | <template v-if="tab === 'media'"> |
| @@ -121,7 +139,10 @@ function click(src: string) { | ||
| 121 | 139 | <span class="frame hatch"> |
| 122 | 140 | <span v-if="multi" class="check" :class="{ on: selected.includes(v.src) }">✓</span> |
| 123 | 141 | <img v-if="v.kind === 'image'" :src="v.url" :alt="v.name" loading="lazy"> |
| 124 | - <span v-else class="mono video">▶</span> | |
| 142 | + <template v-else> | |
| 143 | + <video :src="`${v.url}#t=1`" preload="metadata" muted playsinline /> | |
| 144 | + <span class="mono video">▶</span> | |
| 145 | + </template> | |
| 125 | 146 | </span> |
| 126 | 147 | <span class="cap">{{ shortName(v.name) }}</span> |
| 127 | 148 | </button> |
| @@ -133,9 +154,9 @@ function click(src: string) { | ||
| 133 | 154 | <div v-else-if="tab === 'import'" class="import"> |
| 134 | 155 | <div class="drop-zone" :class="{ over }" @click="fileInput?.click()" |
| 135 | 156 | @dragover.prevent="over = true" @dragleave="over = false" @drop.prevent="onDrop"> |
| 136 | - {{ busy ? tr("editing.beat.image.importing") : tr("editing.beat.image.drop") }} | |
| 157 | + {{ busy ? tr("editing.beat.image.importing") : tr(broll ? "editing.beat.image.dropBroll" : "editing.beat.image.drop") }} | |
| 137 | 158 | </div> |
| 138 | - <input ref="fileInput" type="file" accept="image/*" hidden @change="onPick"> | |
| 159 | + <input ref="fileInput" type="file" :accept="broll ? 'image/*,video/*' : 'image/*'" hidden @change="onPick"> | |
| 139 | 160 | <label class="f"> |
| 140 | 161 | {{ tr("editing.beat.image.url") }} |
| 141 | 162 | <span class="row"> |
| @@ -145,6 +166,7 @@ function click(src: string) { | ||
| 145 | 166 | </label> |
| 146 | 167 | </div> |
| 147 | 168 | |
| 169 | + <YoutubeTab v-else-if="tab === 'video'" /> | |
| 148 | 170 | <ImagesTab v-else :beat="beat.id" :prefill="beat.show" @added="choose" /> |
| 149 | 171 | </Modal> |
| 150 | 172 | </template> |
| @@ -165,7 +187,7 @@ function click(src: string) { | ||
| 165 | 187 | .frame { position: relative; aspect-ratio: 4/3; display: flex; align-items: center; justify-content: center; overflow: hidden; outline: 2px solid transparent; outline-offset: -2px; } |
| 166 | 188 | .card:hover .frame { outline-color: var(--line-strong); } |
| 167 | 189 | .card.on .frame { outline-color: var(--ink); } |
| 168 | -.frame img { max-width: 100%; max-height: 100%; object-fit: contain; } | |
| 190 | +.frame img, .frame video { max-width: 100%; max-height: 100%; object-fit: contain; } | |
| 169 | 191 | .check { |
| 170 | 192 | position: absolute; top: 6px; left: 6px; width: 18px; height: 18px; border: 1px solid var(--line-strong); border-radius: 3px; |
| 171 | 193 | background: #fff; color: transparent; font-size: 11px; line-height: 16px; text-align: center; |
| @@ -173,7 +195,7 @@ function click(src: string) { | ||
| 173 | 195 | .check.on { background: var(--ink); border-color: var(--ink); color: #fff; } |
| 174 | 196 | .count { font-size: 12px; color: var(--muted); padding: 0 10px 8px; } |
| 175 | 197 | .done { margin: 0 10px 14px; } |
| 176 | -.video { font-size: 22px; color: var(--muted); } | |
| 198 | +.video { position: absolute; font-size: 22px; color: #fff; text-shadow: 0 1px 4px rgba(0, 0, 0, .6); } | |
| 177 | 199 | .cap { font-size: 12.5px; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 178 | 200 | .import { display: flex; flex-direction: column; gap: 22px; max-width: 640px; } |
| 179 | 201 | .drop-zone { padding: 56px 18px; } |
| @@ -3,19 +3,20 @@ | |||
| 3 | * Choisir les images d'un beat sans passer par Médias : parmi les médias déjà là, en les important (fichier, | 3 | * Choisir les images d'un beat sans passer par Médias : parmi les médias déjà là, en les important (fichier, |
| 4 | * glisser-déposer, capture collée, adresse web), ou en les faisant chercher par Daryl à partir de la consigne du beat. | 4 | * glisser-déposer, capture collée, adresse web), ou en les faisant chercher par Daryl à partir de la consigne du beat. |
| 5 | * Une image importée est rangée dans les images du projet. Un motion en prend plusieurs (`multi` : la popup reste | 5 | * Une image importée est rangée dans les images du projet. Un motion en prend plusieurs (`multi` : la popup reste |
| 6 | - * ouverte, chaque choix s'ajoute) ; un B-roll un seul média (le choix ferme la popup). | 6 | + * ouverte, chaque choix s'ajoute) ; un B-roll un seul média (le choix ferme la popup), qui peut aussi être une vidéo : |
| 7 | + * un fichier importé, ou un extrait (ou la vidéo entière) d'un lien YouTube, posé dès son téléchargement fini. | ||
| 7 | */ | 8 | */ |
| 8 | -import { computed, onMounted, onUnmounted, ref } from "vue"; | 9 | +import { computed, onMounted, onUnmounted, ref, watch } from "vue"; |
| 9 | import { useI18n } from "vue-i18n"; | 10 | import { useI18n } from "vue-i18n"; |
| 10 | import Modal from "../../../components/Modal.vue"; | 11 | import Modal from "../../../components/Modal.vue"; |
| 11 | import { toast } from "../../../lib/toast"; | 12 | import { toast } from "../../../lib/toast"; |
| 12 | import { upload } from "../../../lib/upload"; | 13 | import { upload } from "../../../lib/upload"; |
| 13 | import ImagesTab from "../../media/ImagesTab.vue"; | 14 | import ImagesTab from "../../media/ImagesTab.vue"; |
| 15 | +import YoutubeTab from "../../media/YoutubeTab.vue"; | ||
| 14 | import { store } from "../../store"; | 16 | import { store } from "../../store"; |
| 15 | import { isImage, type Beat, type Visual } from "./types"; | 17 | import { isImage, type Beat, type Visual } from "./types"; |
| 16 | 18 | ||
| 17 | -export type ImageTab = "media" | "import" | "search"; | 19 | +export type ImageTab = "media" | "import" | "video" | "search"; |
| 18 | -const TABS: ImageTab[] = ["media", "import", "search"]; | ||
| 19 | 20 | ||
| 20 | /** `tab` : l'onglet à l'ouverture (la recherche, rouverte depuis la file) */ | 21 | /** `tab` : l'onglet à l'ouverture (la recherche, rouverte depuis la file) */ |
| 21 | const props = defineProps<{ beat: Beat; visuals: Visual[]; multi: boolean; selected: string[]; tab?: ImageTab }>(); | 22 | const props = defineProps<{ beat: Beat; visuals: Visual[]; multi: boolean; selected: string[]; tab?: ImageTab }>(); |
| @@ -29,7 +30,9 @@ const emit = defineEmits<{ | |||
| 29 | const { t: tr } = useI18n(); | 30 | const { t: tr } = useI18n(); |
| 30 | 31 | ||
| 31 | /** un motion n'intègre qu'une image ; un B-roll montre aussi une vidéo */ | 32 | /** un motion n'intègre qu'une image ; un B-roll montre aussi une vidéo */ |
| 32 | -const choices = computed(() => props.visuals.filter((v) => props.beat.type === "broll" || v.kind === "image")); | 33 | +const broll = computed(() => props.beat.type === "broll"); |
| 34 | +const tabs = computed<ImageTab[]>(() => broll.value ? ["media", "import", "video", "search"] : ["media", "import", "search"]); | ||
| 35 | +const choices = computed(() => props.visuals.filter((v) => broll.value || v.kind === "image")); | ||
| 33 | const tab = ref<ImageTab>(props.tab ?? (choices.value.length ? "media" : "import")); | 36 | const tab = ref<ImageTab>(props.tab ?? (choices.value.length ? "media" : "import")); |
| 34 | const shortName = (name: string) => name.replace(/\.[^.]+$/, ""); | 37 | const shortName = (name: string) => name.replace(/\.[^.]+$/, ""); |
| 35 | 38 | ||
| @@ -38,9 +41,13 @@ const over = ref(false); | |||
| 38 | const link = ref(""); | 41 | const link = ref(""); |
| 39 | const fileInput = ref<HTMLInputElement>(); | 42 | const fileInput = ref<HTMLInputElement>(); |
| 40 | 43 | ||
| 44 | +/** les vidéos que le studio lit (backend service.VIDEO_EXTENSIONS) */ | ||
| 45 | +const VIDEO = /\.(mov|mp4|mxf|mkv|avi|webm|m4v|mts)$/i; | ||
| 41 | async function importImage(file: File | null, url = "") { | 46 | async function importImage(file: File | null, url = "") { |
| 42 | if (busy.value) return; | 47 | if (busy.value) return; |
| 43 | - if (file && !isImage(file.name)) return toast(tr("editing.beat.image.notImage"), true); | 48 | + if (file && !isImage(file.name) && !(broll.value && VIDEO.test(file.name))) { |
| 49 | + return toast(tr(broll.value ? "editing.beat.image.notMedia" : "editing.beat.image.notImage"), true); | ||
| 50 | + } | ||
| 44 | busy.value = true; | 51 | busy.value = true; |
| 45 | try { | 52 | try { |
| 46 | const { src } = await upload<{ src: string }>(`/api/projects/${store.project!.id}/assets/image`, | 53 | const { src } = await upload<{ src: string }>(`/api/projects/${store.project!.id}/assets/image`, |
| @@ -92,13 +99,24 @@ function click(src: string) { | |||
| 92 | if (props.multi) emit("toggle", src); | 99 | if (props.multi) emit("toggle", src); |
| 93 | else emit("pick", src); | 100 | else emit("pick", src); |
| 94 | } | 101 | } |
| 102 | + | ||
| 103 | +/** une vidéo téléchargée depuis la popup est posée sur le B-roll dès qu'elle est là (pas celles d'avant) */ | ||
| 104 | +const downloads = computed(() => store.project?.runtime.assets.downloads ?? []); | ||
| 105 | +const before = new Set(downloads.value.map((d) => d.id)); | ||
| 106 | +watch(downloads, (list) => { | ||
| 107 | + const got = list.find((d) => !before.has(d.id) && d.status === "done" && d.mode === "video" && d.src); | ||
| 108 | + if (!got) return; | ||
| 109 | + before.add(got.id); | ||
| 110 | + toast(tr("editing.beat.image.downloaded")); | ||
| 111 | + choose([got.src!]); | ||
| 112 | +}); | ||
| 95 | </script> | 113 | </script> |
| 96 | 114 | ||
| 97 | <template> | 115 | <template> |
| 98 | <Modal :title="tr(`editing.beat.image.tabs.${tab}`)" @close="emit('close')"> | 116 | <Modal :title="tr(`editing.beat.image.tabs.${tab}`)" @close="emit('close')"> |
| 99 | <template #nav> | 117 | <template #nav> |
| 100 | <span class="nav-title">{{ beat.type === "broll" ? tr("editing.beat.image.titleBroll") : tr("editing.beat.image.title") }}</span> | 118 | <span class="nav-title">{{ beat.type === "broll" ? tr("editing.beat.image.titleBroll") : tr("editing.beat.image.title") }}</span> |
| 101 | - <button v-for="k in TABS" :key="k" class="src" :class="{ on: k === tab }" @click="tab = k"> | 119 | + <button v-for="k in tabs" :key="k" class="src" :class="{ on: k === tab }" @click="tab = k"> |
| 102 | <span class="label">{{ tr(`editing.beat.image.tabs.${k}`) }}</span> | 120 | <span class="label">{{ tr(`editing.beat.image.tabs.${k}`) }}</span> |
| 103 | <span class="desc">{{ tr(`editing.beat.image.tabsDesc.${k}`) }}</span> | 121 | <span class="desc">{{ tr(`editing.beat.image.tabsDesc.${k}`) }}</span> |
| 104 | </button> | 122 | </button> |
| @@ -107,7 +125,7 @@ function click(src: string) { | |||
| 107 | <span class="mono count">{{ tr("editing.beat.image.chosen", selected.length) }}</span> | 125 | <span class="mono count">{{ tr("editing.beat.image.chosen", selected.length) }}</span> |
| 108 | <button class="btn dark md done" @click="emit('close')">{{ tr("editing.beat.image.done") }}</button> | 126 | <button class="btn dark md done" @click="emit('close')">{{ tr("editing.beat.image.done") }}</button> |
| 109 | </template> | 127 | </template> |
| 110 | - <span class="foot">{{ tr("editing.beat.image.foot") }}</span> | 128 | + <span class="foot">{{ tr(broll ? "editing.beat.image.footBroll" : "editing.beat.image.foot") }}</span> |
| 111 | </template> | 129 | </template> |
| 112 | 130 | ||
| 113 | <template v-if="tab === 'media'"> | 131 | <template v-if="tab === 'media'"> |
| @@ -121,7 +139,10 @@ function click(src: string) { | |||
| 121 | <span class="frame hatch"> | 139 | <span class="frame hatch"> |
| 122 | <span v-if="multi" class="check" :class="{ on: selected.includes(v.src) }">✓</span> | 140 | <span v-if="multi" class="check" :class="{ on: selected.includes(v.src) }">✓</span> |
| 123 | <img v-if="v.kind === 'image'" :src="v.url" :alt="v.name" loading="lazy"> | 141 | <img v-if="v.kind === 'image'" :src="v.url" :alt="v.name" loading="lazy"> |
| 124 | - <span v-else class="mono video">▶</span> | 142 | + <template v-else> |
| 143 | + <video :src="`${v.url}#t=1`" preload="metadata" muted playsinline /> | ||
| 144 | + <span class="mono video">▶</span> | ||
| 145 | + </template> | ||
| 125 | </span> | 146 | </span> |
| 126 | <span class="cap">{{ shortName(v.name) }}</span> | 147 | <span class="cap">{{ shortName(v.name) }}</span> |
| 127 | </button> | 148 | </button> |
| @@ -133,9 +154,9 @@ function click(src: string) { | |||
| 133 | <div v-else-if="tab === 'import'" class="import"> | 154 | <div v-else-if="tab === 'import'" class="import"> |
| 134 | <div class="drop-zone" :class="{ over }" @click="fileInput?.click()" | 155 | <div class="drop-zone" :class="{ over }" @click="fileInput?.click()" |
| 135 | @dragover.prevent="over = true" @dragleave="over = false" @drop.prevent="onDrop"> | 156 | @dragover.prevent="over = true" @dragleave="over = false" @drop.prevent="onDrop"> |
| 136 | - {{ busy ? tr("editing.beat.image.importing") : tr("editing.beat.image.drop") }} | 157 | + {{ busy ? tr("editing.beat.image.importing") : tr(broll ? "editing.beat.image.dropBroll" : "editing.beat.image.drop") }} |
| 137 | </div> | 158 | </div> |
| 138 | - <input ref="fileInput" type="file" accept="image/*" hidden @change="onPick"> | 159 | + <input ref="fileInput" type="file" :accept="broll ? 'image/*,video/*' : 'image/*'" hidden @change="onPick"> |
| 139 | <label class="f"> | 160 | <label class="f"> |
| 140 | {{ tr("editing.beat.image.url") }} | 161 | {{ tr("editing.beat.image.url") }} |
| 141 | <span class="row"> | 162 | <span class="row"> |
| @@ -145,6 +166,7 @@ function click(src: string) { | |||
| 145 | </label> | 166 | </label> |
| 146 | </div> | 167 | </div> |
| 147 | 168 | ||
| 169 | + <YoutubeTab v-else-if="tab === 'video'" /> | ||
| 148 | <ImagesTab v-else :beat="beat.id" :prefill="beat.show" @added="choose" /> | 170 | <ImagesTab v-else :beat="beat.id" :prefill="beat.show" @added="choose" /> |
| 149 | </Modal> | 171 | </Modal> |
| 150 | </template> | 172 | </template> |
| @@ -165,7 +187,7 @@ function click(src: string) { | |||
| 165 | .frame { position: relative; aspect-ratio: 4/3; display: flex; align-items: center; justify-content: center; overflow: hidden; outline: 2px solid transparent; outline-offset: -2px; } | 187 | .frame { position: relative; aspect-ratio: 4/3; display: flex; align-items: center; justify-content: center; overflow: hidden; outline: 2px solid transparent; outline-offset: -2px; } |
| 166 | .card:hover .frame { outline-color: var(--line-strong); } | 188 | .card:hover .frame { outline-color: var(--line-strong); } |
| 167 | .card.on .frame { outline-color: var(--ink); } | 189 | .card.on .frame { outline-color: var(--ink); } |
| 168 | -.frame img { max-width: 100%; max-height: 100%; object-fit: contain; } | 190 | +.frame img, .frame video { max-width: 100%; max-height: 100%; object-fit: contain; } |
| 169 | .check { | 191 | .check { |
| 170 | position: absolute; top: 6px; left: 6px; width: 18px; height: 18px; border: 1px solid var(--line-strong); border-radius: 3px; | 192 | position: absolute; top: 6px; left: 6px; width: 18px; height: 18px; border: 1px solid var(--line-strong); border-radius: 3px; |
| 171 | background: #fff; color: transparent; font-size: 11px; line-height: 16px; text-align: center; | 193 | background: #fff; color: transparent; font-size: 11px; line-height: 16px; text-align: center; |
| @@ -173,7 +195,7 @@ function click(src: string) { | |||
| 173 | .check.on { background: var(--ink); border-color: var(--ink); color: #fff; } | 195 | .check.on { background: var(--ink); border-color: var(--ink); color: #fff; } |
| 174 | .count { font-size: 12px; color: var(--muted); padding: 0 10px 8px; } | 196 | .count { font-size: 12px; color: var(--muted); padding: 0 10px 8px; } |
| 175 | .done { margin: 0 10px 14px; } | 197 | .done { margin: 0 10px 14px; } |
| 176 | -.video { font-size: 22px; color: var(--muted); } | 198 | +.video { position: absolute; font-size: 22px; color: #fff; text-shadow: 0 1px 4px rgba(0, 0, 0, .6); } |
| 177 | .cap { font-size: 12.5px; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | 199 | .cap { font-size: 12.5px; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 178 | .import { display: flex; flex-direction: column; gap: 22px; max-width: 640px; } | 200 | .import { display: flex; flex-direction: column; gap: 22px; max-width: 640px; } |
| 179 | .drop-zone { padding: 56px 18px; } | 201 | .drop-zone { padding: 56px 18px; } |
modified
web/src/project/types.ts +2 -0 | @@ -106,6 +106,8 @@ export interface AssetDownload { | ||
| 106 | 106 | id: string; video: string; dest: string; status: string; progress: number; error: string | null; |
| 107 | 107 | finished: boolean; mode: "video" | "audio" | "thumbnail"; start: number | null; end: number | null; |
| 108 | 108 | name: string | null; transcribe: boolean; |
| 109 | + /** le fichier téléchargé comme la timeline le désigne (assets/broll/…, library/broll/…) */ | |
| 110 | + src: string | null; | |
| 109 | 111 | } |
| 110 | 112 | |
| 111 | 113 | export interface Assets { |
| @@ -106,6 +106,8 @@ export interface AssetDownload { | |||
| 106 | id: string; video: string; dest: string; status: string; progress: number; error: string | null; | 106 | id: string; video: string; dest: string; status: string; progress: number; error: string | null; |
| 107 | finished: boolean; mode: "video" | "audio" | "thumbnail"; start: number | null; end: number | null; | 107 | finished: boolean; mode: "video" | "audio" | "thumbnail"; start: number | null; end: number | null; |
| 108 | name: string | null; transcribe: boolean; | 108 | name: string | null; transcribe: boolean; |
| 109 | + /** le fichier téléchargé comme la timeline le désigne (assets/broll/…, library/broll/…) */ | ||
| 110 | + src: string | null; | ||
| 109 | } | 111 | } |
| 110 | 112 | ||
| 111 | export interface Assets { | 113 | export interface Assets { |