feat(montage): pick media from a searchable popup, including rushes and added files
The media list under the inspector is replaced by a popup opened from "+ Rush / + B-roll / + Sound / + Music": tabs, search over names and library descriptions, a preview, place at playhead or insert into the A-roll, and drag onto the timeline. Available media now include the project's rushes (new "rush" category) and the files added to the project (ressources/), which were missing.
ef2bf51 parent: 2932ab7 modified
backend/studio/modules/projects/montage.py +15 -1 | @@ -1076,9 +1076,15 @@ MEDIA_CATEGORIES = ("broll", "musique", "sfx") | ||
| 1076 | 1076 | |
| 1077 | 1077 | |
| 1078 | 1078 | def available(pid: str) -> list[dict]: |
| 1079 | - """B-roll, musique et sons : ceux du projet, puis ceux de la bibliothèque commune.""" | |
| 1079 | + """Rushes, B-roll, musique et sons : ceux du projet, puis ceux de la bibliothèque commune.""" | |
| 1080 | 1080 | base = service.project_dir(pid) |
| 1081 | 1081 | items = [] |
| 1082 | + # les rushes copiés dans le projet : à insérer dans l'A-roll (ou à poser en B-roll) | |
| 1083 | + for path in library._files(base / service.RUSHES_DIR): | |
| 1084 | + if path.suffix.lower() in library.VIDEO_EXT: | |
| 1085 | + items.append({"src": path.relative_to(base).as_posix(), "name": path.name, "category": "rush", | |
| 1086 | + "where": "rushes", "kind": "Vidéo", "duration": library.duration(path), | |
| 1087 | + "url": f"/api/projects/{pid}/files/{path.relative_to(base).as_posix()}"}) | |
| 1082 | 1088 | for category in MEDIA_CATEGORIES: |
| 1083 | 1089 | for path in library._files(base / service.ASSETS_DIR / category): |
| 1084 | 1090 | if path.suffix.lower() in library.TIMED: |
| @@ -1091,6 +1097,14 @@ def available(pid: str) -> list[dict]: | ||
| 1091 | 1097 | items.append({"src": path.relative_to(base).as_posix(), "name": path.name, "category": "broll", |
| 1092 | 1098 | "where": "projet", "kind": "Vidéo", "duration": library.duration(path), |
| 1093 | 1099 | "url": f"/api/projects/{pid}/files/{path.relative_to(base).as_posix()}"}) |
| 1100 | + # les fichiers ajoutés au projet (Ressources) : une vidéo y sert de B-roll, un son de sound design | |
| 1101 | + for path in library._files(base / service.RESOURCES_DIR): | |
| 1102 | + ext = path.suffix.lower() | |
| 1103 | + if ext in library.TIMED: | |
| 1104 | + items.append({"src": path.relative_to(base).as_posix(), "name": path.name, | |
| 1105 | + "category": "broll" if ext in library.VIDEO_EXT else "sfx", | |
| 1106 | + "where": "ajoutés", "kind": library.kind_of(path), "duration": library.duration(path), | |
| 1107 | + "url": f"/api/projects/{pid}/files/{path.relative_to(base).as_posix()}"}) | |
| 1094 | 1108 | described = library.descriptions() |
| 1095 | 1109 | for category in MEDIA_CATEGORIES: |
| 1096 | 1110 | for path in library._files(library.LIBRARY_DIR / category): |
| @@ -1076,9 +1076,15 @@ MEDIA_CATEGORIES = ("broll", "musique", "sfx") | |||
| 1076 | 1076 | ||
| 1077 | 1077 | ||
| 1078 | def available(pid: str) -> list[dict]: | 1078 | def available(pid: str) -> list[dict]: |
| 1079 | - """B-roll, musique et sons : ceux du projet, puis ceux de la bibliothèque commune.""" | 1079 | + """Rushes, B-roll, musique et sons : ceux du projet, puis ceux de la bibliothèque commune.""" |
| 1080 | base = service.project_dir(pid) | 1080 | base = service.project_dir(pid) |
| 1081 | items = [] | 1081 | items = [] |
| 1082 | + # les rushes copiés dans le projet : à insérer dans l'A-roll (ou à poser en B-roll) | ||
| 1083 | + for path in library._files(base / service.RUSHES_DIR): | ||
| 1084 | + if path.suffix.lower() in library.VIDEO_EXT: | ||
| 1085 | + items.append({"src": path.relative_to(base).as_posix(), "name": path.name, "category": "rush", | ||
| 1086 | + "where": "rushes", "kind": "Vidéo", "duration": library.duration(path), | ||
| 1087 | + "url": f"/api/projects/{pid}/files/{path.relative_to(base).as_posix()}"}) | ||
| 1082 | for category in MEDIA_CATEGORIES: | 1088 | for category in MEDIA_CATEGORIES: |
| 1083 | for path in library._files(base / service.ASSETS_DIR / category): | 1089 | for path in library._files(base / service.ASSETS_DIR / category): |
| 1084 | if path.suffix.lower() in library.TIMED: | 1090 | if path.suffix.lower() in library.TIMED: |
| @@ -1091,6 +1097,14 @@ def available(pid: str) -> list[dict]: | |||
| 1091 | items.append({"src": path.relative_to(base).as_posix(), "name": path.name, "category": "broll", | 1097 | items.append({"src": path.relative_to(base).as_posix(), "name": path.name, "category": "broll", |
| 1092 | "where": "projet", "kind": "Vidéo", "duration": library.duration(path), | 1098 | "where": "projet", "kind": "Vidéo", "duration": library.duration(path), |
| 1093 | "url": f"/api/projects/{pid}/files/{path.relative_to(base).as_posix()}"}) | 1099 | "url": f"/api/projects/{pid}/files/{path.relative_to(base).as_posix()}"}) |
| 1100 | + # les fichiers ajoutés au projet (Ressources) : une vidéo y sert de B-roll, un son de sound design | ||
| 1101 | + for path in library._files(base / service.RESOURCES_DIR): | ||
| 1102 | + ext = path.suffix.lower() | ||
| 1103 | + if ext in library.TIMED: | ||
| 1104 | + items.append({"src": path.relative_to(base).as_posix(), "name": path.name, | ||
| 1105 | + "category": "broll" if ext in library.VIDEO_EXT else "sfx", | ||
| 1106 | + "where": "ajoutés", "kind": library.kind_of(path), "duration": library.duration(path), | ||
| 1107 | + "url": f"/api/projects/{pid}/files/{path.relative_to(base).as_posix()}"}) | ||
| 1094 | described = library.descriptions() | 1108 | described = library.descriptions() |
| 1095 | for category in MEDIA_CATEGORIES: | 1109 | for category in MEDIA_CATEGORIES: |
| 1096 | for path in library._files(library.LIBRARY_DIR / category): | 1110 | for path in library._files(library.LIBRARY_DIR / category): |
modified
web/src/i18n/locales/en/editing.ts +15 -4 | @@ -56,7 +56,6 @@ export default { | ||
| 56 | 56 | insertedHint: "Clip inserted from another file, with its own sound; drag its edges to shorten it. No transition with it: a whoosh does the job.", |
| 57 | 57 | insertAroll: "→ A-roll", |
| 58 | 58 | insertArollTitle: "Insert into the A-roll at {tc} (everything after moves along)", |
| 59 | - dragHint: "Drag onto the timeline: on the A-roll track to insert it, elsewhere to place it on top", | |
| 60 | 59 | scale: "Scale", |
| 61 | 60 | horizontal: "Horizontal", |
| 62 | 61 | vertical: "Vertical", |
| @@ -101,12 +100,24 @@ export default { | ||
| 101 | 100 | addBeat: "+ Beat", |
| 102 | 101 | addBeatTitle: "A beat on this passage (M): write its brief on the slide, drag its edges on the timeline, then create the animation.", |
| 103 | 102 | addChapter: "+ Chapter", |
| 103 | + addRush: "+ Rush", | |
| 104 | + addBroll: "+ B-roll", | |
| 105 | + addSfx: "+ Sound", | |
| 106 | + addMusic: "+ Music", | |
| 107 | + motionHint: "Motion design: select a section (drag on the timeline) and ask Daryl for it.", | |
| 108 | + }, | |
| 109 | + picker: { | |
| 110 | + title: "Media · at playhead {tc}", | |
| 111 | + search: "Search rushes, the project and the library…", | |
| 112 | + tabAll: "All", | |
| 113 | + tabRush: "Rushes", | |
| 104 | 114 | tabBroll: "B-roll", |
| 105 | 115 | tabSfx: "Sounds", |
| 106 | 116 | tabMusic: "Music", |
| 107 | - filter: "Filter", | |
| 108 | - emptyMedia: "Nothing here. B-roll comes from the Sourcing step; sounds and music from the library.", | |
| 109 | - motionHint: "Motion design: select a section (drag on the timeline) and ask Daryl for it.", | |
| 117 | + add: "Place at playhead", | |
| 118 | + empty: "Nothing here. Rushes come from the Rushes step, B-roll from Sourcing; sounds and music from the library.", | |
| 119 | + more: "… and {n} more: refine the search.", | |
| 120 | + hint: "↑ ↓ to choose, Enter or double-click to place at the playhead. Drag a file onto the timeline: on the A-roll track to insert it, elsewhere to place it on top.", | |
| 110 | 121 | }, |
| 111 | 122 | anims: { |
| 112 | 123 | title: "Animations", |
| @@ -56,7 +56,6 @@ export default { | |||
| 56 | insertedHint: "Clip inserted from another file, with its own sound; drag its edges to shorten it. No transition with it: a whoosh does the job.", | 56 | insertedHint: "Clip inserted from another file, with its own sound; drag its edges to shorten it. No transition with it: a whoosh does the job.", |
| 57 | insertAroll: "→ A-roll", | 57 | insertAroll: "→ A-roll", |
| 58 | insertArollTitle: "Insert into the A-roll at {tc} (everything after moves along)", | 58 | insertArollTitle: "Insert into the A-roll at {tc} (everything after moves along)", |
| 59 | - dragHint: "Drag onto the timeline: on the A-roll track to insert it, elsewhere to place it on top", | ||
| 60 | scale: "Scale", | 59 | scale: "Scale", |
| 61 | horizontal: "Horizontal", | 60 | horizontal: "Horizontal", |
| 62 | vertical: "Vertical", | 61 | vertical: "Vertical", |
| @@ -101,12 +100,24 @@ export default { | |||
| 101 | addBeat: "+ Beat", | 100 | addBeat: "+ Beat", |
| 102 | addBeatTitle: "A beat on this passage (M): write its brief on the slide, drag its edges on the timeline, then create the animation.", | 101 | addBeatTitle: "A beat on this passage (M): write its brief on the slide, drag its edges on the timeline, then create the animation.", |
| 103 | addChapter: "+ Chapter", | 102 | addChapter: "+ Chapter", |
| 103 | + addRush: "+ Rush", | ||
| 104 | + addBroll: "+ B-roll", | ||
| 105 | + addSfx: "+ Sound", | ||
| 106 | + addMusic: "+ Music", | ||
| 107 | + motionHint: "Motion design: select a section (drag on the timeline) and ask Daryl for it.", | ||
| 108 | + }, | ||
| 109 | + picker: { | ||
| 110 | + title: "Media · at playhead {tc}", | ||
| 111 | + search: "Search rushes, the project and the library…", | ||
| 112 | + tabAll: "All", | ||
| 113 | + tabRush: "Rushes", | ||
| 104 | tabBroll: "B-roll", | 114 | tabBroll: "B-roll", |
| 105 | tabSfx: "Sounds", | 115 | tabSfx: "Sounds", |
| 106 | tabMusic: "Music", | 116 | tabMusic: "Music", |
| 107 | - filter: "Filter", | 117 | + add: "Place at playhead", |
| 108 | - emptyMedia: "Nothing here. B-roll comes from the Sourcing step; sounds and music from the library.", | 118 | + empty: "Nothing here. Rushes come from the Rushes step, B-roll from Sourcing; sounds and music from the library.", |
| 109 | - motionHint: "Motion design: select a section (drag on the timeline) and ask Daryl for it.", | 119 | + more: "… and {n} more: refine the search.", |
| 120 | + hint: "↑ ↓ to choose, Enter or double-click to place at the playhead. Drag a file onto the timeline: on the A-roll track to insert it, elsewhere to place it on top.", | ||
| 110 | }, | 121 | }, |
| 111 | anims: { | 122 | anims: { |
| 112 | title: "Animations", | 123 | title: "Animations", |
modified
web/src/i18n/locales/fr/editing.ts +15 -4 | @@ -56,7 +56,6 @@ export default { | ||
| 56 | 56 | insertedHint: "Clip inséré d’un autre média, avec son propre son ; tire ses bords pour le raccourcir. Pas de transition avec lui : un whoosh fait l’affaire.", |
| 57 | 57 | insertAroll: "→ A-roll", |
| 58 | 58 | insertArollTitle: "Insérer dans l’A-roll à {tc} (la suite est décalée d’autant)", |
| 59 | - dragHint: "Glisser sur la timeline : sur la piste A-roll pour l’insérer, ailleurs pour le poser au-dessus", | |
| 60 | 59 | scale: "Échelle", |
| 61 | 60 | horizontal: "Horizontal", |
| 62 | 61 | vertical: "Vertical", |
| @@ -101,12 +100,24 @@ export default { | ||
| 101 | 100 | addBeat: "+ Beat", |
| 102 | 101 | addBeatTitle: "Un beat sur ce passage (M) : écris sa consigne sur la slide, tire ses bords sur la timeline, puis crée l’animation.", |
| 103 | 102 | addChapter: "+ Chapitre", |
| 103 | + addRush: "+ Rush", | |
| 104 | + addBroll: "+ B-roll", | |
| 105 | + addSfx: "+ Son", | |
| 106 | + addMusic: "+ Musique", | |
| 107 | + motionHint: "Motion design : sélectionne un passage (glisse sur la timeline) et demande-le à Daryl.", | |
| 108 | + }, | |
| 109 | + picker: { | |
| 110 | + title: "Médias · au curseur {tc}", | |
| 111 | + search: "Chercher dans les rushes, le projet et la bibliothèque…", | |
| 112 | + tabAll: "Tout", | |
| 113 | + tabRush: "Rushes", | |
| 104 | 114 | tabBroll: "B-roll", |
| 105 | 115 | tabSfx: "Sons", |
| 106 | 116 | tabMusic: "Musique", |
| 107 | - filter: "Filtrer", | |
| 108 | - emptyMedia: "Rien ici. Les B-roll viennent de l’étape Sourcing ; sons et musiques de la bibliothèque.", | |
| 109 | - motionHint: "Motion design : sélectionne un passage (glisse sur la timeline) et demande-le à Daryl.", | |
| 117 | + add: "Poser au curseur", | |
| 118 | + empty: "Rien ici. Les rushes viennent de l’étape Rushes, les B-roll du Sourcing ; sons et musiques de la bibliothèque.", | |
| 119 | + more: "… et {n} autres : affine la recherche.", | |
| 120 | + hint: "↑ ↓ pour choisir, Entrée ou double-clic pour poser au curseur. Glisse un média sur la timeline : sur la piste A-roll pour l’insérer, ailleurs pour le poser au-dessus.", | |
| 110 | 121 | }, |
| 111 | 122 | anims: { |
| 112 | 123 | title: "Animations", |
| @@ -56,7 +56,6 @@ export default { | |||
| 56 | insertedHint: "Clip inséré d’un autre média, avec son propre son ; tire ses bords pour le raccourcir. Pas de transition avec lui : un whoosh fait l’affaire.", | 56 | insertedHint: "Clip inséré d’un autre média, avec son propre son ; tire ses bords pour le raccourcir. Pas de transition avec lui : un whoosh fait l’affaire.", |
| 57 | insertAroll: "→ A-roll", | 57 | insertAroll: "→ A-roll", |
| 58 | insertArollTitle: "Insérer dans l’A-roll à {tc} (la suite est décalée d’autant)", | 58 | insertArollTitle: "Insérer dans l’A-roll à {tc} (la suite est décalée d’autant)", |
| 59 | - dragHint: "Glisser sur la timeline : sur la piste A-roll pour l’insérer, ailleurs pour le poser au-dessus", | ||
| 60 | scale: "Échelle", | 59 | scale: "Échelle", |
| 61 | horizontal: "Horizontal", | 60 | horizontal: "Horizontal", |
| 62 | vertical: "Vertical", | 61 | vertical: "Vertical", |
| @@ -101,12 +100,24 @@ export default { | |||
| 101 | addBeat: "+ Beat", | 100 | addBeat: "+ Beat", |
| 102 | addBeatTitle: "Un beat sur ce passage (M) : écris sa consigne sur la slide, tire ses bords sur la timeline, puis crée l’animation.", | 101 | addBeatTitle: "Un beat sur ce passage (M) : écris sa consigne sur la slide, tire ses bords sur la timeline, puis crée l’animation.", |
| 103 | addChapter: "+ Chapitre", | 102 | addChapter: "+ Chapitre", |
| 103 | + addRush: "+ Rush", | ||
| 104 | + addBroll: "+ B-roll", | ||
| 105 | + addSfx: "+ Son", | ||
| 106 | + addMusic: "+ Musique", | ||
| 107 | + motionHint: "Motion design : sélectionne un passage (glisse sur la timeline) et demande-le à Daryl.", | ||
| 108 | + }, | ||
| 109 | + picker: { | ||
| 110 | + title: "Médias · au curseur {tc}", | ||
| 111 | + search: "Chercher dans les rushes, le projet et la bibliothèque…", | ||
| 112 | + tabAll: "Tout", | ||
| 113 | + tabRush: "Rushes", | ||
| 104 | tabBroll: "B-roll", | 114 | tabBroll: "B-roll", |
| 105 | tabSfx: "Sons", | 115 | tabSfx: "Sons", |
| 106 | tabMusic: "Musique", | 116 | tabMusic: "Musique", |
| 107 | - filter: "Filtrer", | 117 | + add: "Poser au curseur", |
| 108 | - emptyMedia: "Rien ici. Les B-roll viennent de l’étape Sourcing ; sons et musiques de la bibliothèque.", | 118 | + empty: "Rien ici. Les rushes viennent de l’étape Rushes, les B-roll du Sourcing ; sons et musiques de la bibliothèque.", |
| 109 | - motionHint: "Motion design : sélectionne un passage (glisse sur la timeline) et demande-le à Daryl.", | 119 | + more: "… et {n} autres : affine la recherche.", |
| 120 | + hint: "↑ ↓ pour choisir, Entrée ou double-clic pour poser au curseur. Glisse un média sur la timeline : sur la piste A-roll pour l’insérer, ailleurs pour le poser au-dessus.", | ||
| 110 | }, | 121 | }, |
| 111 | anims: { | 122 | anims: { |
| 112 | title: "Animations", | 123 | title: "Animations", |
modified
web/src/project/steps/montage/Inspector.vue +12 -41 | @@ -1,8 +1,8 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | 2 | /** |
| 3 | 3 | * À côté de l'aperçu : l'élément sélectionné (réglages, Commenter, Supprimer), la plage sélectionnée |
| 4 | - * (à confier au monteur), ou sinon de quoi ajouter au curseur (beat, zoom, texte, B-roll, son, chapitre). Une vidéo peut aussi | |
| 5 | - * s'insérer dans l'A-roll (bouton, ou glissée sur la piste A-roll de la timeline). | |
| 4 | + * (à confier au monteur), ou sinon de quoi ajouter au curseur (beat, zoom, texte, chapitre, et par la popup des médias : | |
| 5 | + * rush, B-roll, son, musique). Une vidéo peut aussi s'insérer dans l'A-roll (depuis la popup, ou glissée sur sa piste). | |
| 6 | 6 | */ |
| 7 | 7 | import { computed, ref } from "vue"; |
| 8 | 8 | import { useI18n } from "vue-i18n"; |
| @@ -12,10 +12,11 @@ import type { Fx } from "../../../lib/transitions"; | ||
| 12 | 12 | import { post } from "../../../lib/api"; |
| 13 | 13 | import { duration as fmtDur } from "../../../lib/format"; |
| 14 | 14 | import { toast } from "../../../lib/toast"; |
| 15 | -import { addMedia as addMediaTo, addText as addTextTo, isVideo, MEDIA_DRAG, remove } from "./ops"; | |
| 15 | +import MediaPicker from "./MediaPicker.vue"; | |
| 16 | +import { addMedia as addMediaTo, addText as addTextTo, remove } from "./ops"; | |
| 16 | 17 | import { |
| 17 | 18 | fxSpan, newId, prevJoined, spanOf, tcd, trackLabel, |
| 18 | - type AvailableMedia, type ArollClip, type Broll, type Marker, type MontageView, type Motion, type Selection, type Sound, type TextNote, type Timeline, type Zoom, | |
| 19 | + type AvailableMedia, type ArollClip, type Broll, type MediaTab, type Marker, type MontageView, type Motion, type Selection, type Sound, type TextNote, type Timeline, type Zoom, | |
| 19 | 20 | } from "./types"; |
| 20 | 21 | |
| 21 | 22 | const props = defineProps<{ |
| @@ -89,13 +90,10 @@ const motionState = computed(() => (item.value?.track === "motion" ? props.view. | ||
| 89 | 90 | |
| 90 | 91 | // --- ajouter au curseur |
| 91 | 92 | const at = computed(() => Math.min(props.t, Math.max(0, props.total - 0.5))); |
| 92 | -const query = ref(""); | |
| 93 | -const tab = ref<"broll" | "sfx" | "musique">("broll"); | |
| 94 | -const TABS = computed(() => [["broll", tr("editing.inspector.tabBroll")], ["sfx", tr("editing.inspector.tabSfx")], ["musique", tr("editing.inspector.tabMusic")]] as const); | |
| 95 | -const list = computed(() => { | |
| 96 | - const q = query.value.trim().toLowerCase(); | |
| 97 | - return props.available.filter((m) => m.category === tab.value && (!q || m.name.toLowerCase().includes(q))).slice(0, 40); | |
| 98 | -}); | |
| 93 | +/** l'onglet sur lequel la popup des médias est ouverte ; null : fermée */ | |
| 94 | +const picker = ref<MediaTab | null>(null); | |
| 95 | +const MEDIA_BUTTONS = computed(() => [["rush", tr("editing.inspector.addRush")], ["broll", tr("editing.inspector.addBroll")], | |
| 96 | + ["sfx", tr("editing.inspector.addSfx")], ["musique", tr("editing.inspector.addMusic")]] as const); | |
| 99 | 97 | |
| 100 | 98 | function addZoom(scale: number) { |
| 101 | 99 | let id = ""; |
| @@ -114,10 +112,6 @@ function addMedia(m: AvailableMedia) { | ||
| 114 | 112 | emit("edit", (tl) => { added = addMediaTo(tl, m, at.value); }); |
| 115 | 113 | if (added) sel.value = { item: added }; |
| 116 | 114 | } |
| 117 | -function dragMedia(e: DragEvent, m: AvailableMedia) { | |
| 118 | - e.dataTransfer?.setData(MEDIA_DRAG, JSON.stringify(m)); | |
| 119 | - if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy"; | |
| 120 | -} | |
| 121 | 115 | function addChapter() { |
| 122 | 116 | let id = ""; |
| 123 | 117 | emit("edit", (tl) => { id = newId(tl, "k"); tl.markers.push({ id, t: round(props.t), text: "", kind: "chapter", by: "user", done: false }); }); |
| @@ -306,25 +300,13 @@ const SOUND_ROLES = computed(() => [["sfx", tr("editing.inspector.sound")], ["mu | ||
| 306 | 300 | <button class="btn sm" :title="tr('editing.inspector.addBeatTitle')" @click="emit('beat')">{{ tr("editing.inspector.addBeat") }}</button> |
| 307 | 301 | <button class="btn sm" @click="addChapter">{{ tr("editing.inspector.addChapter") }}</button> |
| 308 | 302 | </div> |
| 309 | - <div class="media"> | |
| 310 | - <div class="row"> | |
| 311 | - <Seg v-model="tab" :options="TABS" size="sm" /> | |
| 312 | - <input v-model="query" class="input search" :placeholder="tr('editing.inspector.filter')"> | |
| 313 | - </div> | |
| 314 | - <div class="list"> | |
| 315 | - <div v-for="m in list" :key="m.src" class="m" draggable="true" :title="`${m.src}${m.duration ? ` · ${fmtDur(m.duration)}` : ''} | |
| 316 | -${tr('editing.inspector.dragHint')}`" | |
| 317 | - @click="addMedia(m)" @dragstart="dragMedia($event, m)"> | |
| 318 | - <span class="n">+ {{ m.name }}</span><span class="mono d">{{ m.duration ? fmtDur(m.duration) : "" }}</span><span class="w">{{ m.where }}</span> | |
| 319 | - <button v-if="isVideo(m)" class="to-aroll" :title="tr('editing.inspector.insertArollTitle', { tc: tcd(t) })" | |
| 320 | - @click.stop="emit('insert', m)">{{ tr("editing.inspector.insertAroll") }}</button> | |
| 321 | - </div> | |
| 322 | - <span v-if="!list.length" class="hint">{{ tr("editing.inspector.emptyMedia") }}</span> | |
| 323 | - </div> | |
| 303 | + <div class="row"> | |
| 304 | + <button v-for="[k, label] in MEDIA_BUTTONS" :key="k" class="btn sm" @click="picker = k">{{ label }}</button> | |
| 324 | 305 | </div> |
| 325 | 306 | </fieldset> |
| 326 | 307 | <p class="hint">{{ tr("editing.inspector.motionHint") }}</p> |
| 327 | 308 | </template> |
| 309 | + <MediaPicker v-model="picker" :available="available" :t="t" @add="addMedia" @insert="(m) => emit('insert', m)" /> | |
| 328 | 310 | </div> |
| 329 | 311 | </template> |
| 330 | 312 | |
| @@ -353,15 +335,4 @@ fieldset { border: none; margin: 0; padding: 0; display: flex; flex-direction: c | ||
| 353 | 335 | .chips { display: flex; flex-wrap: wrap; gap: 6px; } |
| 354 | 336 | .chip { border: 1px solid var(--line-strong); border-radius: 3px; background: #fff; padding: 5px 9px; font-size: 12.5px; cursor: pointer; } |
| 355 | 337 | .chip:hover { border-color: var(--ink); } |
| 356 | -.media { display: flex; flex-direction: column; gap: 8px; } | |
| 357 | -.input.search { flex: 1; min-width: 100px; height: 28px; font-size: 12.5px; } | |
| 358 | -.list { display: flex; flex-direction: column; max-height: 170px; overflow-y: auto; border: 1px solid var(--line-soft); border-radius: 3px; } | |
| 359 | -.m { display: flex; align-items: baseline; gap: 8px; border: none; background: #fff; padding: 6px 8px; text-align: left; cursor: pointer; border-bottom: 1px solid var(--line-soft); } | |
| 360 | -.m:hover { background: var(--bg-soft); } | |
| 361 | -.m .n { flex: 1; min-width: 0; font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 362 | -.m .d { font-size: 11px; color: var(--faint); } | |
| 363 | -.m .w { font-size: 10.5px; color: var(--off); } | |
| 364 | -.m[draggable="true"] { cursor: grab; } | |
| 365 | -.to-aroll { border: 1px solid var(--line-strong); border-radius: 3px; background: #fff; padding: 1px 6px; font-size: 10.5px; cursor: pointer; white-space: nowrap; } | |
| 366 | -.to-aroll:hover { border-color: var(--ink); } | |
| 367 | 338 | </style> |
| @@ -1,8 +1,8 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | /** | 2 | /** |
| 3 | * À côté de l'aperçu : l'élément sélectionné (réglages, Commenter, Supprimer), la plage sélectionnée | 3 | * À côté de l'aperçu : l'élément sélectionné (réglages, Commenter, Supprimer), la plage sélectionnée |
| 4 | - * (à confier au monteur), ou sinon de quoi ajouter au curseur (beat, zoom, texte, B-roll, son, chapitre). Une vidéo peut aussi | 4 | + * (à confier au monteur), ou sinon de quoi ajouter au curseur (beat, zoom, texte, chapitre, et par la popup des médias : |
| 5 | - * s'insérer dans l'A-roll (bouton, ou glissée sur la piste A-roll de la timeline). | 5 | + * rush, B-roll, son, musique). Une vidéo peut aussi s'insérer dans l'A-roll (depuis la popup, ou glissée sur sa piste). |
| 6 | */ | 6 | */ |
| 7 | import { computed, ref } from "vue"; | 7 | import { computed, ref } from "vue"; |
| 8 | import { useI18n } from "vue-i18n"; | 8 | import { useI18n } from "vue-i18n"; |
| @@ -12,10 +12,11 @@ import type { Fx } from "../../../lib/transitions"; | |||
| 12 | import { post } from "../../../lib/api"; | 12 | import { post } from "../../../lib/api"; |
| 13 | import { duration as fmtDur } from "../../../lib/format"; | 13 | import { duration as fmtDur } from "../../../lib/format"; |
| 14 | import { toast } from "../../../lib/toast"; | 14 | import { toast } from "../../../lib/toast"; |
| 15 | -import { addMedia as addMediaTo, addText as addTextTo, isVideo, MEDIA_DRAG, remove } from "./ops"; | 15 | +import MediaPicker from "./MediaPicker.vue"; |
| 16 | +import { addMedia as addMediaTo, addText as addTextTo, remove } from "./ops"; | ||
| 16 | import { | 17 | import { |
| 17 | fxSpan, newId, prevJoined, spanOf, tcd, trackLabel, | 18 | fxSpan, newId, prevJoined, spanOf, tcd, trackLabel, |
| 18 | - type AvailableMedia, type ArollClip, type Broll, type Marker, type MontageView, type Motion, type Selection, type Sound, type TextNote, type Timeline, type Zoom, | 19 | + type AvailableMedia, type ArollClip, type Broll, type MediaTab, type Marker, type MontageView, type Motion, type Selection, type Sound, type TextNote, type Timeline, type Zoom, |
| 19 | } from "./types"; | 20 | } from "./types"; |
| 20 | 21 | ||
| 21 | const props = defineProps<{ | 22 | const props = defineProps<{ |
| @@ -89,13 +90,10 @@ const motionState = computed(() => (item.value?.track === "motion" ? props.view. | |||
| 89 | 90 | ||
| 90 | // --- ajouter au curseur | 91 | // --- ajouter au curseur |
| 91 | const at = computed(() => Math.min(props.t, Math.max(0, props.total - 0.5))); | 92 | const at = computed(() => Math.min(props.t, Math.max(0, props.total - 0.5))); |
| 92 | -const query = ref(""); | 93 | +/** l'onglet sur lequel la popup des médias est ouverte ; null : fermée */ |
| 93 | -const tab = ref<"broll" | "sfx" | "musique">("broll"); | 94 | +const picker = ref<MediaTab | null>(null); |
| 94 | -const TABS = computed(() => [["broll", tr("editing.inspector.tabBroll")], ["sfx", tr("editing.inspector.tabSfx")], ["musique", tr("editing.inspector.tabMusic")]] as const); | 95 | +const MEDIA_BUTTONS = computed(() => [["rush", tr("editing.inspector.addRush")], ["broll", tr("editing.inspector.addBroll")], |
| 95 | -const list = computed(() => { | 96 | + ["sfx", tr("editing.inspector.addSfx")], ["musique", tr("editing.inspector.addMusic")]] as const); |
| 96 | - const q = query.value.trim().toLowerCase(); | ||
| 97 | - return props.available.filter((m) => m.category === tab.value && (!q || m.name.toLowerCase().includes(q))).slice(0, 40); | ||
| 98 | -}); | ||
| 99 | 97 | ||
| 100 | function addZoom(scale: number) { | 98 | function addZoom(scale: number) { |
| 101 | let id = ""; | 99 | let id = ""; |
| @@ -114,10 +112,6 @@ function addMedia(m: AvailableMedia) { | |||
| 114 | emit("edit", (tl) => { added = addMediaTo(tl, m, at.value); }); | 112 | emit("edit", (tl) => { added = addMediaTo(tl, m, at.value); }); |
| 115 | if (added) sel.value = { item: added }; | 113 | if (added) sel.value = { item: added }; |
| 116 | } | 114 | } |
| 117 | -function dragMedia(e: DragEvent, m: AvailableMedia) { | ||
| 118 | - e.dataTransfer?.setData(MEDIA_DRAG, JSON.stringify(m)); | ||
| 119 | - if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy"; | ||
| 120 | -} | ||
| 121 | function addChapter() { | 115 | function addChapter() { |
| 122 | let id = ""; | 116 | let id = ""; |
| 123 | emit("edit", (tl) => { id = newId(tl, "k"); tl.markers.push({ id, t: round(props.t), text: "", kind: "chapter", by: "user", done: false }); }); | 117 | emit("edit", (tl) => { id = newId(tl, "k"); tl.markers.push({ id, t: round(props.t), text: "", kind: "chapter", by: "user", done: false }); }); |
| @@ -306,25 +300,13 @@ const SOUND_ROLES = computed(() => [["sfx", tr("editing.inspector.sound")], ["mu | |||
| 306 | <button class="btn sm" :title="tr('editing.inspector.addBeatTitle')" @click="emit('beat')">{{ tr("editing.inspector.addBeat") }}</button> | 300 | <button class="btn sm" :title="tr('editing.inspector.addBeatTitle')" @click="emit('beat')">{{ tr("editing.inspector.addBeat") }}</button> |
| 307 | <button class="btn sm" @click="addChapter">{{ tr("editing.inspector.addChapter") }}</button> | 301 | <button class="btn sm" @click="addChapter">{{ tr("editing.inspector.addChapter") }}</button> |
| 308 | </div> | 302 | </div> |
| 309 | - <div class="media"> | 303 | + <div class="row"> |
| 310 | - <div class="row"> | 304 | + <button v-for="[k, label] in MEDIA_BUTTONS" :key="k" class="btn sm" @click="picker = k">{{ label }}</button> |
| 311 | - <Seg v-model="tab" :options="TABS" size="sm" /> | ||
| 312 | - <input v-model="query" class="input search" :placeholder="tr('editing.inspector.filter')"> | ||
| 313 | - </div> | ||
| 314 | - <div class="list"> | ||
| 315 | - <div v-for="m in list" :key="m.src" class="m" draggable="true" :title="`${m.src}${m.duration ? ` · ${fmtDur(m.duration)}` : ''} | ||
| 316 | -${tr('editing.inspector.dragHint')}`" | ||
| 317 | - @click="addMedia(m)" @dragstart="dragMedia($event, m)"> | ||
| 318 | - <span class="n">+ {{ m.name }}</span><span class="mono d">{{ m.duration ? fmtDur(m.duration) : "" }}</span><span class="w">{{ m.where }}</span> | ||
| 319 | - <button v-if="isVideo(m)" class="to-aroll" :title="tr('editing.inspector.insertArollTitle', { tc: tcd(t) })" | ||
| 320 | - @click.stop="emit('insert', m)">{{ tr("editing.inspector.insertAroll") }}</button> | ||
| 321 | - </div> | ||
| 322 | - <span v-if="!list.length" class="hint">{{ tr("editing.inspector.emptyMedia") }}</span> | ||
| 323 | - </div> | ||
| 324 | </div> | 305 | </div> |
| 325 | </fieldset> | 306 | </fieldset> |
| 326 | <p class="hint">{{ tr("editing.inspector.motionHint") }}</p> | 307 | <p class="hint">{{ tr("editing.inspector.motionHint") }}</p> |
| 327 | </template> | 308 | </template> |
| 309 | + <MediaPicker v-model="picker" :available="available" :t="t" @add="addMedia" @insert="(m) => emit('insert', m)" /> | ||
| 328 | </div> | 310 | </div> |
| 329 | </template> | 311 | </template> |
| 330 | 312 | ||
| @@ -353,15 +335,4 @@ fieldset { border: none; margin: 0; padding: 0; display: flex; flex-direction: c | |||
| 353 | .chips { display: flex; flex-wrap: wrap; gap: 6px; } | 335 | .chips { display: flex; flex-wrap: wrap; gap: 6px; } |
| 354 | .chip { border: 1px solid var(--line-strong); border-radius: 3px; background: #fff; padding: 5px 9px; font-size: 12.5px; cursor: pointer; } | 336 | .chip { border: 1px solid var(--line-strong); border-radius: 3px; background: #fff; padding: 5px 9px; font-size: 12.5px; cursor: pointer; } |
| 355 | .chip:hover { border-color: var(--ink); } | 337 | .chip:hover { border-color: var(--ink); } |
| 356 | -.media { display: flex; flex-direction: column; gap: 8px; } | ||
| 357 | -.input.search { flex: 1; min-width: 100px; height: 28px; font-size: 12.5px; } | ||
| 358 | -.list { display: flex; flex-direction: column; max-height: 170px; overflow-y: auto; border: 1px solid var(--line-soft); border-radius: 3px; } | ||
| 359 | -.m { display: flex; align-items: baseline; gap: 8px; border: none; background: #fff; padding: 6px 8px; text-align: left; cursor: pointer; border-bottom: 1px solid var(--line-soft); } | ||
| 360 | -.m:hover { background: var(--bg-soft); } | ||
| 361 | -.m .n { flex: 1; min-width: 0; font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | ||
| 362 | -.m .d { font-size: 11px; color: var(--faint); } | ||
| 363 | -.m .w { font-size: 10.5px; color: var(--off); } | ||
| 364 | -.m[draggable="true"] { cursor: grab; } | ||
| 365 | -.to-aroll { border: 1px solid var(--line-strong); border-radius: 3px; background: #fff; padding: 1px 6px; font-size: 10.5px; cursor: pointer; white-space: nowrap; } | ||
| 366 | -.to-aroll:hover { border-color: var(--ink); } | ||
| 367 | </style> | 338 | </style> |
added
web/src/project/steps/montage/MediaPicker.vue +163 -0 | new file mode 100644 | ||
| @@ -0,0 +1,163 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +/** | |
| 3 | + * La popup des médias à poser au curseur : rushes, assets du projet et bibliothèque commune, par onglet et par | |
| 4 | + * recherche. On écoute ou regarde l'élément choisi avant de le poser au-dessus de l'A-roll, ou (vidéo) de l'insérer | |
| 5 | + * dans l'A-roll. On peut aussi le glisser sur la timeline : la popup s'efface le temps du glisser. | |
| 6 | + * ↑ ↓ choisissent, Entrée pose au curseur, Échap ferme. | |
| 7 | + */ | |
| 8 | +import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"; | |
| 9 | +import { useI18n } from "vue-i18n"; | |
| 10 | +import Seg from "../../../components/Seg.vue"; | |
| 11 | +import { duration as fmtDur } from "../../../lib/format"; | |
| 12 | +import { isVideo, MEDIA_DRAG } from "./ops"; | |
| 13 | +import { tcd, type AvailableMedia, type MediaTab } from "./types"; | |
| 14 | + | |
| 15 | +const props = defineProps<{ available: AvailableMedia[]; t: number }>(); | |
| 16 | +const emit = defineEmits<{ add: [AvailableMedia]; insert: [AvailableMedia] }>(); | |
| 17 | +const tab = defineModel<MediaTab | null>({ required: true }); | |
| 18 | +const { t: tr } = useI18n(); | |
| 19 | + | |
| 20 | +const query = ref(""); | |
| 21 | +const current = ref<AvailableMedia | null>(null); | |
| 22 | +const dragging = ref(false); | |
| 23 | +const search = ref<HTMLInputElement | null>(null); | |
| 24 | +const listEl = ref<HTMLElement | null>(null); | |
| 25 | + | |
| 26 | +const TABS = computed(() => { | |
| 27 | + const n = (c: MediaTab) => props.available.filter((m) => c === "all" || m.category === c).length; | |
| 28 | + return ([["all", "tabAll"], ["rush", "tabRush"], ["broll", "tabBroll"], ["sfx", "tabSfx"], ["musique", "tabMusic"]] as const) | |
| 29 | + .map(([k, label]) => [k, `${tr(`editing.picker.${label}`)} · ${n(k)}`] as const); | |
| 30 | +}); | |
| 31 | +const list = computed(() => { | |
| 32 | + const words = query.value.trim().toLowerCase().split(/\s+/).filter(Boolean); | |
| 33 | + return props.available.filter((m) => (tab.value === "all" || m.category === tab.value) | |
| 34 | + && words.every((w) => `${m.name} ${m.description ?? ""}`.toLowerCase().includes(w))); | |
| 35 | +}); | |
| 36 | +const shown = computed(() => list.value.slice(0, 300)); | |
| 37 | + | |
| 38 | +// à l'ouverture : la recherche prend le clavier ; on repart d'une recherche vide | |
| 39 | +watch(tab, (v, old) => { | |
| 40 | + if (!v) return; | |
| 41 | + if (!old) { | |
| 42 | + query.value = ""; | |
| 43 | + current.value = null; | |
| 44 | + dragging.value = false; | |
| 45 | + nextTick(() => search.value?.focus()); | |
| 46 | + } | |
| 47 | +}); | |
| 48 | +// le premier de la liste est choisi, tant qu'on n'en a pas choisi un autre qui y est encore | |
| 49 | +watch(list, (l) => { if (!current.value || !l.includes(current.value)) current.value = l[0] ?? null; }, { immediate: true }); | |
| 50 | + | |
| 51 | +function close() { tab.value = null; } | |
| 52 | +function add(m: AvailableMedia) { emit("add", m); close(); } | |
| 53 | +function insert(m: AvailableMedia) { emit("insert", m); close(); } | |
| 54 | + | |
| 55 | +function move(d: number) { | |
| 56 | + const l = shown.value; | |
| 57 | + if (!l.length) return; | |
| 58 | + const i = current.value ? l.indexOf(current.value) : -1; | |
| 59 | + current.value = l[Math.max(0, Math.min(l.length - 1, i + d))]; | |
| 60 | + nextTick(() => listEl.value?.querySelector(".m.on")?.scrollIntoView({ block: "nearest" })); | |
| 61 | +} | |
| 62 | +function onKey(e: KeyboardEvent) { | |
| 63 | + if (!tab.value) return; | |
| 64 | + // popup ouverte : les raccourcis du montage (Suppr, lame, flèches…) ne passent pas | |
| 65 | + e.stopPropagation(); | |
| 66 | + if (e.key === "Escape") close(); | |
| 67 | + else if (e.key === "ArrowDown") move(1); | |
| 68 | + else if (e.key === "ArrowUp") move(-1); | |
| 69 | + else if (e.key === "Enter" && current.value && (e.target as HTMLElement).tagName !== "BUTTON") add(current.value); | |
| 70 | + else return; | |
| 71 | + e.preventDefault(); | |
| 72 | +} | |
| 73 | +onMounted(() => document.addEventListener("keydown", onKey, true)); | |
| 74 | +onBeforeUnmount(() => document.removeEventListener("keydown", onKey, true)); | |
| 75 | + | |
| 76 | +function dragStart(e: DragEvent, m: AvailableMedia) { | |
| 77 | + e.dataTransfer?.setData(MEDIA_DRAG, JSON.stringify(m)); | |
| 78 | + if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy"; | |
| 79 | + // pas tout de suite : le navigateur prend l'image du glisser après ce gestionnaire | |
| 80 | + requestAnimationFrame(() => (dragging.value = true)); | |
| 81 | +} | |
| 82 | +function dragEnd(e: DragEvent) { | |
| 83 | + dragging.value = false; | |
| 84 | + if (e.dataTransfer?.dropEffect !== "none") close(); | |
| 85 | +} | |
| 86 | + | |
| 87 | +const label = (c: AvailableMedia["category"]) => tr(`editing.picker.${{ rush: "tabRush", broll: "tabBroll", sfx: "tabSfx", musique: "tabMusic" }[c]}`); | |
| 88 | +</script> | |
| 89 | + | |
| 90 | +<template> | |
| 91 | + <Teleport to="body"> | |
| 92 | + <template v-if="tab"> | |
| 93 | + <div class="backdrop" :class="{ dragging }" @click="close" /> | |
| 94 | + <div class="picker" :class="{ dragging }" role="dialog"> | |
| 95 | + <div class="head"> | |
| 96 | + <span class="eyebrow">{{ tr("editing.picker.title", { tc: tcd(t) }) }}</span> | |
| 97 | + <span class="spacer" /> | |
| 98 | + <button class="x-btn" :title="tr('common.close')" @click="close">×</button> | |
| 99 | + </div> | |
| 100 | + <input ref="search" v-model="query" class="input search" :placeholder="tr('editing.picker.search')"> | |
| 101 | + <Seg v-model="tab" :options="TABS" size="sm" /> | |
| 102 | + <div class="body"> | |
| 103 | + <div ref="listEl" class="list"> | |
| 104 | + <div v-for="m in shown" :key="m.src" class="m" :class="{ on: m === current }" draggable="true" :title="m.src" | |
| 105 | + @click="current = m" @dblclick="add(m)" @dragstart="dragStart($event, m)" @dragend="dragEnd"> | |
| 106 | + <span class="n">{{ m.name }}</span> | |
| 107 | + <span class="mono d">{{ m.duration ? fmtDur(m.duration) : "" }}</span> | |
| 108 | + <span class="w">{{ tab === "all" ? label(m.category) : m.where }}</span> | |
| 109 | + </div> | |
| 110 | + <span v-if="!list.length" class="hint empty">{{ tr("editing.picker.empty") }}</span> | |
| 111 | + <span v-else-if="list.length > shown.length" class="hint empty">{{ tr("editing.picker.more", { n: list.length - shown.length }) }}</span> | |
| 112 | + </div> | |
| 113 | + <div class="side"> | |
| 114 | + <template v-if="current"> | |
| 115 | + <video v-if="current.kind === 'Vidéo'" :key="current.url" :src="current.url" controls preload="metadata" /> | |
| 116 | + <audio v-else-if="current.kind === 'Audio'" :key="current.url" :src="current.url" controls preload="metadata" /> | |
| 117 | + <span class="t" :title="current.name">{{ current.name }}</span> | |
| 118 | + <span class="mono sub">{{ [label(current.category), current.where, current.duration ? fmtDur(current.duration) : ""].filter(Boolean).join(" · ") }}</span> | |
| 119 | + <p v-if="current.description" class="desc">{{ current.description }}</p> | |
| 120 | + <div class="row"> | |
| 121 | + <button class="btn sm" @click="add(current)">{{ tr("editing.picker.add") }}</button> | |
| 122 | + <button v-if="isVideo(current)" class="btn sm" :title="tr('editing.inspector.insertArollTitle', { tc: tcd(t) })" | |
| 123 | + @click="insert(current)">{{ tr("editing.inspector.insertAroll") }}</button> | |
| 124 | + </div> | |
| 125 | + </template> | |
| 126 | + </div> | |
| 127 | + </div> | |
| 128 | + <p class="hint">{{ tr("editing.picker.hint") }}</p> | |
| 129 | + </div> | |
| 130 | + </template> | |
| 131 | + </Teleport> | |
| 132 | +</template> | |
| 133 | + | |
| 134 | +<style scoped> | |
| 135 | +.backdrop { position: fixed; inset: 0; z-index: 40; background: rgba(24, 25, 27, .45); } | |
| 136 | +.picker { | |
| 137 | + position: fixed; z-index: 41; top: 50%; left: 50%; transform: translate(-50%, -50%); width: min(980px, 92vw); height: min(640px, 86vh); | |
| 138 | + background: #fff; border-radius: 6px; box-shadow: 0 16px 48px rgba(0, 0, 0, .3); padding: 16px; | |
| 139 | + display: flex; flex-direction: column; gap: 10px; | |
| 140 | +} | |
| 141 | +/* pendant un glisser vers la timeline, la popup laisse passer la souris */ | |
| 142 | +.dragging { opacity: 0; pointer-events: none; } | |
| 143 | +.head { display: flex; align-items: center; gap: 10px; } | |
| 144 | +.input.search { height: 36px; padding: 0 10px; font-size: 14px; } | |
| 145 | +.body { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 14px; } | |
| 146 | +.list { overflow-y: auto; border: 1px solid var(--line-soft); border-radius: 3px; } | |
| 147 | +.m { display: flex; align-items: baseline; gap: 10px; padding: 7px 10px; cursor: grab; border-bottom: 1px solid var(--line-soft); } | |
| 148 | +.m:hover { background: var(--bg-soft); } | |
| 149 | +.m.on { background: var(--ink); color: #fff; } | |
| 150 | +.m.on .d, .m.on .w { color: rgba(255, 255, 255, .7); } | |
| 151 | +.m .n { flex: 1; min-width: 0; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 152 | +.m .d { font-size: 11px; color: var(--faint); } | |
| 153 | +.m .w { font-size: 10.5px; color: var(--off); white-space: nowrap; } | |
| 154 | +.side { display: flex; flex-direction: column; gap: 8px; min-width: 0; overflow-y: auto; } | |
| 155 | +.side video { width: 100%; aspect-ratio: 16 / 9; background: #111; border-radius: 4px; } | |
| 156 | +.side audio { width: 100%; } | |
| 157 | +.t { font-size: 14px; font-weight: 600; word-break: break-all; } | |
| 158 | +.sub { font-size: 12px; color: var(--muted); } | |
| 159 | +.desc { margin: 0; font-size: 12.5px; color: var(--muted); line-height: 1.5; } | |
| 160 | +.row { display: flex; gap: 8px; flex-wrap: wrap; } | |
| 161 | +.hint { margin: 0; font-size: 12px; color: var(--faint); line-height: 1.5; } | |
| 162 | +.empty { display: block; padding: 10px; } | |
| 163 | +</style> | |
| new file mode 100644 | |||
| @@ -0,0 +1,163 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +/** | ||
| 3 | + * La popup des médias à poser au curseur : rushes, assets du projet et bibliothèque commune, par onglet et par | ||
| 4 | + * recherche. On écoute ou regarde l'élément choisi avant de le poser au-dessus de l'A-roll, ou (vidéo) de l'insérer | ||
| 5 | + * dans l'A-roll. On peut aussi le glisser sur la timeline : la popup s'efface le temps du glisser. | ||
| 6 | + * ↑ ↓ choisissent, Entrée pose au curseur, Échap ferme. | ||
| 7 | + */ | ||
| 8 | +import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"; | ||
| 9 | +import { useI18n } from "vue-i18n"; | ||
| 10 | +import Seg from "../../../components/Seg.vue"; | ||
| 11 | +import { duration as fmtDur } from "../../../lib/format"; | ||
| 12 | +import { isVideo, MEDIA_DRAG } from "./ops"; | ||
| 13 | +import { tcd, type AvailableMedia, type MediaTab } from "./types"; | ||
| 14 | + | ||
| 15 | +const props = defineProps<{ available: AvailableMedia[]; t: number }>(); | ||
| 16 | +const emit = defineEmits<{ add: [AvailableMedia]; insert: [AvailableMedia] }>(); | ||
| 17 | +const tab = defineModel<MediaTab | null>({ required: true }); | ||
| 18 | +const { t: tr } = useI18n(); | ||
| 19 | + | ||
| 20 | +const query = ref(""); | ||
| 21 | +const current = ref<AvailableMedia | null>(null); | ||
| 22 | +const dragging = ref(false); | ||
| 23 | +const search = ref<HTMLInputElement | null>(null); | ||
| 24 | +const listEl = ref<HTMLElement | null>(null); | ||
| 25 | + | ||
| 26 | +const TABS = computed(() => { | ||
| 27 | + const n = (c: MediaTab) => props.available.filter((m) => c === "all" || m.category === c).length; | ||
| 28 | + return ([["all", "tabAll"], ["rush", "tabRush"], ["broll", "tabBroll"], ["sfx", "tabSfx"], ["musique", "tabMusic"]] as const) | ||
| 29 | + .map(([k, label]) => [k, `${tr(`editing.picker.${label}`)} · ${n(k)}`] as const); | ||
| 30 | +}); | ||
| 31 | +const list = computed(() => { | ||
| 32 | + const words = query.value.trim().toLowerCase().split(/\s+/).filter(Boolean); | ||
| 33 | + return props.available.filter((m) => (tab.value === "all" || m.category === tab.value) | ||
| 34 | + && words.every((w) => `${m.name} ${m.description ?? ""}`.toLowerCase().includes(w))); | ||
| 35 | +}); | ||
| 36 | +const shown = computed(() => list.value.slice(0, 300)); | ||
| 37 | + | ||
| 38 | +// à l'ouverture : la recherche prend le clavier ; on repart d'une recherche vide | ||
| 39 | +watch(tab, (v, old) => { | ||
| 40 | + if (!v) return; | ||
| 41 | + if (!old) { | ||
| 42 | + query.value = ""; | ||
| 43 | + current.value = null; | ||
| 44 | + dragging.value = false; | ||
| 45 | + nextTick(() => search.value?.focus()); | ||
| 46 | + } | ||
| 47 | +}); | ||
| 48 | +// le premier de la liste est choisi, tant qu'on n'en a pas choisi un autre qui y est encore | ||
| 49 | +watch(list, (l) => { if (!current.value || !l.includes(current.value)) current.value = l[0] ?? null; }, { immediate: true }); | ||
| 50 | + | ||
| 51 | +function close() { tab.value = null; } | ||
| 52 | +function add(m: AvailableMedia) { emit("add", m); close(); } | ||
| 53 | +function insert(m: AvailableMedia) { emit("insert", m); close(); } | ||
| 54 | + | ||
| 55 | +function move(d: number) { | ||
| 56 | + const l = shown.value; | ||
| 57 | + if (!l.length) return; | ||
| 58 | + const i = current.value ? l.indexOf(current.value) : -1; | ||
| 59 | + current.value = l[Math.max(0, Math.min(l.length - 1, i + d))]; | ||
| 60 | + nextTick(() => listEl.value?.querySelector(".m.on")?.scrollIntoView({ block: "nearest" })); | ||
| 61 | +} | ||
| 62 | +function onKey(e: KeyboardEvent) { | ||
| 63 | + if (!tab.value) return; | ||
| 64 | + // popup ouverte : les raccourcis du montage (Suppr, lame, flèches…) ne passent pas | ||
| 65 | + e.stopPropagation(); | ||
| 66 | + if (e.key === "Escape") close(); | ||
| 67 | + else if (e.key === "ArrowDown") move(1); | ||
| 68 | + else if (e.key === "ArrowUp") move(-1); | ||
| 69 | + else if (e.key === "Enter" && current.value && (e.target as HTMLElement).tagName !== "BUTTON") add(current.value); | ||
| 70 | + else return; | ||
| 71 | + e.preventDefault(); | ||
| 72 | +} | ||
| 73 | +onMounted(() => document.addEventListener("keydown", onKey, true)); | ||
| 74 | +onBeforeUnmount(() => document.removeEventListener("keydown", onKey, true)); | ||
| 75 | + | ||
| 76 | +function dragStart(e: DragEvent, m: AvailableMedia) { | ||
| 77 | + e.dataTransfer?.setData(MEDIA_DRAG, JSON.stringify(m)); | ||
| 78 | + if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy"; | ||
| 79 | + // pas tout de suite : le navigateur prend l'image du glisser après ce gestionnaire | ||
| 80 | + requestAnimationFrame(() => (dragging.value = true)); | ||
| 81 | +} | ||
| 82 | +function dragEnd(e: DragEvent) { | ||
| 83 | + dragging.value = false; | ||
| 84 | + if (e.dataTransfer?.dropEffect !== "none") close(); | ||
| 85 | +} | ||
| 86 | + | ||
| 87 | +const label = (c: AvailableMedia["category"]) => tr(`editing.picker.${{ rush: "tabRush", broll: "tabBroll", sfx: "tabSfx", musique: "tabMusic" }[c]}`); | ||
| 88 | +</script> | ||
| 89 | + | ||
| 90 | +<template> | ||
| 91 | + <Teleport to="body"> | ||
| 92 | + <template v-if="tab"> | ||
| 93 | + <div class="backdrop" :class="{ dragging }" @click="close" /> | ||
| 94 | + <div class="picker" :class="{ dragging }" role="dialog"> | ||
| 95 | + <div class="head"> | ||
| 96 | + <span class="eyebrow">{{ tr("editing.picker.title", { tc: tcd(t) }) }}</span> | ||
| 97 | + <span class="spacer" /> | ||
| 98 | + <button class="x-btn" :title="tr('common.close')" @click="close">×</button> | ||
| 99 | + </div> | ||
| 100 | + <input ref="search" v-model="query" class="input search" :placeholder="tr('editing.picker.search')"> | ||
| 101 | + <Seg v-model="tab" :options="TABS" size="sm" /> | ||
| 102 | + <div class="body"> | ||
| 103 | + <div ref="listEl" class="list"> | ||
| 104 | + <div v-for="m in shown" :key="m.src" class="m" :class="{ on: m === current }" draggable="true" :title="m.src" | ||
| 105 | + @click="current = m" @dblclick="add(m)" @dragstart="dragStart($event, m)" @dragend="dragEnd"> | ||
| 106 | + <span class="n">{{ m.name }}</span> | ||
| 107 | + <span class="mono d">{{ m.duration ? fmtDur(m.duration) : "" }}</span> | ||
| 108 | + <span class="w">{{ tab === "all" ? label(m.category) : m.where }}</span> | ||
| 109 | + </div> | ||
| 110 | + <span v-if="!list.length" class="hint empty">{{ tr("editing.picker.empty") }}</span> | ||
| 111 | + <span v-else-if="list.length > shown.length" class="hint empty">{{ tr("editing.picker.more", { n: list.length - shown.length }) }}</span> | ||
| 112 | + </div> | ||
| 113 | + <div class="side"> | ||
| 114 | + <template v-if="current"> | ||
| 115 | + <video v-if="current.kind === 'Vidéo'" :key="current.url" :src="current.url" controls preload="metadata" /> | ||
| 116 | + <audio v-else-if="current.kind === 'Audio'" :key="current.url" :src="current.url" controls preload="metadata" /> | ||
| 117 | + <span class="t" :title="current.name">{{ current.name }}</span> | ||
| 118 | + <span class="mono sub">{{ [label(current.category), current.where, current.duration ? fmtDur(current.duration) : ""].filter(Boolean).join(" · ") }}</span> | ||
| 119 | + <p v-if="current.description" class="desc">{{ current.description }}</p> | ||
| 120 | + <div class="row"> | ||
| 121 | + <button class="btn sm" @click="add(current)">{{ tr("editing.picker.add") }}</button> | ||
| 122 | + <button v-if="isVideo(current)" class="btn sm" :title="tr('editing.inspector.insertArollTitle', { tc: tcd(t) })" | ||
| 123 | + @click="insert(current)">{{ tr("editing.inspector.insertAroll") }}</button> | ||
| 124 | + </div> | ||
| 125 | + </template> | ||
| 126 | + </div> | ||
| 127 | + </div> | ||
| 128 | + <p class="hint">{{ tr("editing.picker.hint") }}</p> | ||
| 129 | + </div> | ||
| 130 | + </template> | ||
| 131 | + </Teleport> | ||
| 132 | +</template> | ||
| 133 | + | ||
| 134 | +<style scoped> | ||
| 135 | +.backdrop { position: fixed; inset: 0; z-index: 40; background: rgba(24, 25, 27, .45); } | ||
| 136 | +.picker { | ||
| 137 | + position: fixed; z-index: 41; top: 50%; left: 50%; transform: translate(-50%, -50%); width: min(980px, 92vw); height: min(640px, 86vh); | ||
| 138 | + background: #fff; border-radius: 6px; box-shadow: 0 16px 48px rgba(0, 0, 0, .3); padding: 16px; | ||
| 139 | + display: flex; flex-direction: column; gap: 10px; | ||
| 140 | +} | ||
| 141 | +/* pendant un glisser vers la timeline, la popup laisse passer la souris */ | ||
| 142 | +.dragging { opacity: 0; pointer-events: none; } | ||
| 143 | +.head { display: flex; align-items: center; gap: 10px; } | ||
| 144 | +.input.search { height: 36px; padding: 0 10px; font-size: 14px; } | ||
| 145 | +.body { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 14px; } | ||
| 146 | +.list { overflow-y: auto; border: 1px solid var(--line-soft); border-radius: 3px; } | ||
| 147 | +.m { display: flex; align-items: baseline; gap: 10px; padding: 7px 10px; cursor: grab; border-bottom: 1px solid var(--line-soft); } | ||
| 148 | +.m:hover { background: var(--bg-soft); } | ||
| 149 | +.m.on { background: var(--ink); color: #fff; } | ||
| 150 | +.m.on .d, .m.on .w { color: rgba(255, 255, 255, .7); } | ||
| 151 | +.m .n { flex: 1; min-width: 0; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | ||
| 152 | +.m .d { font-size: 11px; color: var(--faint); } | ||
| 153 | +.m .w { font-size: 10.5px; color: var(--off); white-space: nowrap; } | ||
| 154 | +.side { display: flex; flex-direction: column; gap: 8px; min-width: 0; overflow-y: auto; } | ||
| 155 | +.side video { width: 100%; aspect-ratio: 16 / 9; background: #111; border-radius: 4px; } | ||
| 156 | +.side audio { width: 100%; } | ||
| 157 | +.t { font-size: 14px; font-weight: 600; word-break: break-all; } | ||
| 158 | +.sub { font-size: 12px; color: var(--muted); } | ||
| 159 | +.desc { margin: 0; font-size: 12.5px; color: var(--muted); line-height: 1.5; } | ||
| 160 | +.row { display: flex; gap: 8px; flex-wrap: wrap; } | ||
| 161 | +.hint { margin: 0; font-size: 12px; color: var(--faint); line-height: 1.5; } | ||
| 162 | +.empty { display: block; padding: 10px; } | ||
| 163 | +</style> | ||
modified
web/src/project/steps/montage/ops.ts +4 -3 | @@ -67,8 +67,9 @@ export function insertAroll(tl: Timeline, t: number, clip: { src: string; in: nu | ||
| 67 | 67 | /** Ajoute un média au-dessus de l'A-roll à l'instant `at` : un B-roll (muet), une musique ou un son. */ |
| 68 | 68 | export function addMedia(tl: Timeline, m: AvailableMedia, at: number): { track: "broll" | "audio"; id: string } { |
| 69 | 69 | const total = totalOf(tl); |
| 70 | - const len = Math.min(m.duration ?? 4, m.category === "broll" ? 4 : m.category === "musique" ? Math.max(5, total - at) : 3); | |
| 71 | - if (m.category === "broll") { | |
| 70 | + const visual = m.category === "broll" || m.category === "rush"; | |
| 71 | + const len = Math.min(m.duration ?? 4, visual ? 4 : m.category === "musique" ? Math.max(5, total - at) : 3); | |
| 72 | + if (visual) { | |
| 72 | 73 | const id = newId(tl, "b"); |
| 73 | 74 | tl.broll.push({ id, src: m.src, at: round(at), in: 0, out: round(len) }); |
| 74 | 75 | return { track: "broll", id }; |
| @@ -88,7 +89,7 @@ export function addText(tl: Timeline, at: number): string { | ||
| 88 | 89 | } |
| 89 | 90 | |
| 90 | 91 | /** Une vidéo, qu'on peut insérer dans l'A-roll (pas un son ni une image). */ |
| 91 | -export const isVideo = (m: AvailableMedia) => m.category === "broll" && /\.(mp4|mov|m4v|mkv|webm|avi|mts)$/i.test(m.src); | |
| 92 | +export const isVideo = (m: AvailableMedia) => (m.category === "broll" || m.category === "rush") && /\.(mp4|mov|m4v|mkv|webm|avi|mts)$/i.test(m.src); | |
| 92 | 93 | |
| 93 | 94 | /** Le type MIME d'un média glissé depuis l'inspecteur vers la timeline. */ |
| 94 | 95 | export const MEDIA_DRAG = "application/x-montage-media"; |
| @@ -67,8 +67,9 @@ export function insertAroll(tl: Timeline, t: number, clip: { src: string; in: nu | |||
| 67 | /** Ajoute un média au-dessus de l'A-roll à l'instant `at` : un B-roll (muet), une musique ou un son. */ | 67 | /** Ajoute un média au-dessus de l'A-roll à l'instant `at` : un B-roll (muet), une musique ou un son. */ |
| 68 | export function addMedia(tl: Timeline, m: AvailableMedia, at: number): { track: "broll" | "audio"; id: string } { | 68 | export function addMedia(tl: Timeline, m: AvailableMedia, at: number): { track: "broll" | "audio"; id: string } { |
| 69 | const total = totalOf(tl); | 69 | const total = totalOf(tl); |
| 70 | - const len = Math.min(m.duration ?? 4, m.category === "broll" ? 4 : m.category === "musique" ? Math.max(5, total - at) : 3); | 70 | + const visual = m.category === "broll" || m.category === "rush"; |
| 71 | - if (m.category === "broll") { | 71 | + const len = Math.min(m.duration ?? 4, visual ? 4 : m.category === "musique" ? Math.max(5, total - at) : 3); |
| 72 | + if (visual) { | ||
| 72 | const id = newId(tl, "b"); | 73 | const id = newId(tl, "b"); |
| 73 | tl.broll.push({ id, src: m.src, at: round(at), in: 0, out: round(len) }); | 74 | tl.broll.push({ id, src: m.src, at: round(at), in: 0, out: round(len) }); |
| 74 | return { track: "broll", id }; | 75 | return { track: "broll", id }; |
| @@ -88,7 +89,7 @@ export function addText(tl: Timeline, at: number): string { | |||
| 88 | } | 89 | } |
| 89 | 90 | ||
| 90 | /** Une vidéo, qu'on peut insérer dans l'A-roll (pas un son ni une image). */ | 91 | /** Une vidéo, qu'on peut insérer dans l'A-roll (pas un son ni une image). */ |
| 91 | -export const isVideo = (m: AvailableMedia) => m.category === "broll" && /\.(mp4|mov|m4v|mkv|webm|avi|mts)$/i.test(m.src); | 92 | +export const isVideo = (m: AvailableMedia) => (m.category === "broll" || m.category === "rush") && /\.(mp4|mov|m4v|mkv|webm|avi|mts)$/i.test(m.src); |
| 92 | 93 | ||
| 93 | /** Le type MIME d'un média glissé depuis l'inspecteur vers la timeline. */ | 94 | /** Le type MIME d'un média glissé depuis l'inspecteur vers la timeline. */ |
| 94 | export const MEDIA_DRAG = "application/x-montage-media"; | 95 | export const MEDIA_DRAG = "application/x-montage-media"; |
modified
web/src/project/steps/montage/types.ts +5 -1 | @@ -145,11 +145,15 @@ export interface KitAnimation { | ||
| 145 | 145 | |
| 146 | 146 | /** Un média utilisable : du projet (assets/…) ou de la bibliothèque (library/…). */ |
| 147 | 147 | export interface AvailableMedia { |
| 148 | - src: string; name: string; category: "broll" | "musique" | "sfx"; where: string; kind: string; | |
| 148 | + src: string; name: string; category: "rush" | "broll" | "musique" | "sfx"; where: string; kind: string; | |
| 149 | 149 | duration: number | null; url: string; |
| 150 | + /** ce que montre ou fait un fichier de la bibliothèque */ | |
| 151 | + description?: string; | |
| 150 | 152 | /** le volume auquel la bibliothèque veut ce son (dB) ; absent : le défaut du studio */ |
| 151 | 153 | gain_db?: number; |
| 152 | 154 | } |
| 155 | +/** Un onglet de la popup des médias : tous, ou une catégorie. */ | |
| 156 | +export type MediaTab = "all" | AvailableMedia["category"]; | |
| 153 | 157 | |
| 154 | 158 | /** Libellé d'une piste, dans la langue de l'interface (à appeler au rendu). */ |
| 155 | 159 | export const trackLabel = (track: Track): string => t(`editing.tracks.${track}`); |
| @@ -145,11 +145,15 @@ export interface KitAnimation { | |||
| 145 | 145 | ||
| 146 | /** Un média utilisable : du projet (assets/…) ou de la bibliothèque (library/…). */ | 146 | /** Un média utilisable : du projet (assets/…) ou de la bibliothèque (library/…). */ |
| 147 | export interface AvailableMedia { | 147 | export interface AvailableMedia { |
| 148 | - src: string; name: string; category: "broll" | "musique" | "sfx"; where: string; kind: string; | 148 | + src: string; name: string; category: "rush" | "broll" | "musique" | "sfx"; where: string; kind: string; |
| 149 | duration: number | null; url: string; | 149 | duration: number | null; url: string; |
| 150 | + /** ce que montre ou fait un fichier de la bibliothèque */ | ||
| 151 | + description?: string; | ||
| 150 | /** le volume auquel la bibliothèque veut ce son (dB) ; absent : le défaut du studio */ | 152 | /** le volume auquel la bibliothèque veut ce son (dB) ; absent : le défaut du studio */ |
| 151 | gain_db?: number; | 153 | gain_db?: number; |
| 152 | } | 154 | } |
| 155 | +/** Un onglet de la popup des médias : tous, ou une catégorie. */ | ||
| 156 | +export type MediaTab = "all" | AvailableMedia["category"]; | ||
| 153 | 157 | ||
| 154 | /** Libellé d'une piste, dans la langue de l'interface (à appeler au rendu). */ | 158 | /** Libellé d'une piste, dans la langue de l'interface (à appeler au rendu). */ |
| 155 | export const trackLabel = (track: Track): string => t(`editing.tracks.${track}`); | 159 | export const trackLabel = (track: Track): string => t(`editing.tracks.${track}`); |