Movie Editor: functional media bin + shortcuts/transitions library; calmer autosave

Left panel is now tabbed bins (Media / Shortcuts / Transitions):
- Media: upload via click or drop, thumbnail grid, delete; drag an asset onto a
  timeline clip (or click with a scene selected) to set its image source.
  Backend media store (media.py: upload/list/serve/delete under MEDIA_DIR).
- Shortcuts: list/search FunPack shortcuts, add, delete, click 'insert' to append
  a trigger to the selected scene's prompt.
- Transitions: list/search, add, delete, 'apply' to the selected scene's seam.
  Library CRUD runs in-process via bridge (templates.py functions); new routes
  under /api/library/* and /api/media/*.

Autosave fix: free-text/number fields use quiet patches (state update + debounced
save, no per-keystroke re-render) so typing isn't interrupted; debounce 550→900/
1100ms; inspector preserves focus+caret across the eventual rebuild.

+test_media.py; 22 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-06 21:01:19 +03:00
co-authored by Claude Opus 4.8
parent cffaeeec13
commit 1bf2a6eab1
11 changed files with 536 additions and 38 deletions
+45
View File
@@ -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:
+2
View File
@@ -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)
+114
View File
@@ -0,0 +1,114 @@
"""Media bin: uploaded assets (images / clips) the editor can assign to scenes.
Files live under config.MEDIA_DIR as <id><ext>; 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
+16
View File
@@ -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")),
+25 -14
View File
@@ -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);
+141 -20
View File
@@ -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);
+48 -2
View File
@@ -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,
};
})();
+34
View File
@@ -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); }
+9
View File
@@ -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] || "▦"));
+69 -2
View File
@@ -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):
+33
View File
@@ -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