Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
152 lines
12 KiB
JavaScript
152 lines
12 KiB
JavaScript
// 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
|
||
},
|
||
};
|