diff --git a/movie_editor/backend/bridge.py b/movie_editor/backend/bridge.py index 9e052c5f..de7e9d71 100644 --- a/movie_editor/backend/bridge.py +++ b/movie_editor/backend/bridge.py @@ -39,6 +39,51 @@ def transitions() -> dict: return {"data": data, "transitions": transition_items(data)} +def _library_fns(): + """FunPack shortcut/transition CRUD, in-process (no HTTP).""" + try: + from templates import ( # type: ignore + shortcut_items, save_shortcut_item, delete_shortcut_item, + transition_items, save_transition_item, delete_transition_item, + ) + except ImportError: + from ...templates import ( # type: ignore + shortcut_items, save_shortcut_item, delete_shortcut_item, + transition_items, save_transition_item, delete_transition_item, + ) + return (shortcut_items, save_shortcut_item, delete_shortcut_item, + transition_items, save_transition_item, delete_transition_item) + + +def shortcuts() -> dict: + si, *_ = _library_fns() + return {"shortcuts": si()} + + +def save_shortcut(payload: dict) -> dict: + si, save, *_ = _library_fns() + save(payload) + return {"shortcuts": si()} + + +def delete_shortcut(name: str) -> dict: + si, _save, delete, *_ = _library_fns() + delete(name) + return {"shortcuts": si()} + + +def save_transition(payload: dict) -> dict: + *_, ti, save, _del = _library_fns() + save(payload) + return {"transitions": ti()} + + +def delete_transition(name: str) -> dict: + *_, ti, _save, delete = _library_fns() + delete(name) + return {"transitions": ti()} + + # ── Loopback HTTP to ComfyUI (queue + results) ─────────────────────────────── def _url(path: str) -> str: diff --git a/movie_editor/backend/config.py b/movie_editor/backend/config.py index b9d48d06..b66d3f4b 100644 --- a/movie_editor/backend/config.py +++ b/movie_editor/backend/config.py @@ -11,6 +11,7 @@ from pathlib import Path # Where the editor stores projects (JSON) and, later, cached assets/latents. DATA_DIR = Path(os.environ.get("FUNPACK_MOVIE_DATA", Path.home() / ".funpack_movie")) PROJECTS_DIR = DATA_DIR / "projects" +MEDIA_DIR = DATA_DIR / "media" # uploaded assets (images/clips) + index.json # API-format workflow template the app fills and queues (user-exported; step 0). BACKEND_DIR = Path(__file__).resolve().parent @@ -54,3 +55,4 @@ def comfy_base_url() -> str: def ensure_dirs() -> None: PROJECTS_DIR.mkdir(parents=True, exist_ok=True) + MEDIA_DIR.mkdir(parents=True, exist_ok=True) diff --git a/movie_editor/backend/media.py b/movie_editor/backend/media.py new file mode 100644 index 00000000..e113a890 --- /dev/null +++ b/movie_editor/backend/media.py @@ -0,0 +1,114 @@ +"""Media bin: uploaded assets (images / clips) the editor can assign to scenes. + +Files live under config.MEDIA_DIR as ; a sibling index.json records the +display name, kind, size and timestamp. Pure file/JSON I/O — no ComfyUI imports. +""" +from __future__ import annotations + +import json +import mimetypes +import time +import uuid +from pathlib import Path +from typing import Optional + +from . import config + +_IMAGE_EXT = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff"} +_VIDEO_EXT = {".mp4", ".webm", ".mov", ".mkv", ".avi", ".gif"} + + +def _index_path() -> Path: + return config.MEDIA_DIR / "index.json" + + +def _load_index() -> list[dict]: + p = _index_path() + if not p.exists(): + return [] + try: + data = json.loads(p.read_text()) + return data if isinstance(data, list) else [] + except (json.JSONDecodeError, OSError): + return [] + + +def _save_index(items: list[dict]) -> None: + config.ensure_dirs() + _index_path().write_text(json.dumps(items, indent=2)) + + +def _kind(ext: str) -> str: + ext = ext.lower() + if ext in _IMAGE_EXT: + return "image" + if ext in _VIDEO_EXT: + return "video" + return "other" + + +def list_media() -> list[dict]: + # newest first; drop entries whose file vanished + out = [] + for it in _load_index(): + if (config.MEDIA_DIR / it.get("filename", "")).is_file(): + out.append(it) + out.sort(key=lambda i: i.get("added", 0), reverse=True) + return out + + +def save_upload(orig_name: str, data: bytes) -> dict: + config.ensure_dirs() + ext = Path(orig_name or "").suffix or ".bin" + mid = uuid.uuid4().hex[:12] + filename = mid + ext + (config.MEDIA_DIR / filename).write_bytes(data) + entry = { + "id": mid, + "name": Path(orig_name).name or filename, + "filename": filename, + "kind": _kind(ext), + "size": len(data), + "added": time.time(), + } + items = _load_index() + items.append(entry) + _save_index(items) + return entry + + +def get(mid: str) -> Optional[dict]: + return next((i for i in _load_index() if i.get("id") == mid), None) + + +def path_for(mid: str) -> Optional[Path]: + it = get(mid) + if not it: + return None + p = config.MEDIA_DIR / it["filename"] + return p if p.is_file() else None + + +def content_type(mid: str) -> str: + it = get(mid) + if not it: + return "application/octet-stream" + return mimetypes.guess_type(it["filename"])[0] or "application/octet-stream" + + +def delete(mid: str) -> bool: + items = _load_index() + keep, removed = [], None + for it in items: + if it.get("id") == mid: + removed = it + else: + keep.append(it) + if removed is None: + return False + try: + (config.MEDIA_DIR / removed["filename"]).unlink(missing_ok=True) + except OSError: + pass + _save_index(keep) + return True diff --git a/movie_editor/frontend/api.js b/movie_editor/frontend/api.js index d411cfbf..0be261e7 100644 --- a/movie_editor/frontend/api.js +++ b/movie_editor/frontend/api.js @@ -35,6 +35,22 @@ // libraries transitions: () => j("GET", API("/library/transitions")), + saveTransition: (item) => j("POST", API("/library/transitions"), item), + deleteTransition: (name) => j("DELETE", API(`/library/transitions/${encodeURIComponent(name)}`)), + shortcuts: () => j("GET", API("/library/shortcuts")), + saveShortcut: (item) => j("POST", API("/library/shortcuts"), item), + deleteShortcut: (name) => j("DELETE", API(`/library/shortcuts/${encodeURIComponent(name)}`)), + + // media bin + listMedia: () => j("GET", API("/media")), + mediaUrl: (id) => API(`/media/${encodeURIComponent(id)}`), + deleteMedia: (id) => j("DELETE", API(`/media/${encodeURIComponent(id)}`)), + async uploadMedia(file) { + const fd = new FormData(); fd.append("file", file, file.name); + const res = await fetch(API("/media"), { method: "POST", body: fd }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail || res.statusText); + return res.json(); + }, // models / node slots nodeRoles: () => j("GET", API("/node-roles")), diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js index 9c7ce5e6..25bd7c0d 100644 --- a/movie_editor/frontend/inspector.js +++ b/movie_editor/frontend/inspector.js @@ -26,8 +26,8 @@ const l = el("label", "field"); l.append(el("span", null, labelText)); l.append(control); return l; } - function numberField(labelText, value, on) { - const i = el("input"); i.type = "number"; i.value = value; + function numberField(labelText, value, on, key) { + const i = el("input"); i.type = "number"; i.value = value; if (key) i.dataset.k = key; i.oninput = () => on(parseInt(i.value || "0", 10)); return field(labelText, i); } @@ -36,8 +36,8 @@ title.textContent = `Scene · ${st.project.scenes.indexOf(scene) + 1}`; const tag = el("div", "insp-tag"); tag.textContent = "Clip properties"; body.append(tag); - const ta = el("textarea"); ta.rows = 5; ta.value = scene.text || ""; ta.placeholder = "Describe this scene…"; - ta.oninput = () => S.patchScene(scene.id, { text: ta.value }); + const ta = el("textarea"); ta.rows = 5; ta.value = scene.text || ""; ta.placeholder = "Describe this scene…"; ta.dataset.k = "sc-text"; + ta.oninput = () => S.patchSceneQuiet(scene.id, { text: ta.value }); body.append(field("Prompt", ta)); const src = el("select"); @@ -52,8 +52,8 @@ const frames = scene.frames != null ? scene.frames : p.num_frames_per_scene; const fps = scene.fps != null ? scene.fps : p.frame_rate; const lenRow = el("div", "fields-row"); - lenRow.append(numberField("Frames", frames, (v) => S.patchScene(scene.id, { frames: S.snapFrames(v) }))); - lenRow.append(numberField("FPS", fps, (v) => S.patchScene(scene.id, { fps: Math.max(1, v) }))); + lenRow.append(numberField("Frames", frames, (v) => S.patchSceneQuiet(scene.id, { frames: S.snapFrames(v) }), "sc-frames")); + lenRow.append(numberField("FPS", fps, (v) => S.patchSceneQuiet(scene.id, { fps: Math.max(1, v) }), "sc-fps")); body.append(lenRow); body.append(el("div", "insp-hint", `Duration ≈ ${(frames / (fps || 1)).toFixed(2)}s${scene.frames == null ? " · inheriting project length" : ""}`)); @@ -74,25 +74,25 @@ title.textContent = "Project"; const tag = el("div", "insp-tag"); tag.textContent = "Global settings"; body.append(tag); - const name = el("input"); name.value = p.name || ""; - name.oninput = () => S.patchProject({ name: name.value }); + const name = el("input"); name.value = p.name || ""; name.dataset.k = "pj-name"; + name.oninput = () => S.patchProjectQuiet({ name: name.value }); body.append(field("Project name", name)); - const anchor = el("textarea"); anchor.rows = 2; anchor.value = p.anchor || ""; + const anchor = el("textarea"); anchor.rows = 2; anchor.value = p.anchor || ""; anchor.dataset.k = "pj-anchor"; anchor.placeholder = "Character / world description prepended to every scene"; - anchor.oninput = () => S.patchProject({ anchor: anchor.value }); + anchor.oninput = () => S.patchProjectQuiet({ anchor: anchor.value }); body.append(field("Anchor", anchor)); body.append(field("Opening transition (anchor → scene 1)", transitionSelect(p.intro_transition || "", (v) => S.patchProject({ intro_transition: v })))); const row1 = el("div", "fields-row"); - row1.append(numberField("Seed", p.seed, (v) => S.patchProject({ seed: v }))); - row1.append(numberField("Frames / scene", p.num_frames_per_scene, (v) => S.patchProject({ num_frames_per_scene: v }))); + row1.append(numberField("Seed", p.seed, (v) => S.patchProjectQuiet({ seed: v }), "pj-seed")); + row1.append(numberField("Frames / scene", p.num_frames_per_scene, (v) => S.patchProjectQuiet({ num_frames_per_scene: v }), "pj-frames")); body.append(row1); const row2 = el("div", "fields-row"); - row2.append(numberField("FPS", p.frame_rate, (v) => S.patchProject({ frame_rate: v }))); - row2.append(numberField("Max scenes", p.max_scenes, (v) => S.patchProject({ max_scenes: v }))); + row2.append(numberField("FPS", p.frame_rate, (v) => S.patchProjectQuiet({ frame_rate: v }), "pj-fps")); + row2.append(numberField("Max scenes", p.max_scenes, (v) => S.patchProjectQuiet({ max_scenes: v }), "pj-max")); body.append(row2); } @@ -155,12 +155,23 @@ } function render(st) { + // preserve focus + caret of a text/number field across the rebuild (autosave-safe) + const a = document.activeElement; + let foc = null; + if (a && body.contains(a) && (a.tagName === "TEXTAREA" || a.tagName === "INPUT") && a.dataset.k) + foc = { k: a.dataset.k, s: a.selectionStart, e: a.selectionEnd }; + clear(body); if (!st.project) { title.textContent = "Inspector"; body.append(el("div", "pj-meta", "No project open.")); return; } const scene = st.selectedSceneId ? S.scene(st.selectedSceneId) : null; if (scene) renderScene(st, scene); else renderProject(st); renderExposed(st); renderSplit(st); + + if (foc) { + const n = body.querySelector(`[data-k="${foc.k}"]`); + if (n) { n.focus(); try { if (foc.s != null) n.setSelectionRange(foc.s, foc.e); } catch (_) {} } + } } S.subscribe(render); diff --git a/movie_editor/frontend/mediabrowser.js b/movie_editor/frontend/mediabrowser.js index 89bd235e..16a37ab5 100644 --- a/movie_editor/frontend/mediabrowser.js +++ b/movie_editor/frontend/mediabrowser.js @@ -1,13 +1,15 @@ -// Left zone: projects list + media bin (drag-drop placeholder for Phase 3). +// Left zone: projects + tabbed library bins — Media, Shortcuts, Transitions. (function () { const { el, clear } = window.dom; const S = window.Store; + const API = window.MovieEditorAPI; const body = document.getElementById("media-body"); - function render(st) { - clear(body); + let tab = "Media"; // active bin + let q = { Shortcuts: "", Transitions: "" }; - // Projects + // ── projects ─────────────────────────────────────────────────────────────────── + function projectsSection(st) { const sec = el("div", "mb-section"); const head = el("div", "mb-section-title"); head.append(el("span", null, "Projects")); @@ -23,23 +25,142 @@ sec.append(row); }); if (!(st.projects || []).length) sec.append(el("div", "pj-meta", "No projects yet.")); - body.append(sec); + return sec; + } - // Media bin (placeholder) - const msec = el("div", "mb-section"); - const mhead = el("div", "mb-section-title"); - mhead.append(el("span", null, "Media Bin")); - mhead.append(el("span", "soon", "soon")); - msec.append(mhead); - const bin = el("div", "mediabin"); - bin.append(el("div", "big", "🎞")); - bin.append(el("div", null, "Drop images & clips here")); - bin.append(el("div", "pj-meta", "Assign to scenes as i2v anchors")); - // drag affordance (visual only for now) - ["dragenter", "dragover"].forEach((ev) => bin.addEventListener(ev, (e) => { e.preventDefault(); bin.classList.add("drag"); })); - ["dragleave", "drop"].forEach((ev) => bin.addEventListener(ev, (e) => { e.preventDefault(); bin.classList.remove("drag"); })); - msec.append(bin); - body.append(msec); + // ── media bin ────────────────────────────────────────────────────────────────── + function mediaTab(st) { + const wrap = el("div", "bin"); + const drop = el("div", "mediabin"); + drop.append(el("div", "big", "🎞")); + drop.append(el("div", null, "Drop images & clips here")); + drop.append(el("div", "pj-meta", "or click to browse · drag onto a clip to set its source")); + const file = el("input"); file.type = "file"; file.accept = "image/*,video/*"; file.multiple = true; file.style.display = "none"; + file.onchange = () => { if (file.files.length) S.uploadMedia([...file.files]); file.value = ""; }; + drop.onclick = () => file.click(); + ["dragenter", "dragover"].forEach((ev) => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add("drag"); })); + ["dragleave", "drop"].forEach((ev) => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove("drag"); })); + drop.addEventListener("drop", (e) => { const fs = [...(e.dataTransfer?.files || [])]; if (fs.length) S.uploadMedia(fs); }); + wrap.append(drop); wrap.append(file); + + const grid = el("div", "media-grid"); + (st.mediaBin || []).forEach((m) => { + const card = el("div", "media-card"); + card.draggable = true; + card.addEventListener("dragstart", (e) => { e.dataTransfer.setData("application/funpack-media", m.id); e.dataTransfer.effectAllowed = "copy"; }); + card.title = `${m.name}\nClick to assign to selected scene · drag onto a clip`; + const thumb = el("div", "media-thumb"); + if (m.kind === "image") { const img = el("img"); img.src = API.mediaUrl(m.id); img.loading = "lazy"; thumb.append(img); } + else thumb.append(el("span", "media-icon", m.kind === "video" ? "▶" : "◆")); + card.append(thumb); + card.append(el("div", "media-name", m.name)); + const del = el("button", "media-del", "✕"); del.title = "Delete asset"; + del.onclick = (e) => { e.stopPropagation(); if (confirm(`Delete "${m.name}"?`)) S.deleteMedia(m.id); }; + card.append(del); + card.onclick = () => { + if (!st.selectedSceneId) { alert("Select a scene first to assign this asset."); return; } + S.assignMediaToScene(st.selectedSceneId, m.id); + }; + grid.append(card); + }); + if (!(st.mediaBin || []).length) grid.append(el("div", "pj-meta", "No media yet.")); + wrap.append(grid); + return wrap; + } + + // ── shortcuts bin ──────────────────────────────────────────────────────────────── + function shortcutsTab(st) { + const wrap = el("div", "bin"); + wrap.append(searchRow("Shortcuts", "Filter shortcuts…", () => render(S.get()))); + const addBtn = el("button", "btn ghost tiny", "+ Add shortcut"); + addBtn.onclick = async () => { + const name = prompt("Shortcut name / activation phrase:"); if (!name) return; + const repl = prompt(`Replacement phrase for “${name}”:`, ""); if (repl == null) return; + await S.saveShortcut({ name, triggers: [name], replacements: [repl] }); + }; + wrap.append(addBtn); + + const list = el("div", "lib-list"); + const items = filtered(st.shortcuts || [], q.Shortcuts, (s) => `${s.name} ${(s.triggers || []).join(" ")} ${(s.replacements || []).join(" ")}`); + items.forEach((s) => { + const trig = (s.triggers || [])[0] || s.name; + const row = el("div", "lib-row"); + const main = el("div", "lib-main"); + main.append(el("div", "lib-name", s.name)); + const rep = (s.replacements || []).join(" / "); + if (rep) main.append(el("div", "lib-sub", "→ " + rep)); + row.append(main); + const ins = el("button", "btn ghost tiny", "insert"); ins.title = "Append to selected scene's prompt"; + ins.onclick = () => { if (!S.insertShortcutIntoSelection(trig)) alert("Select a scene first."); }; + const del = el("button", "ic-btn danger", "✕"); del.title = "Delete shortcut"; + del.onclick = () => { if (confirm(`Delete shortcut "${s.name}"?`)) S.deleteShortcut(s.name); }; + row.append(ins); row.append(del); + list.append(row); + }); + if (!items.length) list.append(el("div", "pj-meta", (st.shortcuts || []).length ? "No match." : "No shortcuts yet.")); + wrap.append(list); + return wrap; + } + + // ── transitions bin ────────────────────────────────────────────────────────────── + function transitionsTab(st) { + const wrap = el("div", "bin"); + wrap.append(searchRow("Transitions", "Filter transitions…", () => render(S.get()))); + const addBtn = el("button", "btn ghost tiny", "+ Add transition"); + addBtn.onclick = async () => { + const name = prompt("Transition trigger phrase (e.g. 'hard cut'):"); if (!name) return; + const fx = prompt("Visual effect (none, crossfade, fade_to_black, …):", "none") || "none"; + await S.saveTransition({ name, trigger: name, visual_effect: fx }); + }; + wrap.append(addBtn); + + const list = el("div", "lib-list"); + const items = filtered(st.transitions || [], q.Transitions, (t) => `${t.name || ""} ${t.trigger || ""} ${t.visual_effect || ""}`); + items.forEach((t) => { + const trig = t.trigger || t.name || t.key; + const row = el("div", "lib-row"); + const main = el("div", "lib-main"); + main.append(el("div", "lib-name", t.name || trig)); + const sub = [t.trigger && t.trigger !== (t.name || "") ? `“${t.trigger}”` : "", t.visual_effect && t.visual_effect !== "none" ? t.visual_effect : ""].filter(Boolean).join(" · "); + if (sub) main.append(el("div", "lib-sub", sub)); + row.append(main); + const apply = el("button", "btn ghost tiny", "apply"); apply.title = "Set as the selected scene's transition"; + apply.onclick = () => { if (!S.applyTransitionToSelection(trig)) alert("Select a scene first."); }; + const del = el("button", "ic-btn danger", "✕"); del.title = "Delete transition"; + del.onclick = () => { if (confirm(`Delete transition "${t.name || trig}"?`)) S.deleteTransition(t.name || trig); }; + row.append(apply); row.append(del); + list.append(row); + }); + if (!items.length) list.append(el("div", "pj-meta", (st.transitions || []).length ? "No match." : "No transitions yet.")); + wrap.append(list); + return wrap; + } + + // ── helpers ────────────────────────────────────────────────────────────────────── + function filtered(arr, query, textOf) { + const s = (query || "").trim().toLowerCase(); + return s ? arr.filter((x) => textOf(x).toLowerCase().includes(s)) : arr; + } + function searchRow(key, placeholder, onInput) { + const inp = el("input", "lib-search"); inp.type = "text"; inp.placeholder = placeholder; inp.value = q[key] || ""; + inp.oninput = () => { q[key] = inp.value; onInput(); }; + return inp; + } + + function render(st) { + clear(body); + body.append(projectsSection(st)); + + const sec = el("div", "mb-section"); + const tabs = el("div", "bin-tabs"); + ["Media", "Shortcuts", "Transitions"].forEach((name) => { + const b = el("button", "bin-tab" + (tab === name ? " active" : ""), name); + b.onclick = () => { tab = name; render(S.get()); }; + tabs.append(b); + }); + sec.append(tabs); + sec.append(tab === "Media" ? mediaTab(st) : tab === "Shortcuts" ? shortcutsTab(st) : transitionsTab(st)); + body.append(sec); } S.subscribe(render); diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 777e845a..ecdf2e83 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -12,6 +12,8 @@ gen: { state: "idle", promptId: null, media: [], msg: "" }, saving: false, models: { slots: [] }, // pluggable node config (shared with Models modal) + mediaBin: [], // uploaded assets [{id,name,kind,...}] + shortcuts: [], // prompt shortcut library }; const listeners = new Set(); @@ -54,9 +56,13 @@ function scheduleSave() { state.saving = true; notify(); clearTimeout(saveTimer); - saveTimer = setTimeout(commit, 550); + saveTimer = setTimeout(commit, 900); } + // For free-text / number fields: update state and queue a save WITHOUT re-rendering, + // so typing isn't interrupted. The eventual commit() re-renders once, after a pause. + function scheduleSaveSilent() { clearTimeout(saveTimer); saveTimer = setTimeout(commit, 1100); } + async function commit() { if (!state.project) return; try { @@ -75,6 +81,7 @@ } function patchProject(patch) { if (!state.project) return; Object.assign(state.project, patch); notify(); scheduleSave(); } + function patchProjectQuiet(patch) { if (!state.project) return; Object.assign(state.project, patch); scheduleSaveSilent(); } function scene(id) { return state.project?.scenes.find((s) => s.id === id) || null; } @@ -82,6 +89,10 @@ const s = scene(id); if (!s) return; Object.assign(s, patch); notify(); scheduleSave(); } + function patchSceneQuiet(id, patch) { + const s = scene(id); if (!s) return; + Object.assign(s, patch); scheduleSaveSilent(); + } function selectScene(id) { state.selectedSceneId = id; notify(); } @@ -216,10 +227,42 @@ catch (e) { console.error("saveModels failed", e); } } + // ── media bin + libraries ───────────────────────────────────────────────────── + async function loadMedia() { try { state.mediaBin = (await API.listMedia()).media || []; } catch (_) { state.mediaBin = []; } notify(); } + async function uploadMedia(files) { + for (const f of files) { try { await API.uploadMedia(f); } catch (e) { console.error("upload failed", e); } } + await loadMedia(); + } + async function deleteMedia(id) { try { await API.deleteMedia(id); } catch (_) {} await loadMedia(); } + + async function loadTransitions() { try { state.transitions = (await API.transitions()).transitions || []; } catch (_) {} notify(); } + async function loadShortcuts() { try { state.shortcuts = (await API.shortcuts()).shortcuts || []; } catch (_) { state.shortcuts = []; } notify(); } + async function saveShortcut(item) { try { state.shortcuts = (await API.saveShortcut(item)).shortcuts || state.shortcuts; notify(); } catch (e) { alert("Save failed: " + e.message); } } + async function deleteShortcut(name) { try { state.shortcuts = (await API.deleteShortcut(name)).shortcuts || []; notify(); } catch (e) { console.error(e); } } + async function saveTransition(item) { try { state.transitions = (await API.saveTransition(item)).transitions || state.transitions; notify(); } catch (e) { alert("Save failed: " + e.message); } } + async function deleteTransition(name) { try { state.transitions = (await API.deleteTransition(name)).transitions || []; notify(); } catch (e) { console.error(e); } } + + // Apply a library item to the selected scene. + function applyTransitionToSelection(trigger) { + const s = scene(state.selectedSceneId); if (!s) return false; + patchScene(s.id, { transition_to_next: trigger }); return true; + } + function insertShortcutIntoSelection(trigger) { + const s = scene(state.selectedSceneId); if (!s) return false; + const t = (s.text || "").trim(); + patchScene(s.id, { text: t ? `${t} ${trigger}` : trigger }); return true; + } + function assignMediaToScene(sceneId, mediaId) { + const s = scene(sceneId); if (!s) return; + patchScene(sceneId, { source: { ...(s.source || {}), type: "image", media_ref: mediaId } }); + } + // ── boot ───────────────────────────────────────────────────────────────────── async function init() { try { state.health = await API.health(); } catch (_) { state.health = { ok: false }; } try { const t = await API.transitions(); state.transitions = t.transitions || []; } catch (_) { state.transitions = []; } + await loadShortcuts(); + await loadMedia(); await loadModels(); window.addEventListener("funpack-models-changed", loadModels); await refreshProjectList(); @@ -231,8 +274,11 @@ window.Store = { get, set, subscribe, init, refreshProjectList, loadProject, newProject, deleteProject, - patchProject, patchScene, selectScene, addScene, removeScene, moveScene, scene, + patchProject, patchProjectQuiet, patchScene, patchSceneQuiet, selectScene, addScene, removeScene, moveScene, scene, resizeScene, splitScene, snapFrames, refreshPreview, generate, loadModels, setModelInput, setModelLink, + loadMedia, uploadMedia, deleteMedia, assignMediaToScene, + loadShortcuts, saveShortcut, deleteShortcut, loadTransitions, saveTransition, deleteTransition, + applyTransitionToSelection, insertShortcutIntoSelection, }; })(); diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 69c7eb17..f6cd46aa 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -176,8 +176,42 @@ input[type=number] { font-family: var(--font-mono); } } .mediabin.drag { border-color: var(--teal); background: var(--teal-soft); color: var(--teal); } .mediabin .big { font-size: 26px; opacity: .5; margin-bottom: 8px; } +.mediabin { cursor: pointer; padding: 18px 14px; } .soon { font-family: var(--font-mono); font-size: 10px; letter-spacing: 1px; color: var(--faint); border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; } +/* bin tabs (Media / Shortcuts / Transitions) */ +.bin-tabs { display: flex; gap: 2px; margin-bottom: 10px; border-bottom: 1px solid var(--line); } +.bin-tab { background: transparent; border: none; border-bottom: 2px solid transparent; color: var(--faint); + font-family: var(--font-mono); font-size: 11px; letter-spacing: .5px; padding: 6px 8px; cursor: pointer; } +.bin-tab:hover { color: var(--muted); } +.bin-tab.active { color: var(--amber); border-bottom-color: var(--amber); } +.bin { display: flex; flex-direction: column; gap: 10px; } + +/* media grid */ +.media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(76px, 1fr)); gap: 8px; } +.media-card { position: relative; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; cursor: pointer; background: var(--ink-0); } +.media-card:hover { border-color: var(--muted); } +.media-thumb { height: 56px; display: flex; align-items: center; justify-content: center; background: #060606; } +.media-thumb img { width: 100%; height: 100%; object-fit: cover; } +.media-icon { font-size: 20px; color: var(--faint); } +.media-name { font-size: 10px; color: var(--muted); padding: 3px 5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.media-del { position: absolute; top: 2px; right: 2px; width: 16px; height: 16px; padding: 0; font-size: 10px; line-height: 1; + border: none; border-radius: 4px; background: #000a; color: #fff8; cursor: pointer; opacity: 0; transition: opacity .12s; } +.media-card:hover .media-del { opacity: 1; } +.media-del:hover { color: var(--danger); } + +/* library lists (shortcuts / transitions) */ +.lib-search { width: 100%; } +.lib-list { display: flex; flex-direction: column; gap: 5px; } +.lib-row { display: flex; align-items: center; gap: 6px; padding: 6px 8px; border: 1px solid var(--line); border-radius: 7px; background: var(--ink-0); } +.lib-row:hover { border-color: var(--muted); } +.lib-main { flex: 1; min-width: 0; } +.lib-name { font-size: 12px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.lib-sub { font-size: 10.5px; color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* media drop onto a clip */ +.clip.drop-target { border-color: var(--teal); box-shadow: inset 0 0 0 2px var(--teal-soft); } + /* ───────────────────────── PREVIEW / PROGRAM MONITOR ───────────────────────── */ .program { padding: 0; background: #060606; display: flex; align-items: center; justify-content: center; position: relative; } .program::after { content: ""; position: absolute; inset: 0; pointer-events: none; box-shadow: inset 0 0 140px 10px rgba(0,0,0,.65); } diff --git a/movie_editor/frontend/timeline.js b/movie_editor/frontend/timeline.js index 9cbb926b..fb01ba71 100644 --- a/movie_editor/frontend/timeline.js +++ b/movie_editor/frontend/timeline.js @@ -68,6 +68,15 @@ clip.style.width = Math.max(widthPx, 8) + "px"; clip.onclick = () => S.selectScene(scene.id); + // accept a media asset dragged from the bin → sets this clip's source + clip.addEventListener("dragover", (e) => { if (e.dataTransfer.types.includes("application/funpack-media")) { e.preventDefault(); clip.classList.add("drop-target"); } }); + clip.addEventListener("dragleave", () => clip.classList.remove("drop-target")); + clip.addEventListener("drop", (e) => { + const id = e.dataTransfer.getData("application/funpack-media"); + clip.classList.remove("drop-target"); + if (id) { e.preventDefault(); S.assignMediaToScene(scene.id, id); } + }); + const head = el("div", "clip-head"); head.append(el("span", "clip-no", p2(index + 1))); head.append(el("span", "clip-src", SRC_ICON[scene.source?.type] || "▦")); diff --git a/movie_editor/server.py b/movie_editor/server.py index 6f69ae98..536bfe20 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -19,7 +19,7 @@ except Exception: # pragma: no cover - only available inside ComfyUI web = None PromptServer = None -from .backend import bridge, builder, config, nodes, projects +from .backend import bridge, builder, config, media, nodes, projects from .backend.timeline import Project, build_combined_prompt UI_PREFIX = "/funpack/movie" @@ -179,7 +179,7 @@ if web is not None and PromptServer is not None: result["parse_error"] = str(e) return web.json_response(result) - # --- API: library --- + # --- API: library (shortcuts + transitions, FunPack in-process) --- @routes.get(UI_PREFIX + "/api/library/transitions") async def _transitions(_req): try: @@ -187,6 +187,73 @@ if web is not None and PromptServer is not None: except Exception as e: # noqa: BLE001 return web.json_response({"transitions": [], "error": str(e)}) + @routes.post(UI_PREFIX + "/api/library/transitions") + async def _transition_save(req): + try: + return web.json_response(bridge.save_transition(await req.json())) + except Exception as e: # noqa: BLE001 + raise web.HTTPBadRequest(reason=str(e)) + + @routes.delete(UI_PREFIX + "/api/library/transitions/{name}") + async def _transition_delete(req): + try: + return web.json_response(bridge.delete_transition(req.match_info["name"])) + except Exception as e: # noqa: BLE001 + raise web.HTTPBadRequest(reason=str(e)) + + @routes.get(UI_PREFIX + "/api/library/shortcuts") + async def _shortcuts(_req): + try: + return web.json_response(bridge.shortcuts()) + except Exception as e: # noqa: BLE001 + return web.json_response({"shortcuts": [], "error": str(e)}) + + @routes.post(UI_PREFIX + "/api/library/shortcuts") + async def _shortcut_save(req): + try: + return web.json_response(bridge.save_shortcut(await req.json())) + except Exception as e: # noqa: BLE001 + raise web.HTTPBadRequest(reason=str(e)) + + @routes.delete(UI_PREFIX + "/api/library/shortcuts/{name}") + async def _shortcut_delete(req): + try: + return web.json_response(bridge.delete_shortcut(req.match_info["name"])) + except Exception as e: # noqa: BLE001 + raise web.HTTPBadRequest(reason=str(e)) + + # --- API: media bin --- + @routes.get(UI_PREFIX + "/api/media") + async def _media_list(_req): + return web.json_response({"media": media.list_media()}) + + @routes.post(UI_PREFIX + "/api/media") + async def _media_upload(req): + reader = await req.multipart() + saved = [] + async for part in reader: + if part.name not in ("file", "files"): + continue + data = await part.read(decode=False) + if data: + saved.append(media.save_upload(part.filename or "upload.bin", data)) + if not saved: + raise web.HTTPBadRequest(reason="No file in upload.") + return web.json_response({"media": saved}) + + @routes.get(UI_PREFIX + "/api/media/{mid}") + async def _media_get(req): + p = media.path_for(req.match_info["mid"]) + if p is None: + raise web.HTTPNotFound() + return web.Response(body=p.read_bytes(), content_type=media.content_type(req.match_info["mid"])) + + @routes.delete(UI_PREFIX + "/api/media/{mid}") + async def _media_delete(req): + if not media.delete(req.match_info["mid"]): + raise web.HTTPNotFound() + return web.json_response({"deleted": req.match_info["mid"]}) + # --- API: generate / status / result --- @routes.post(UI_PREFIX + "/api/projects/{pid}/generate") async def _generate(req): diff --git a/movie_editor/tests/test_media.py b/movie_editor/tests/test_media.py new file mode 100644 index 00000000..7d3114b5 --- /dev/null +++ b/movie_editor/tests/test_media.py @@ -0,0 +1,33 @@ +"""Tests for the media bin store (upload/list/delete round-trip).""" +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from movie_editor.backend import config, media # noqa: E402 + + +def test_media_roundtrip(tmp_path, monkeypatch): + monkeypatch.setattr(config, "DATA_DIR", tmp_path) + monkeypatch.setattr(config, "MEDIA_DIR", tmp_path / "media") + assert media.list_media() == [] + + entry = media.save_upload("shot one.png", b"\x89PNG\r\n\x1a\nfake") + assert entry["kind"] == "image" + assert entry["name"] == "shot one.png" + listed = media.list_media() + assert len(listed) == 1 and listed[0]["id"] == entry["id"] + + p = media.path_for(entry["id"]) + assert p is not None and p.read_bytes().startswith(b"\x89PNG") + assert media.content_type(entry["id"]).startswith("image/") + + vid = media.save_upload("clip.mp4", b"\x00\x00\x00") + assert vid["kind"] == "video" + assert len(media.list_media()) == 2 + + assert media.delete(entry["id"]) is True + assert media.path_for(entry["id"]) is None + assert [m["id"] for m in media.list_media()] == [vid["id"]] + assert media.delete("nope") is False