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:
co-authored by
Claude Opus 4.8
parent
cffaeeec13
commit
1bf2a6eab1
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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")),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user