// The media bin: files brought in (reference images, clips), kept apart from what runs produce. import { composer as c } from "../../composer/composer.js"; import { learn } from "../../shell/bin.js"; import { referenceNumbers, sceneReferences } from "../../shell/reference_wiring.js"; import { MEDIA_DRAG } from "../../shell/dnd.js"; const base = (id) => `/funpack/api/media/${encodeURIComponent(id)}`; export default { id: "media", mount: "assets.media", needs: ["project", "api"], setup({ host, app }) { const p = app.project, api = app.api; let items = []; const tell = (text) => c.toast.warn({ text }); const toggle = (id) => { if (chosen.has(id)) chosen.delete(id); else chosen.add(id); draw(true); }; const FILTERS = [{ value: "all", label: "All" }, { value: "video", label: "Video" }, { value: "audio", label: "Audio" }, { value: "image", label: "Images" }]; const SORTS = [{ value: "name", label: "Name A-Z" }, { value: "name-", label: "Name Z-A" }, { value: "kind", label: "Type" }, { value: "added", label: "Date added" }]; const VIEWS = [{ value: "adaptive", label: "Grid" }, { value: "list", label: "List" }, { value: "icons", label: "Icons" }]; const DENSITY = [{ value: "0", label: "Auto" }, ...[1, 2, 3, 4].map((n) => ({ value: String(n), label: `${n}×` }))]; const show = { filter: "all", sort: "name", view: "adaptive", cols: "0" }; let selecting = false; const chosen = new Set(); const peek = (it) => { const item = items.find((m) => m.id === it.id); if (!item) return; app.mediaPeek.item = item; app.say("media.peek"); }; // a click looks at it on the monitor const pick = (it) => { // an image becomes the selected scene's aspect ratio source const sc = p.selected, item = items.find((m) => m.id === it.id); if (!sc || !item) return tell("Select a scene first."); if (item.kind !== "image") return tell("Only an image can be a scene's aspect ratio source."); p.setScene(sc.id, "source_image", item.id); draw(); }; /** The slot number this picture holds in the selected scene, and whether it is pinned there: null when it is not one of its references. */ const slotOf = (id) => { const sc = p.selected; if (!sc) return null; const plan = sceneReferences(sc, (p.project || {}).fixed_references), i = plan.ids.indexOf(id); return i < 0 ? null : { n: referenceNumbers(plan.ids, plan.pins)[i], pinned: id in plan.pins }; }; const pinItem = (it) => { const s = slotOf(it.id); return s && s.n <= 16 ? [{ id: "pinref", label: s.pinned ? `Unpin reference from slot R${s.n}` : `Pin reference to slot R${s.n}` }] : []; }; const extras = (it) => (app.mediaMenu || []).flatMap((f) => { try { return f(it, items.find((m) => m.id === it.id)) || []; } catch { return []; } }); // entries other features add to a tile's menu: { id, label, run } const context = (it, e) => c.menu.context({ x: e.clientX, y: e.clientY, onPick: (id) => (act[id] ? act[id](it) : extras(it).find((x) => x.id === id)?.run()), items: [{ id: "look", label: "Look at it on the monitor" }, ...(p.selected && p.selected.source_image === it.id ? [{ id: "unres", label: "Stop using as the selected scene's aspect ratio source" }] : [{ id: "res", label: "Use as the selected scene's aspect ratio source", disabled: !p.selected || it.kind === "audio" || !items.some((m) => m.id === it.id && m.kind === "image") }]), p.selected && (p.selected.references || []).includes(it.id) ? { id: "unref", label: "Stop using as a reference for the selected scene" } : { id: "ref", label: "Use as a reference for the selected scene", disabled: !p.selected || !!(slotOf(it.id) || {}).pinned }, ...pinItem(it), { id: "rename", label: "Rename…" }, { id: "export", label: "Save to your computer", disabled: !items.some((m) => m.id === it.id && m.kind !== "audio") }, ...extras(it).map(({ id, label, disabled }) => ({ id, label, disabled })), { separator: true }, { id: "delete", label: "Delete from the bin", danger: true }] }); /** Delete these from the bin, and nothing in this project may keep pointing at them. */ async function gone(ids) { const dead = new Set(); try { for (const id of ids) { await api.deleteMedia(id); dead.add(id); } } catch (err) { tell(`${err.message}${dead.size ? ` (${dead.size} of ${ids.length} were deleted)` : ""}`); } try { p.edit((pr) => { const fx = pr.fixed_references || {}, stale = Object.keys(fx).filter((id) => dead.has(id)); if (stale.length) pr.fixed_references = Object.fromEntries(Object.entries(fx).filter(([id]) => !dead.has(id))); return stale.length > 0 | pr.scenes.reduce((hit, s) => { const had = dead.has(s.source_image) || (s.references || []).some((r) => dead.has(r)); if (dead.has(s.source_image)) s.source_image = ""; if (had) s.references = (s.references || []).filter((r) => !dead.has(r)); return hit || had; }, false); }); ids.forEach((id) => chosen.delete(id)); } catch (err) { tell(err.message); } await refresh(); } const act = { look: (it) => peek(it), res: (it) => pick(it), ref: (it) => p.setScene(p.selected.id, "references", [...(p.selected.references || []), it.id]), pinref: (it) => { // pin at the number it holds now, for every scene, or take the pin off const s = slotOf(it.id); if (s) p.edit((pr) => { const next = { ...(pr.fixed_references || {}) }; if (s.pinned) delete next[it.id]; else next[it.id] = s.n; pr.fixed_references = next; return true; }); }, unref: (it) => p.setScene(p.selected.id, "references", (p.selected.references || []).filter((r) => r !== it.id)), unres: () => p.setScene(p.selected.id, "source_image", ""), rename: async (it) => { const name = await c.modal.prompt({ title: "Rename media", label: "Name", value: (items.find((m) => m.id === it.id) || {}).name || "", confirmLabel: "Rename" }).result; if (!name || !name.trim()) return; try { await api.renameMedia(it.id, name.trim()); await refresh(); } catch (err) { tell(err.message); } }, export: (it) => { const m = items.find((x) => x.id === it.id); Object.assign(document.createElement("a"), { href: `${base(it.id)}/file`, download: m ? m.name : it.id }).click(); }, delete: async (it) => { const name = (items.find((m) => m.id === it.id) || {}).name || "this file"; if (!(await c.modal.dialogue({ title: "Delete media", message: `Delete “${name}” from the bin for good? Scenes in other projects that use it will lose it too.`, tone: "danger", confirmLabel: "Delete" }).result)) return; await gone([it.id]); }, }; const props = { id: "media", items: [], empty: "No media yet. Drop images or clips here.", onActivate: (it) => (selecting ? toggle(it.id) : peek(it)), onContext: context, drag: { type: MEDIA_DRAG, data: (it) => ({ id: it.id, kind: (items.find((m) => m.id === it.id) || {}).kind }) } }; const galleries = { adaptive: c.gallery.adaptive(props), list: c.gallery.list(props), icons: c.gallery.icons(props) }; const shelf = c.region.stack({ gap: "none", children: [galleries.adaptive] }); const note = c.text.sm({ text: "" }); const drop = c.dropzone.default({ label: "Drop or choose files", hint: "images, clips, audio", onFiles: async (files) => { try { const r = await api.uploadMedia(files, (i, n, name) => note.setText(`Uploading ${i + 1}/${n}: ${name}`)); (r.problems || []).forEach(tell); await refresh(); } catch (err) { tell(err.message); } note.setText(""); } }); const seg = (label, options, key) => c.segmented.sm({ label, options, value: show[key], onChange: (v) => { show[key] = v; draw(true); } }); const tileSize = seg("Tile size", DENSITY, "cols"); // only the grid has columns const sort = c.select.sm({ label: "Sort", options: SORTS, value: show.sort, onChange: (v) => { show.sort = v; draw(true); } }); const selectBtn = c.button.sm({ label: "Select", tone: "ghost", title: "Pick several files, then delete them together", onClick: () => { selecting = !selecting; chosen.clear(); draw(true); } }); const bulk = c.button.sm({ label: "Delete (0)", tone: "danger", onClick: async () => { const ids = [...chosen]; if (!ids.length || !(await c.modal.dialogue({ title: "Delete media", message: `Delete ${ids.length} file${ids.length > 1 ? "s" : ""} from the bin for good? Scenes in other projects that use them will lose them too.`, tone: "danger", confirmLabel: "Delete" }).result)) return; await gone(ids); } }); bulk.node.hidden = true; const clearRefs = c.button.sm({ label: "Clear references", tone: "ghost", title: "Stop using every reference picture in the selected scene", onClick: () => p.selected && p.setScene(p.selected.id, "references", []) }); host.append(c.toolbar.default({ items: [c.text.sm({ text: "Media" })], trailing: [clearRefs, selectBtn, bulk] }).node, drop.node, note.node, seg("Show", FILTERS, "filter").node, sort.node, seg("View", VIEWS, "view").node, tileSize.node, shelf.node); let drawn = ""; function draw(force) { const sc = p.selected, plan = sc ? sceneReferences(sc, (p.project || {}).fixed_references) : { ids: [], pins: {} }, refs = plan.ids, nums = referenceNumbers(refs, plan.pins); const key = `${(sc || {}).source_image}|${refs}|${nums}|${JSON.stringify(plan.pins)}|${items.map((m) => m.id)}|${selecting}|${[...chosen]}|${show.filter}`; if (!force && key === drawn) return; // typing elsewhere must not rebuild the thumbnails drawn = key; const visible = new Set(items.filter((m) => show.filter === "all" || m.kind === show.filter).map((m) => m.id)); for (const id of [...chosen]) if (!visible.has(id)) chosen.delete(id); // a file out of sight is not picked const rev = show.sort.endsWith("-") ? -1 : 1, by = show.sort.replace("-", ""); const shown = items.filter((m) => show.filter === "all" || m.kind === show.filter) .sort((x, y) => rev * (by === "added" ? (y.added || 0) - (x.added || 0) : String(x[by === "kind" ? "kind" : "name"]).localeCompare(String(y[by === "kind" ? "kind" : "name"])))); const g = galleries[show.view]; shelf.set([g]); if (g.setCols) g.setCols(Number(show.cols)); tileSize.node.hidden = !g.setCols; // what the selected scene uses is marked on its tile, so a reference can be told from the rest const flags = (m) => [sc && sc.source_image === m.id ? { text: "◐", title: "The selected scene's aspect ratio source" } : null, refs.includes(m.id) ? { text: `${plan.pins[m.id] ? "📌" : ""}R${nums[refs.indexOf(m.id)]}`, title: `Reference slot ${nums[refs.indexOf(m.id)]} for the selected scene${plan.pins[m.id] ? ", pinned to every scene" : ""}` } : null].filter(Boolean); g.setItems(shown.map((m) => ({ id: m.id, label: m.name, thumb: m.kind === "audio" ? undefined : `${base(m.id)}/thumb`, badge: m.kind, flags: flags(m) }))); g.setValue(selecting ? [...chosen] : sc ? [sc.source_image, ...refs].filter(Boolean) : []); selectBtn.setLabel(selecting ? "Done" : "Select"); bulk.node.hidden = !selecting; bulk.setLabel(`Delete (${chosen.size})`); bulk.setDisabled(!chosen.size); clearRefs.node.hidden = !refs.length; clearRefs.setLabel(`Clear references (${refs.length})`); } async function refresh() { try { items = (await api.media()).media || []; learn(items); app.say("bin"); } catch (err) { tell(err.message); } draw(true); } refresh(); return app.on((what) => (what === "media" ? refresh() : draw())); // "media": another feature put something in the bin }, };