hlassiege/darylpublic Fork
⑂ ef2bf51
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): 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.
hlassiege committed 2026-10-11T17:18:34+02:00 Browse files
ef2bf51 parent: 2932ab7
modified backend/studio/modules/projects/montage.py +15 -1
@@ -1076,9 +1076,15 @@ MEDIA_CATEGORIES = ("broll", "musique", "sfx")
10761076
10771077
10781078 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."""
10801080 base = service.project_dir(pid)
10811081 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()}"})
10821088 for category in MEDIA_CATEGORIES:
10831089 for path in library._files(base / service.ASSETS_DIR / category):
10841090 if path.suffix.lower() in library.TIMED:
@@ -1091,6 +1097,14 @@ def available(pid: str) -> list[dict]:
10911097 items.append({"src": path.relative_to(base).as_posix(), "name": path.name, "category": "broll",
10921098 "where": "projet", "kind": "Vidéo", "duration": library.duration(path),
10931099 "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()}"})
10941108 described = library.descriptions()
10951109 for category in MEDIA_CATEGORIES:
10961110 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 {
5656 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.",
5757 insertAroll: "→ A-roll",
5858 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",
6059 scale: "Scale",
6160 horizontal: "Horizontal",
6261 vertical: "Vertical",
@@ -101,12 +100,24 @@ export default {
101100 addBeat: "+ Beat",
102101 addBeatTitle: "A beat on this passage (M): write its brief on the slide, drag its edges on the timeline, then create the animation.",
103102 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",
104114 tabBroll: "B-roll",
105115 tabSfx: "Sounds",
106116 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.",
110121 },
111122 anims: {
112123 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 {
5656 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.",
5757 insertAroll: "→ A-roll",
5858 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",
6059 scale: "Échelle",
6160 horizontal: "Horizontal",
6261 vertical: "Vertical",
@@ -101,12 +100,24 @@ export default {
101100 addBeat: "+ Beat",
102101 addBeatTitle: "Un beat sur ce passage (M) : écris sa consigne sur la slide, tire ses bords sur la timeline, puis crée l’animation.",
103102 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",
104114 tabBroll: "B-roll",
105115 tabSfx: "Sons",
106116 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.",
110121 },
111122 anims: {
112123 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 @@
11 <script setup lang="ts">
22 /**
33 * À 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).
66 */
77 import { computed, ref } from "vue";
88 import { useI18n } from "vue-i18n";
@@ -12,10 +12,11 @@ import type { Fx } from "../../../lib/transitions";
1212 import { post } from "../../../lib/api";
1313 import { duration as fmtDur } from "../../../lib/format";
1414 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";
1617 import {
1718 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,
1920 } from "./types";
2021
2122 const props = defineProps<{
@@ -89,13 +90,10 @@ const motionState = computed(() => (item.value?.track === "motion" ? props.view.
8990
9091 // --- ajouter au curseur
9192 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);
9997
10098 function addZoom(scale: number) {
10199 let id = "";
@@ -114,10 +112,6 @@ function addMedia(m: AvailableMedia) {
114112 emit("edit", (tl) => { added = addMediaTo(tl, m, at.value); });
115113 if (added) sel.value = { item: added };
116114 }
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-}
121115 function addChapter() {
122116 let id = "";
123117 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
306300 <button class="btn sm" :title="tr('editing.inspector.addBeatTitle')" @click="emit('beat')">{{ tr("editing.inspector.addBeat") }}</button>
307301 <button class="btn sm" @click="addChapter">{{ tr("editing.inspector.addChapter") }}</button>
308302 </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>
324305 </div>
325306 </fieldset>
326307 <p class="hint">{{ tr("editing.inspector.motionHint") }}</p>
327308 </template>
309+ <MediaPicker v-model="picker" :available="available" :t="t" @add="addMedia" @insert="(m) => emit('insert', m)" />
328310 </div>
329311 </template>
330312
@@ -353,15 +335,4 @@ fieldset { border: none; margin: 0; padding: 0; display: flex; flex-direction: c
353335 .chips { display: flex; flex-wrap: wrap; gap: 6px; }
354336 .chip { border: 1px solid var(--line-strong); border-radius: 3px; background: #fff; padding: 5px 9px; font-size: 12.5px; cursor: pointer; }
355337 .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); }
367338 </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ée3 * À 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 aussi4+ * (à 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 curseur91 // --- 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
6767 /** Ajoute un média au-dessus de l'A-roll à l'instant `at` : un B-roll (muet), une musique ou un son. */
6868 export function addMedia(tl: Timeline, m: AvailableMedia, at: number): { track: "broll" | "audio"; id: string } {
6969 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) {
7273 const id = newId(tl, "b");
7374 tl.broll.push({ id, src: m.src, at: round(at), in: 0, out: round(len) });
7475 return { track: "broll", id };
@@ -88,7 +89,7 @@ export function addText(tl: Timeline, at: number): string {
8889 }
8990
9091 /** 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);
9293
9394 /** Le type MIME d'un média glissé depuis l'inspecteur vers la timeline. */
9495 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 {
145145
146146 /** Un média utilisable : du projet (assets/…) ou de la bibliothèque (library/…). */
147147 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;
149149 duration: number | null; url: string;
150+ /** ce que montre ou fait un fichier de la bibliothèque */
151+ description?: string;
150152 /** le volume auquel la bibliothèque veut ce son (dB) ; absent : le défaut du studio */
151153 gain_db?: number;
152154 }
155+/** Un onglet de la popup des médias : tous, ou une catégorie. */
156+export type MediaTab = "all" | AvailableMedia["category"];
153157
154158 /** Libellé d'une piste, dans la langue de l'interface (à appeler au rendu). */
155159 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}`);