hlassiege/darylpublic Fork
⑂ 18b8800
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(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.
hlassiege committed 2026-10-22T16:23:39+02:00 Browse files
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 = ""):
13011301 @router.post("/{pid}/assets/image")
13021302 def import_image(pid: str, file: UploadFile | None = File(None), url: str = Form("")):
13031303 """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."""
13051306 _check(pid)
13061307 folder = service.project_dir(pid) / service.ASSETS_DIR / "images"
13071308 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")
13111313 elif url.strip():
13121314 try:
13131315 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 ses1303 """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:
6262 if not job:
6363 return None
6464 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}")
6569 return {"id": job.id, "video": d["video"], "dest": d["dest"], "status": job.status, "progress": job.progress,
6670 "error": job.error, "finished": job.finished, "mode": job.payload.get("mode"),
6771 "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)}
6973
7074
7175 # --- 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 None63 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 {
153153 addBroll: "Choose the media",
154154 change: "Change",
155155 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" },
157157 tabsDesc: {
158158 media: "What is already in the project or the library",
159159 import: "A file, a pasted screenshot, a URL",
160+ video: "A clip or the whole video, from a link",
160161 search: "Daryl searches from the beat’s brief",
161162 },
162163 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.",
163165 noMedia: "No media yet: import an image or have one searched.",
164166 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)",
165168 url: "From a URL",
166169 urlPh: "https://… (direct link to the image)",
167170 add: "Add",
168171 importing: "Importing…",
169172 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",
170175 },
171176 },
172177 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 {
153153 addBroll: "Choisir le média",
154154 change: "Changer",
155155 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" },
157157 tabsDesc: {
158158 media: "Ce qui est déjà dans le projet ou la bibliothèque",
159159 import: "Un fichier, une capture collée, une adresse",
160+ video: "Un extrait ou la vidéo entière, depuis un lien",
160161 search: "Daryl cherche à partir de la consigne du beat",
161162 },
162163 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.",
163165 noMedia: "Aucun média pour l’instant : importe une image ou fais-la chercher.",
164166 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)",
165168 url: "Depuis une adresse",
166169 urlPh: "https://… (lien direct vers l’image)",
167170 add: "Ajouter",
168171 importing: "Import…",
169172 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",
170175 },
171176 },
172177 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) {
219219 SB.edit((list) => {
220220 const b = list.find((x) => x.id === id);
221221 if (!b) return;
222+ // un autre média : le plan le montre depuis son début
223+ if (b.src !== src) b.start = 0;
222224 b.src = src;
223225 if (isImage(src) && !["full", "left", "right"].includes(b.place)) b.place = "full";
224226 }, 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 @@
33 * Choisir les images d'un beat sans passer par Médias : parmi les médias déjà là, en les important (fichier,
44 * glisser-déposer, capture collée, adresse web), ou en les faisant chercher par Daryl à partir de la consigne du beat.
55 * 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.
78 */
8-import { computed, onMounted, onUnmounted, ref } from "vue";
9+import { computed, onMounted, onUnmounted, ref, watch } from "vue";
910 import { useI18n } from "vue-i18n";
1011 import Modal from "../../../components/Modal.vue";
1112 import { toast } from "../../../lib/toast";
1213 import { upload } from "../../../lib/upload";
1314 import ImagesTab from "../../media/ImagesTab.vue";
15+import YoutubeTab from "../../media/YoutubeTab.vue";
1416 import { store } from "../../store";
1517 import { isImage, type Beat, type Visual } from "./types";
1618
17-export type ImageTab = "media" | "import" | "search";
18-const TABS: ImageTab[] = ["media", "import", "search"];
19+export type ImageTab = "media" | "import" | "video" | "search";
1920
2021 /** `tab` : l'onglet à l'ouverture (la recherche, rouverte depuis la file) */
2122 const props = defineProps<{ beat: Beat; visuals: Visual[]; multi: boolean; selected: string[]; tab?: ImageTab }>();
@@ -29,7 +30,9 @@ const emit = defineEmits<{
2930 const { t: tr } = useI18n();
3031
3132 /** 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"));
3336 const tab = ref<ImageTab>(props.tab ?? (choices.value.length ? "media" : "import"));
3437 const shortName = (name: string) => name.replace(/\.[^.]+$/, "");
3538
@@ -38,9 +41,13 @@ const over = ref(false);
3841 const link = ref("");
3942 const fileInput = ref<HTMLInputElement>();
4043
44+/** les vidéos que le studio lit (backend service.VIDEO_EXTENSIONS) */
45+const VIDEO = /\.(mov|mp4|mxf|mkv|avi|webm|m4v|mts)$/i;
4146 async function importImage(file: File | null, url = "") {
4247 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+ }
4451 busy.value = true;
4552 try {
4653 const { src } = await upload<{ src: string }>(`/api/projects/${store.project!.id}/assets/image`,
@@ -92,13 +99,24 @@ function click(src: string) {
9299 if (props.multi) emit("toggle", src);
93100 else emit("pick", src);
94101 }
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+});
95113 </script>
96114
97115 <template>
98116 <Modal :title="tr(`editing.beat.image.tabs.${tab}`)" @close="emit('close')">
99117 <template #nav>
100118 <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">
102120 <span class="label">{{ tr(`editing.beat.image.tabs.${k}`) }}</span>
103121 <span class="desc">{{ tr(`editing.beat.image.tabsDesc.${k}`) }}</span>
104122 </button>
@@ -107,7 +125,7 @@ function click(src: string) {
107125 <span class="mono count">{{ tr("editing.beat.image.chosen", selected.length) }}</span>
108126 <button class="btn dark md done" @click="emit('close')">{{ tr("editing.beat.image.done") }}</button>
109127 </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>
111129 </template>
112130
113131 <template v-if="tab === 'media'">
@@ -121,7 +139,10 @@ function click(src: string) {
121139 <span class="frame hatch">
122140 <span v-if="multi" class="check" :class="{ on: selected.includes(v.src) }">✓</span>
123141 <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>
125146 </span>
126147 <span class="cap">{{ shortName(v.name) }}</span>
127148 </button>
@@ -133,9 +154,9 @@ function click(src: string) {
133154 <div v-else-if="tab === 'import'" class="import">
134155 <div class="drop-zone" :class="{ over }" @click="fileInput?.click()"
135156 @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") }}
137158 </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">
139160 <label class="f">
140161 {{ tr("editing.beat.image.url") }}
141162 <span class="row">
@@ -145,6 +166,7 @@ function click(src: string) {
145166 </label>
146167 </div>
147168
169+ <YoutubeTab v-else-if="tab === 'video'" />
148170 <ImagesTab v-else :beat="beat.id" :prefill="beat.show" @added="choose" />
149171 </Modal>
150172 </template>
@@ -165,7 +187,7 @@ function click(src: string) {
165187 .frame { position: relative; aspect-ratio: 4/3; display: flex; align-items: center; justify-content: center; overflow: hidden; outline: 2px solid transparent; outline-offset: -2px; }
166188 .card:hover .frame { outline-color: var(--line-strong); }
167189 .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; }
169191 .check {
170192 position: absolute; top: 6px; left: 6px; width: 18px; height: 18px; border: 1px solid var(--line-strong); border-radius: 3px;
171193 background: #fff; color: transparent; font-size: 11px; line-height: 16px; text-align: center;
@@ -173,7 +195,7 @@ function click(src: string) {
173195 .check.on { background: var(--ink); border-color: var(--ink); color: #fff; }
174196 .count { font-size: 12px; color: var(--muted); padding: 0 10px 8px; }
175197 .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); }
177199 .cap { font-size: 12.5px; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
178200 .import { display: flex; flex-direction: column; gap: 22px; max-width: 640px; }
179201 .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 reste5 * 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 {
106106 id: string; video: string; dest: string; status: string; progress: number; error: string | null;
107107 finished: boolean; mode: "video" | "audio" | "thumbnail"; start: number | null; end: number | null;
108108 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;
109111 }
110112
111113 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 {