diff --git a/movie_editor/backend/characters.py b/movie_editor/backend/characters.py new file mode 100644 index 00000000..98de7b48 --- /dev/null +++ b/movie_editor/backend/characters.py @@ -0,0 +1,94 @@ +"""Global character library for the Movie Editor (stored under FUNPACK_MOVIE_DATA).""" +from __future__ import annotations + +import json +import time +import uuid +from pathlib import Path +from typing import Any, Optional + +from . import config + +CHARACTERS_PATH = config.DATA_DIR / "characters.json" + + +def normalize_character_record(raw: Optional[dict]) -> dict[str, Any]: + raw = raw or {} + return { + "name": str(raw.get("name", "")).strip(), + "appearance": str(raw.get("appearance", "")).strip(), + "body": str(raw.get("body", "")).strip(), + "wardrobe": str(raw.get("wardrobe", "")).strip(), + "always_include": str(raw.get("always_include", "")).strip(), + "never_include": str(raw.get("never_include", "")).strip(), + "face_ref": raw.get("face_ref") or None, + "body_ref": raw.get("body_ref") or None, + "detail_ref": raw.get("detail_ref") or None, + } + + +def _ensure_db() -> dict: + config.ensure_dirs() + if not CHARACTERS_PATH.exists(): + CHARACTERS_PATH.write_text(json.dumps({"characters": {}}, indent=2), encoding="utf-8") + data = json.loads(CHARACTERS_PATH.read_text(encoding="utf-8")) + if not isinstance(data, dict): + data = {"characters": {}} + data.setdefault("characters", {}) + return data + + +def _write_db(data: dict) -> None: + config.ensure_dirs() + CHARACTERS_PATH.write_text(json.dumps(data, indent=2), encoding="utf-8") + + +def _new_id() -> str: + return uuid.uuid4().hex[:12] + + +def character_items(data: Optional[dict] = None) -> list[dict]: + db = data or _ensure_db() + items = [] + for cid, raw in (db.get("characters") or {}).items(): + if not isinstance(raw, dict): + continue + item = dict(raw) + item["id"] = str(item.get("id") or cid) + items.append(item) + items.sort(key=lambda c: (c.get("name") or "").lower()) + return items + + +def load_character_map() -> dict[str, dict]: + return {c["id"]: c for c in character_items()} + + +def list_characters() -> dict: + return {"characters": character_items()} + + +def save_character(payload: dict) -> dict: + db = _ensure_db() + chars = db.setdefault("characters", {}) + original_id = str(payload.get("original_id") or "").strip() + cid = str(payload.get("id") or original_id or _new_id()) + if original_id and original_id != cid and original_id in chars: + del chars[original_id] + now = time.time() + prev = chars.get(cid) or {} + item = normalize_character_record(payload) + item["id"] = cid + item["created_at"] = float(prev.get("created_at", now)) + item["updated_at"] = now + chars[cid] = item + _write_db(db) + return {"characters": character_items(db)} + + +def delete_character(char_id: str) -> dict: + db = _ensure_db() + chars = db.setdefault("characters", {}) + chars.pop(str(char_id), None) + _write_db(db) + return {"characters": character_items(db)} \ No newline at end of file diff --git a/movie_editor/backend/timeline.py b/movie_editor/backend/timeline.py index 586f2b04..203ed701 100644 --- a/movie_editor/backend/timeline.py +++ b/movie_editor/backend/timeline.py @@ -59,47 +59,125 @@ def normalize_character_bible(raw: Optional[dict]) -> dict[str, Any]: def build_character_bible_anchor(bible: Optional[dict]) -> str: - """Render bible fields into anchor prose prepended before the manual anchor.""" - b = normalize_character_bible(bible) + """Render one character profile into anchor prose.""" + return build_characters_anchor([bible] if bible else []) + + +def build_characters_anchor(characters: list[Optional[dict]]) -> str: + """Merge multiple character profiles into one anchor block.""" + blocks: list[str] = [] + for raw in characters: + b = normalize_character_bible(raw) + if not any(b[k] for k in ("name", "appearance", "body", "wardrobe", "always_include")): + continue + parts: list[str] = [] + if b["name"]: + parts.append(f"Character: {b['name']}.") + if b["appearance"]: + parts.append(f"Appearance: {b['appearance']}.") + if b["body"]: + parts.append(f"Body: {b['body']}.") + if b["wardrobe"]: + parts.append(f"Wardrobe: {b['wardrobe']}.") + if b["always_include"]: + text = b["always_include"].rstrip(".") + parts.append(text + ".") + blocks.append(" ".join(parts).strip()) + return " ".join(blocks).strip() + + +def _load_character_map() -> dict[str, dict]: + try: + from .characters import load_character_map + except ImportError: + from characters import load_character_map # type: ignore + return load_character_map() + + +def _project_uses_scene_characters(project: Project) -> bool: + return any((s.character_ids or []) for s in project.scenes) + + +def _characters_for_scene(scene: Scene, char_map: dict[str, dict]) -> list[dict]: + return [char_map[cid] for cid in (scene.character_ids or []) if cid in char_map] + + +def _scene_character_prefix(scene: Scene, char_map: dict[str, dict]) -> str: + return build_characters_anchor(_characters_for_scene(scene, char_map)) + + +def effective_anchor(project: Project, char_map: Optional[dict[str, dict]] = None) -> str: + """Manual project anchor + legacy project.character_bible when no per-scene characters.""" + char_map = char_map if char_map is not None else _load_character_map() parts: list[str] = [] - if b["name"]: - parts.append(f"Character: {b['name']}.") - if b["appearance"]: - parts.append(f"Appearance: {b['appearance']}.") - if b["body"]: - parts.append(f"Body: {b['body']}.") - if b["wardrobe"]: - parts.append(f"Wardrobe: {b['wardrobe']}.") - if b["always_include"]: - text = b["always_include"].rstrip(".") - parts.append(text + ".") + if not _project_uses_scene_characters(project): + legacy = build_character_bible_anchor(project.character_bible) + if legacy: + parts.append(legacy) + manual = (project.anchor or "").strip() + if manual: + parts.append(manual) return " ".join(parts).strip() -def effective_anchor(project: Project) -> str: - """Character bible + manual anchor (project.anchor) sent to Studio.""" - bible_text = build_character_bible_anchor(project.character_bible) - manual = (project.anchor or "").strip() - if bible_text and manual: - return f"{bible_text} {manual}" - return bible_text or manual - - -def effective_negative_prompt(project: Project) -> str: - """Project negative prompt plus bible never_include (generation only).""" +def effective_negative_prompt(project: Project, char_map: Optional[dict[str, dict]] = None) -> str: + """Project negative plus never_include from scene characters (and legacy bible).""" + char_map = char_map if char_map is not None else _load_character_map() manual = (project.negative_prompt or "").strip() - never = normalize_character_bible(project.character_bible)["never_include"] + never_parts: list[str] = [] + seen: set[str] = set() + if not _project_uses_scene_characters(project): + leg = normalize_character_bible(project.character_bible)["never_include"] + if leg: + never_parts.append(leg) + for scene in project.scenes: + if scene.excluded: + continue + for cid in (scene.character_ids or []): + if cid in seen: + continue + seen.add(cid) + c = char_map.get(cid) + if not c: + continue + never = normalize_character_bible(c)["never_include"] + if never: + never_parts.append(never) + never = ", ".join(never_parts).strip() if never and manual: return f"{manual}, {never}" return never or manual -def resolve_identity_pin_ref(project: Project) -> Optional[str]: - """Face reference from the bible wins when sync_identity_pin is on.""" - b = normalize_character_bible(project.character_bible) - if b["sync_identity_pin"] and b["face_ref"]: - return b["face_ref"] - return normalize_continuity_settings(project.continuity_settings).get("identity_pin_ref") +def resolve_scene_identity_pin( + scene: Scene, + char_map: dict[str, dict], + cs: dict[str, Any], +) -> Optional[str]: + """First assigned character with a face ref drives the pin for this scene.""" + for cid in (scene.character_ids or []): + c = char_map.get(cid) + if c and c.get("face_ref"): + return c["face_ref"] + return cs.get("identity_pin_ref") + + +def character_media_refs_for_project(project: Project, char_map: Optional[dict[str, dict]] = None) -> list[str]: + char_map = char_map if char_map is not None else _load_character_map() + refs: list[str] = [] + if not _project_uses_scene_characters(project): + refs.extend(character_bible_media_refs(project)) + seen: set[str] = set() + for scene in project.scenes: + if scene.excluded: + continue + for c in _characters_for_scene(scene, char_map): + for key in ("face_ref", "body_ref", "detail_ref"): + ref = c.get(key) + if ref and ref not in seen: + seen.add(ref) + refs.append(ref) + return refs def character_bible_media_refs(project: Project) -> list[str]: @@ -113,12 +191,8 @@ def character_bible_media_refs(project: Project) -> list[str]: def continuity_settings_for_run(project: Project) -> dict[str, Any]: - """Continuity knobs with bible-driven identity pin applied.""" - cs = normalize_continuity_settings(project.continuity_settings) - pin = resolve_identity_pin_ref(project) - if pin: - cs = {**cs, "identity_pin_ref": pin} - return cs + """Continuity knobs (per-scene identity pins are resolved in auto guides).""" + return normalize_continuity_settings(project.continuity_settings) def normalize_continuity_settings(raw: Optional[dict]) -> dict[str, Any]: @@ -193,11 +267,15 @@ def build_auto_continuity_guides(full: Project, target: Project) -> Optional[dic if not active_target: return None - pin = cs["identity_pin_ref"] + char_map = _load_character_map() decay = cs["guide_decay"] - def _entries_for_index(full_idx: int, chain_idx: int) -> list[dict]: + def _pin_for(sc: Scene) -> Optional[str]: + return resolve_scene_identity_pin(sc, char_map, cs) + + def _entries_for_index(full_idx: int, chain_idx: int, scene: Scene) -> list[dict]: entries: list[dict] = [] + pin = _pin_for(scene) if pin: entries.append(_identity_pin_guide(pin, cs["identity_pin_strength"])) if full_idx > 0 and cs["prior_scene_guides"]: @@ -213,12 +291,13 @@ def build_auto_continuity_guides(full: Project, target: Project) -> Optional[dic if len(active_target) == 1: sc = active_target[0] full_idx = next((i for i, s in enumerate(active_full) if s.id == sc.id), 0) + pin = _pin_for(sc) if full_idx <= 0 and not pin: return None wants_prior = cs["solo_scene_guides"] and solo_run_wants_prior_guides(sc) if full_idx > 0 and not wants_prior and not pin: return None - entries = _entries_for_index(full_idx, 0) if (full_idx > 0 and wants_prior) else [] + entries = _entries_for_index(full_idx, 0, sc) if (full_idx > 0 and wants_prior) else [] if pin and not any(e.get("media_ref") == pin for e in entries): entries.insert(0, _identity_pin_guide(pin, cs["identity_pin_strength"])) if not entries: @@ -227,16 +306,20 @@ def build_auto_continuity_guides(full: Project, target: Project) -> Optional[dic # Multi-scene carry chain. per_scene: list[Optional[list[dict]]] = [] + any_pin = False for chain_idx, sc in enumerate(active_target): full_idx = next((i for i, s in enumerate(active_full) if s.id == sc.id), chain_idx) + pin = _pin_for(sc) + if pin: + any_pin = True if chain_idx == 0: entries = [_identity_pin_guide(pin, cs["identity_pin_strength"])] if pin else [] per_scene.append(entries if entries else None) continue - entries = _entries_for_index(full_idx, chain_idx) + entries = _entries_for_index(full_idx, chain_idx, sc) per_scene.append(entries) - if not pin and all(e is None or not e for e in per_scene[1:]): + if not any_pin and all(e is None or not e for e in per_scene[1:]): return None return {"stack_enabled": True, "accumulate_prior": False, "scenes": per_scene} @@ -244,9 +327,16 @@ def build_auto_continuity_guides(full: Project, target: Project) -> Optional[dic def continuity_media_refs(full: Project, target: Project) -> list[str]: """Extra media-bin ids to copy for auto continuity (pin + prior anchors).""" cs = continuity_settings_for_run(full) - refs: list[str] = list(character_bible_media_refs(full)) + char_map = _load_character_map() + refs: list[str] = list(character_media_refs_for_project(full, char_map)) if cs["auto_enabled"] and cs["identity_pin_ref"]: refs.append(cs["identity_pin_ref"]) + for sc in full.scenes: + if sc.excluded: + continue + pin = resolve_scene_identity_pin(sc, char_map, cs) + if pin: + refs.append(pin) payload = build_auto_continuity_guides(full, target) if payload: for scene_list in payload.get("scenes") or []: @@ -395,6 +485,8 @@ class Scene: cut_offset_frames: int = 0 # Per-scene guide overrides (only when project.guide_settings.stack_enabled). guides: list = field(default_factory=list) + # Character library ids assigned to this scene (merged into its prompt on generate). + character_ids: list = field(default_factory=list) @staticmethod def from_dict(d: dict) -> "Scene": @@ -418,6 +510,7 @@ class Scene: gen_unit_id=d.get("gen_unit_id"), cut_offset_frames=int(d.get("cut_offset_frames", 0) or 0), guides=list(d.get("guides") or []), + character_ids=[str(x) for x in (d.get("character_ids") or []) if x], ) def to_dict(self) -> dict: @@ -600,8 +693,9 @@ def build_combined_prompt(project: Project, include_excluded: bool = False, """ scenes = [s for s in project.scenes if include_excluded or not s.excluded] units = group_generative_units(scenes) + char_map = _load_character_map() parts: list[str] = [] - anchor = effective_anchor(project) + anchor = effective_anchor(project, char_map) if anchor: parts.append(anchor) # Only inject separators for a MULTI-unit run: editorial subclips count as one unit. @@ -610,6 +704,9 @@ def build_combined_prompt(project: Project, include_excluded: bool = False, for i, (_uid, group) in enumerate(units): root = gen_unit_root(group) text = (root.text or "").strip() + char_prefix = _scene_character_prefix(root, char_map) + if char_prefix: + text = f"{char_prefix} {text}".strip() if text else char_prefix if inject and not (text and trig_re and trig_re.match(text)): prev_root = gen_unit_root(units[i - 1][1]) if i > 0 else None marker = (project.intro_transition or "").strip() if i == 0 else ((prev_root.transition_to_next if prev_root else "") or "").strip() @@ -635,11 +732,16 @@ def generation_prompt_fingerprint(project: Project, target: Optional[Project] = active = [s for s in tgt.scenes if not s.excluded] cs = continuity_settings_for_run(project) gs = normalize_guide_settings(project.guide_settings) + char_map = _load_character_map() + char_key = "|".join( + f"{s.id}:{','.join(s.character_ids or [])}" for s in active + ) run_key = "\n".join([ text_hash, "|".join(_scene_run_fingerprint(s) for s in active), + char_key, cs.get("identity_pin_ref") or "", - normalize_character_bible(project.character_bible).get("never_include") or "", + effective_negative_prompt(project, char_map), "stack" if gs["stack_enabled"] else "", ]) run_hash = hashlib.sha256(run_key.encode("utf-8")).hexdigest()[:24] diff --git a/movie_editor/frontend/api.js b/movie_editor/frontend/api.js index 8e1ea33d..65e78d5e 100644 --- a/movie_editor/frontend/api.js +++ b/movie_editor/frontend/api.js @@ -47,6 +47,9 @@ deleteShortcut: (name) => j("DELETE", API(`/library/shortcuts/${encodeURIComponent(name)}`)), exportShortcutsUrl: () => API("/library/shortcuts/export"), importShortcuts: (data) => j("POST", API("/library/shortcuts/import"), data), + characters: () => j("GET", API("/library/characters")), + saveCharacter: (item) => j("POST", API("/library/characters"), item), + deleteCharacter: (id) => j("DELETE", API(`/library/characters/${encodeURIComponent(id)}`)), // media bin listMedia: () => j("GET", API("/media")), diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js index fb62dac0..9539acc6 100644 --- a/movie_editor/frontend/inspector.js +++ b/movie_editor/frontend/inspector.js @@ -134,6 +134,28 @@ return wrap; } + function renderSceneCharacters(st, scene) { + const ids = S.sceneCharacterIds(scene.id); + const tag = el("div", "insp-tag"); tag.textContent = "Characters"; body.append(tag); + const chips = el("div", "char-chips"); + if (!ids.length) { + chips.append(el("span", "char-chip empty", "None — use Characters in the media bin")); + } else { + ids.forEach((cid) => { + const c = (st.characters || []).find((x) => x.id === cid); + const chip = el("span", "char-chip"); + chip.textContent = c?.name || cid; + const rm = el("button", "char-chip-rm", "✕"); + rm.title = "Remove from this scene"; + rm.onclick = () => S.toggleSceneCharacter(scene.id, cid); + chip.append(rm); + chips.append(chip); + }); + } + body.append(chips); + body.append(el("div", "insp-hint", "Open Characters in the left bin and click to toggle who appears in this scene's prompt.")); + } + function renderScene(st, scene) { const root = S.genUnitRoot(S.genUnitId(scene)) || scene; const unitScenes = (st.project.scenes || []).filter((s) => S.genUnitId(s) === S.genUnitId(scene)) @@ -141,6 +163,7 @@ const cutNo = unitScenes.indexOf(scene) + 1; title.textContent = `Scene · ${st.project.scenes.indexOf(scene) + 1}` + (unitScenes.length > 1 ? ` · cut ${cutNo}/${unitScenes.length}` : ""); + renderSceneCharacters(st, scene); const tag = el("div", "insp-tag"); tag.textContent = "Clip properties"; body.append(tag); const selN = S.selectedSceneCount ? S.selectedSceneCount() : 1; if (selN > 1) { @@ -283,69 +306,6 @@ body.append(strip); } - function bibleRefPicker(st, label, refKey, bible) { - const ref = bible[refKey] || null; - const asset = ref ? (st.mediaBin || []).find((m) => m.id === ref) : null; - const block = el("div", "bible-ref"); - const prev = el("div", "bible-ref-prev"); - if (asset && asset.kind === "image") { - const img = el("img"); img.src = window.MovieEditorAPI.mediaUrl(asset.id); img.loading = "lazy"; prev.append(img); - } else prev.append(el("span", "media-icon", asset ? "▶" : "◇")); - block.append(prev); - const info = el("div", "bible-ref-info"); - info.append(el("div", "bible-ref-name", asset ? asset.name : "No image selected")); - const pick = el("select"); - pick.dataset.k = "bible-ref-" + refKey; - pick.append(new Option("— none —", "")); - (st.mediaBin || []).filter((m) => m.kind === "image").forEach((m) => { - const o = new Option(m.name, m.id); if (m.id === ref) o.selected = true; pick.append(o); - }); - pick.onchange = () => S.patchCharacterBible({ [refKey]: pick.value || null }); - info.append(field(label, pick)); - block.append(info); - return block; - } - - function renderCharacterBible(st) { - const bible = S.normCharacterBible(st.project.character_bible); - const tag = el("div", "insp-tag"); tag.textContent = "Character bible"; body.append(tag); - body.append(el("div", "insp-hint", "Structured character profile — merged into the anchor on generate. Face ref can drive the identity pin.")); - - const name = el("input"); name.value = bible.name; name.dataset.k = "bible-name"; - name.placeholder = "Character name"; - name.oninput = () => S.patchCharacterBible({ name: name.value }, true); - body.append(field("Name", name)); - - const mkTa = (key, label, rows, placeholder) => { - const ta = el("textarea"); ta.rows = rows; ta.value = bible[key] || ""; ta.dataset.k = "bible-" + key; - ta.placeholder = placeholder; - ta.oninput = () => S.patchCharacterBible({ [key]: ta.value }, true); - body.append(field(label, ta)); - }; - mkTa("appearance", "Appearance", 2, "Face, hair, eyes, skin, distinguishing marks…"); - mkTa("body", "Body", 2, "Build, height, proportions…"); - mkTa("wardrobe", "Wardrobe", 2, "Default outfit or style…"); - mkTa("always_include", "Always include", 2, "Tags and phrases always kept in the anchor"); - mkTa("never_include", "Never include", 2, "Appended to the negative prompt on generate"); - - const refTag = el("div", "insp-tag sp-sub"); refTag.textContent = "Reference images"; body.append(refTag); - const refs = el("div", "bible-refs"); - refs.append(bibleRefPicker(st, "Face (identity pin)", "face_ref", bible)); - refs.append(bibleRefPicker(st, "Body", "body_ref", bible)); - refs.append(bibleRefPicker(st, "Detail (tattoo / accessory)", "detail_ref", bible)); - body.append(refs); - - const pinRow = el("label", "chk bible-pin"); - const pinCb = el("input"); pinCb.type = "checkbox"; pinCb.checked = bible.sync_identity_pin; - pinCb.onchange = () => S.patchCharacterBible({ sync_identity_pin: pinCb.checked }); - pinRow.append(pinCb); - pinRow.append(el("span", null, "Use face ref as identity pin")); - body.append(pinRow); - if (bible.sync_identity_pin && bible.face_ref) { - body.append(el("div", "insp-hint", "Identity pin is synced from the face reference (Engine settings → Continuity).")); - } - } - function renderProject(st) { const p = st.project; title.textContent = "Project"; @@ -368,11 +328,9 @@ body.append(row3); body.append(el("div", "insp-hint", "Link FPS / Frames / Width / Height in Models → Linked inputs (Source = Project …).")); - renderCharacterBible(st); - const promptTag = el("div", "insp-tag"); promptTag.textContent = "Prompt"; body.append(promptTag); const anchor = el("textarea"); anchor.rows = 2; anchor.value = p.anchor || ""; anchor.dataset.k = "pj-anchor"; - anchor.placeholder = "Extra world / setting context (character bible prepends above this)"; + anchor.placeholder = "World / setting context prepended to every scene (assign characters per scene in the Characters bin)"; anchor.oninput = () => S.patchProjectQuiet({ anchor: anchor.value }); body.append(field("Anchor", anchor)); body.append(field("Opening transition (anchor → scene 1)", transitionSelect(p.intro_transition || "", diff --git a/movie_editor/frontend/mediabrowser.js b/movie_editor/frontend/mediabrowser.js index b7dd95af..ca642528 100644 --- a/movie_editor/frontend/mediabrowser.js +++ b/movie_editor/frontend/mediabrowser.js @@ -1,4 +1,4 @@ -// Left zone: projects + tabbed library bins — Media, Shortcuts, Transitions. +// Left zone: projects + tabbed library bins — Media, Characters, Shortcuts, Transitions. (function () { const { el, clear } = window.dom; const S = window.Store; @@ -6,9 +6,10 @@ const body = document.getElementById("media-body"); let tab = "Media"; // active bin - let q = { Shortcuts: "", Transitions: "" }; + let q = { Characters: "", Shortcuts: "", Transitions: "" }; let editShortcut = null; // shortcut item being edited ({} = new), or null let editTransition = null; // transition item being edited ({} = new), or null + let editCharacter = null; // character item being edited ({} = new), or null // ── library edit helpers ─────────────────────────────────────────────────────── const splitLines = (v) => String(v || "").split(/[\n,]+/).map((s) => s.trim()).filter(Boolean); @@ -82,6 +83,66 @@ return box; } + function mediaRefSelect(st, value, onChange) { + const sel = el("select", "lib-in"); + sel.append(new Option("— none —", "")); + (st.mediaBin || []).filter((m) => m.kind === "image").forEach((m) => { + const o = new Option(m.name, m.id); if (m.id === value) o.selected = true; sel.append(o); + }); + sel.onchange = () => onChange(sel.value || null); + return sel; + } + + function characterForm(st, item) { + const isNew = !item.id; + const box = el("div", "lib-form"); + box.append(el("div", "lib-form-title", isNew ? "New character" : `Edit “${item.name || "character"}”`)); + const name = el("input", "lib-in"); name.placeholder = "Name"; name.value = item.name || ""; + const appearance = el("textarea", "lib-in"); appearance.rows = 2; appearance.placeholder = "Face, hair, eyes, skin…"; appearance.value = item.appearance || ""; + const body = el("textarea", "lib-in"); body.rows = 2; body.placeholder = "Build, height, proportions…"; body.value = item.body || ""; + const wardrobe = el("textarea", "lib-in"); wardrobe.rows = 2; wardrobe.placeholder = "Default outfit or style…"; wardrobe.value = item.wardrobe || ""; + const always = el("textarea", "lib-in"); always.rows = 2; always.placeholder = "Tags always included in this character's prompt"; always.value = item.always_include || ""; + const never = el("textarea", "lib-in"); never.rows = 2; never.placeholder = "Appended to negative when this character is on a scene"; never.value = item.never_include || ""; + let faceRef = item.face_ref || null; + let bodyRef = item.body_ref || null; + let detailRef = item.detail_ref || null; + box.append( + labeled("Name", name), + labeled("Appearance", appearance), + labeled("Body", body), + labeled("Wardrobe", wardrobe), + labeled("Always include", always), + labeled("Never include", never), + labeled("Face ref (identity pin)", mediaRefSelect(st, faceRef, (v) => { faceRef = v; })), + labeled("Body ref", mediaRefSelect(st, bodyRef, (v) => { bodyRef = v; })), + labeled("Detail ref", mediaRefSelect(st, detailRef, (v) => { detailRef = v; })), + ); + const actions = el("div", "lib-form-actions"); + const save = el("button", "btn primary tiny", "Save"); + save.onclick = async () => { + const nm = name.value.trim(); + if (!nm) { alert("Character name is required."); return; } + await S.saveCharacter({ + id: item.id || undefined, + original_id: item.id || undefined, + name: nm, + appearance: appearance.value, + body: body.value, + wardrobe: wardrobe.value, + always_include: always.value, + never_include: never.value, + face_ref: faceRef, + body_ref: bodyRef, + detail_ref: detailRef, + }); + editCharacter = null; render(S.get()); + }; + const cancel = el("button", "btn ghost tiny", "Cancel"); + cancel.onclick = () => { editCharacter = null; render(S.get()); }; + actions.append(save, cancel); box.append(actions); + return box; + } + // ── projects ─────────────────────────────────────────────────────────────────── function projectsSection(st) { const sec = el("div", "mb-section"); @@ -143,6 +204,62 @@ return wrap; } + // ── characters bin ─────────────────────────────────────────────────────────────── + function charactersTab(st) { + if (editCharacter) return characterForm(st, editCharacter); + const wrap = el("div", "bin"); + wrap.append(searchRow("Characters", "Filter characters…", () => render(S.get()))); + const hint = el("div", "pj-meta char-hint"); + hint.textContent = st.selectedSceneId + ? "Click a character to add/remove it on the selected scene." + : "Select a scene, then click characters to assign them."; + wrap.append(hint); + const head = el("div", "lib-head"); + const add = el("button", "btn ghost tiny", "+ New"); + add.onclick = () => { editCharacter = {}; render(S.get()); }; + head.append(add); wrap.append(head); + + const assigned = st.selectedSceneId ? new Set(S.sceneCharacterIds(st.selectedSceneId)) : null; + const list = el("div", "char-list"); + const items = filtered(st.characters || [], q.Characters, (c) => + `${c.name} ${c.appearance || ""} ${c.body || ""} ${c.wardrobe || ""}`); + items.forEach((c) => { + const onScene = assigned && assigned.has(c.id); + const row = el("div", "char-row" + (onScene ? " on-scene" : "")); + const prev = el("div", "char-thumb"); + if (c.face_ref) { + const img = el("img"); img.src = API.mediaUrl(c.face_ref); img.loading = "lazy"; prev.append(img); + } else prev.append(el("span", null, "◎")); + row.append(prev); + const info = el("div", "char-info"); + info.append(el("div", "char-name", c.name || "(unnamed)")); + const sub = [c.appearance, c.wardrobe].filter(Boolean).join(" · "); + if (sub) info.append(el("div", "char-sub", sub)); + row.append(info); + if (onScene) row.append(el("span", "char-badge", "on scene")); + row.onclick = () => { + if (st.selectedSceneId) S.toggleSceneCharacter(st.selectedSceneId, c.id); + else { editCharacter = { ...c }; render(S.get()); } + }; + const edit = el("button", "btn ghost tiny", "✎"); + edit.title = "Edit character"; + edit.onclick = (e) => { e.stopPropagation(); editCharacter = { ...c }; render(S.get()); }; + const del = el("button", "btn ghost tiny danger", "✕"); + del.title = "Delete character"; + del.onclick = async (e) => { + e.stopPropagation(); + if (confirm(`Delete character “${c.name}”?`)) await S.deleteCharacter(c.id); + }; + const actions = el("div", "char-actions"); + actions.append(edit, del); + row.append(actions); + list.append(row); + }); + if (!items.length) list.append(el("div", "pj-meta", (st.characters || []).length ? "No match." : "No characters yet — create one.")); + wrap.append(list); + return wrap; + } + // ── shortcuts bin ──────────────────────────────────────────────────────────────── function shortcutsTab(st) { const wrap = el("div", "bin"); @@ -252,13 +369,18 @@ const sec = el("div", "mb-section"); const tabs = el("div", "bin-tabs"); - ["Media", "Shortcuts", "Transitions"].forEach((name) => { + ["Media", "Characters", "Shortcuts", "Transitions"].forEach((name) => { const b = el("button", "bin-tab" + (tab === name ? " active" : ""), name); - b.onclick = () => { tab = name; render(S.get()); }; + b.onclick = () => { tab = name; editCharacter = null; render(S.get()); }; tabs.append(b); }); sec.append(tabs); - sec.append(tab === "Media" ? mediaTab(st) : tab === "Shortcuts" ? shortcutsTab(st) : transitionsTab(st)); + sec.append( + tab === "Media" ? mediaTab(st) + : tab === "Characters" ? charactersTab(st) + : tab === "Shortcuts" ? shortcutsTab(st) + : transitionsTab(st), + ); body.append(sec); } diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 176cb130..d65dbb7a 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -23,6 +23,7 @@ mediaBin: [], // uploaded assets [{id,name,kind,...}] mediaPreviewId: null, // transient image preview in the player (not scene assignment) shortcuts: [], // prompt shortcut library + characters: [], // global character library imageTargets: [], // where an image asset can be wired [{value,label}] ratingLabels: [], // FunPack Studio V2 rating options }; @@ -177,36 +178,6 @@ 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 normCharacterBible(raw) { - const b = raw || {}; - return { - name: b.name || "", - appearance: b.appearance || "", - body: b.body || "", - wardrobe: b.wardrobe || "", - always_include: b.always_include || "", - never_include: b.never_include || "", - face_ref: b.face_ref || null, - body_ref: b.body_ref || null, - detail_ref: b.detail_ref || null, - sync_identity_pin: b.sync_identity_pin !== false, - }; - } - - function _characterBiblePinPatch(bible) { - if (!bible.sync_identity_pin || !bible.face_ref) return null; - return { ...(state.project.continuity_settings || {}), identity_pin_ref: bible.face_ref }; - } - - function patchCharacterBible(patch, quiet) { - if (!state.project) return; - const next = { ...normCharacterBible(state.project.character_bible), ...patch }; - const updates = { character_bible: next }; - const pin = _characterBiblePinPatch(next); - if (pin) updates.continuity_settings = pin; - quiet ? patchProjectQuiet(updates) : patchProject(updates); - } - function scene(id) { return state.project?.scenes.find((s) => s.id === id) || null; } function genUnitId(sc) { return (sc && sc.gen_unit_id) || (sc && sc.id) || ""; } @@ -222,7 +193,7 @@ function patchScene(id, patch) { const s = scene(id); if (!s) return; const root = isGenSubclip(s) ? genUnitRoot(genUnitId(s)) : s; - const targetId = (root && isGenSubclip(s) && (patch.text != null || patch.rating != null || patch.source != null)) + const targetId = (root && isGenSubclip(s) && (patch.text != null || patch.rating != null || patch.source != null || patch.character_ids != null)) ? root.id : id; const t = scene(targetId); if (!t) return; Object.assign(t, patch); notify(); scheduleSave(); @@ -1120,6 +1091,28 @@ 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 loadCharacters() { try { state.characters = (await API.characters()).characters || []; } catch (_) { state.characters = []; } notify(); } + async function saveCharacter(item) { + try { state.characters = (await API.saveCharacter(item)).characters || state.characters; notify(); } + catch (e) { alert("Save failed: " + e.message); } + } + async function deleteCharacter(id) { + try { state.characters = (await API.deleteCharacter(id)).characters || []; notify(); } + catch (e) { console.error(e); } + } + + function sceneCharacterIds(sceneId) { + const s = scene(sceneId); if (!s) return []; + const root = isGenSubclip(s) ? genUnitRoot(genUnitId(s)) : s; + return [...(root?.character_ids || s.character_ids || [])]; + } + + function toggleSceneCharacter(sceneId, charId) { + const s = scene(sceneId); if (!s || !charId) return; + const ids = sceneCharacterIds(sceneId); + const next = ids.includes(charId) ? ids.filter((x) => x !== charId) : [...ids, charId]; + patchScene(sceneId, { character_ids: next }); + } 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); } } @@ -1196,6 +1189,7 @@ 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 loadCharacters(); await loadMedia(); try { state.ratingLabels = (await API.ratingLabels()).labels || []; } catch (_) { state.ratingLabels = []; } await loadModels(); @@ -1209,7 +1203,8 @@ window.Store = { get, set, subscribe, init, refreshProjectList, loadProject, newProject, deleteProject, downloadProject, importProject, - patchProject, patchProjectQuiet, patchCharacterBible, normCharacterBible, patchScene, patchSceneQuiet, flushSave, selectScene, addScene, removeScene, dismissGhost, moveScene, moveSceneTo, scene, + patchProject, patchProjectQuiet, patchScene, patchSceneQuiet, flushSave, selectScene, addScene, removeScene, dismissGhost, moveScene, moveSceneTo, scene, + sceneCharacterIds, toggleSceneCharacter, genUnitId, isGenSubclip, genUnitRoot, genUnitSceneIds, buildPreviewSegments, previewTotalSec, segmentDurationSec, addAudioTrack, updateAudioTrack, removeAudioTrack, @@ -1217,7 +1212,9 @@ refreshPreview, syncFromPreview, applyGlobalPrompt, generate, generateMontage, generateSelected, selectedSceneCount, renderFinal, exportSelected, interrupt, loadModels, loadImageTargets, setModelInput, setModelLink, setConditioningSlot, setSamplerSlot, setSamplerInput, setSamplerInputNow, unsetSamplerInput, setStudioInput, setStudioInputNow, loadMedia, uploadMedia, deleteMedia, previewMedia, clearMediaPreview, assignMediaToScene, - loadShortcuts, saveShortcut, deleteShortcut, importShortcuts, loadTransitions, saveTransition, deleteTransition, importTransitions, + loadShortcuts, saveShortcut, deleteShortcut, importShortcuts, + loadCharacters, saveCharacter, deleteCharacter, + loadTransitions, saveTransition, deleteTransition, importTransitions, applyTransitionToSelection, insertShortcutIntoSelection, setSceneRating: (id, v) => patchScene(id, { rating: v }), resetStudioSession, diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 7a3a3467..3cd797d1 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -362,15 +362,30 @@ input[type=number] { font-family: var(--font-mono); } .insp-tag { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 1px; color: var(--amber); text-transform: uppercase; } .insp-hint { font-size: 11px; color: var(--faint); margin: -4px 0 12px; } .insp-block { margin-bottom: 16px; } -.bible-refs { display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px; } -.bible-ref { display: flex; gap: 10px; align-items: flex-start; padding: 8px; background: var(--ink-2); - border: 1px solid var(--line); border-radius: 8px; } -.bible-ref-prev { width: 52px; height: 52px; flex-shrink: 0; border-radius: 6px; overflow: hidden; - background: var(--ink-0); border: 1px solid var(--line); display: flex; align-items: center; justify-content: center; } -.bible-ref-prev img { width: 100%; height: 100%; object-fit: cover; } -.bible-ref-info { flex: 1; min-width: 0; } -.bible-ref-name { font-size: 11px; color: var(--muted); margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.bible-pin { margin-bottom: 12px; } +.char-hint { margin-bottom: 8px; } +.char-list { display: flex; flex-direction: column; gap: 6px; } +.char-row { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border: 1px solid var(--line); + border-radius: 8px; background: var(--ink-2); cursor: pointer; transition: border-color .12s, background .12s; } +.char-row:hover { border-color: #4a4334; background: var(--ink-3); } +.char-row.on-scene { border-color: var(--teal-soft); box-shadow: inset 0 0 0 1px rgba(45, 212, 191, 0.25); } +.char-thumb { width: 36px; height: 36px; flex-shrink: 0; border-radius: 6px; overflow: hidden; + background: var(--ink-0); border: 1px solid var(--line); display: flex; align-items: center; justify-content: center; font-size: 14px; color: var(--faint); } +.char-thumb img { width: 100%; height: 100%; object-fit: cover; } +.char-info { flex: 1; min-width: 0; } +.char-name { font-size: 12px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.char-sub { font-size: 10px; color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.char-badge { font-size: 9px; font-family: var(--font-mono); color: var(--teal-soft); text-transform: uppercase; letter-spacing: .04em; } +.char-actions { display: flex; gap: 2px; flex-shrink: 0; } +.char-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; } +.char-chip { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; padding: 3px 8px; + border-radius: 999px; background: var(--ink-2); border: 1px solid var(--line); color: var(--amber-2); } +.char-chip.empty { color: var(--faint); font-style: italic; border-style: dashed; } +.char-chip-rm { border: none; background: transparent; color: var(--faint); cursor: pointer; padding: 0 2px; font-size: 10px; } +.char-chip-rm:hover { color: var(--danger); } +.clip-chars { display: flex; flex-wrap: wrap; gap: 3px; flex-shrink: 0; } +.clip-char { font-size: 9px; font-family: var(--font-mono); color: var(--amber-2); background: #2a2418; + border: 1px solid #4a4334; border-radius: 4px; padding: 1px 4px; white-space: nowrap; } +.clip.has-media .clip-chars { position: relative; z-index: 1; } /* global prompt — first entry in the inspector list (scrolls with content) */ .insp-global { margin: 0 0 14px; padding: 10px 10px 8px; diff --git a/movie_editor/frontend/timeline.js b/movie_editor/frontend/timeline.js index c101862e..798eaf1b 100644 --- a/movie_editor/frontend/timeline.js +++ b/movie_editor/frontend/timeline.js @@ -246,6 +246,16 @@ const label = scene.text || (root && root.text) || (subclip ? "cut" : "empty scene"); clip.append(el("div", "clip-text" + (label && label !== "empty scene" && label !== "cut" ? "" : " empty"), label)); + const charIds = S.sceneCharacterIds(scene.id); + if (charIds.length) { + const chars = el("div", "clip-chars"); + charIds.forEach((cid) => { + const c = (st.characters || []).find((x) => x.id === cid); + chars.append(el("span", "clip-char", c?.name || cid)); + }); + clip.append(chars); + } + const rating = ((root && root.rating) || scene.rating || "").trim(); if (rating) { const rated = el("div", "clip-rated"); diff --git a/movie_editor/server.py b/movie_editor/server.py index 8198fb98..7e0e1fcc 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -644,6 +644,30 @@ if web is not None and PromptServer is not None: except Exception as e: # noqa: BLE001 raise web.HTTPBadRequest(reason=str(e)) + @routes.get(UI_PREFIX + "/api/library/characters") + async def _characters(_req): + try: + from .backend import characters as char_lib + return web.json_response(char_lib.list_characters()) + except Exception as e: # noqa: BLE001 + return web.json_response({"characters": [], "error": str(e)}) + + @routes.post(UI_PREFIX + "/api/library/characters") + async def _character_save(req): + try: + from .backend import characters as char_lib + return web.json_response(char_lib.save_character(await req.json())) + except Exception as e: # noqa: BLE001 + raise web.HTTPBadRequest(reason=str(e)) + + @routes.delete(UI_PREFIX + "/api/library/characters/{cid}") + async def _character_delete(req): + try: + from .backend import characters as char_lib + return web.json_response(char_lib.delete_character(req.match_info["cid"])) + except Exception as e: # noqa: BLE001 + raise web.HTTPBadRequest(reason=str(e)) + @routes.get(UI_PREFIX + "/api/library/shortcuts/export") async def _shortcuts_export(_req): try: diff --git a/movie_editor/tests/test_characters.py b/movie_editor/tests/test_characters.py new file mode 100644 index 00000000..e65c7e67 --- /dev/null +++ b/movie_editor/tests/test_characters.py @@ -0,0 +1,32 @@ +"""Character library store tests.""" +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from movie_editor.backend import characters # noqa: E402 + + +def test_character_library_crud(tmp_path, monkeypatch): + db = tmp_path / "characters.json" + monkeypatch.setattr(characters, "CHARACTERS_PATH", db) + + assert characters.list_characters()["characters"] == [] + + characters.save_character({"name": "Nicole", "appearance": "red hair", "face_ref": "img1"}) + items = characters.list_characters()["characters"] + assert len(items) == 1 + assert items[0]["name"] == "Nicole" + assert items[0]["face_ref"] == "img1" + cid = items[0]["id"] + + characters.save_character({"id": cid, "name": "Nicole V2", "appearance": "auburn hair"}) + items = characters.list_characters()["characters"] + assert len(items) == 1 + assert items[0]["name"] == "Nicole V2" + + characters.delete_character(cid) + assert characters.list_characters()["characters"] == [] + assert json.loads(db.read_text(encoding="utf-8"))["characters"] == {} \ No newline at end of file diff --git a/movie_editor/tests/test_timeline.py b/movie_editor/tests/test_timeline.py index 9fa263a7..03a80d75 100644 --- a/movie_editor/tests/test_timeline.py +++ b/movie_editor/tests/test_timeline.py @@ -28,7 +28,7 @@ from movie_editor.backend.timeline import ( # noqa: E402 generation_prompt_fingerprint, normalize_character_bible, normalize_continuity_settings, - resolve_identity_pin_ref, + resolve_scene_identity_pin, collapse_generative_units, group_generative_units, is_mixed_source, @@ -303,18 +303,42 @@ def test_character_bible_never_include_merges_negative(): assert "extra limbs" in neg -def test_character_bible_face_ref_drives_identity_pin(): +def test_scene_characters_merge_into_prompt(monkeypatch): + char_map = { + "c1": {"id": "c1", "name": "Nicole", "appearance": "red hair"}, + "c2": {"id": "c2", "name": "Alex", "appearance": "tall"}, + } + monkeypatch.setattr( + "movie_editor.backend.timeline._load_character_map", + lambda: char_map, + ) + p = _project(scenes=[ + {"text": "walks in", "character_ids": ["c1"]}, + {"text": "sits down", "character_ids": ["c1", "c2"]}, + ]) + prompt = build_combined_prompt(p, for_generation=True) + assert "Character: Nicole." in prompt + assert "Character: Alex." in prompt + assert prompt.index("Nicole") < prompt.index("walks") + assert prompt.index("Alex") < prompt.index("sits") + + +def test_scene_character_face_ref_drives_identity_pin(monkeypatch): + char_map = {"c1": {"id": "c1", "name": "Nicole", "face_ref": "face1"}} + monkeypatch.setattr( + "movie_editor.backend.timeline._load_character_map", + lambda: char_map, + ) p = _project( - character_bible={"face_ref": "face1", "sync_identity_pin": True}, continuity_settings={"identity_pin_ref": "other"}, scenes=[ {"id": "s1", "text": "a", "source": {"type": "carry"}}, - {"id": "s2", "text": "b", "source": {"type": "carry"}}, + {"id": "s2", "text": "b", "source": {"type": "carry"}, "character_ids": ["c1"]}, ], ) - assert resolve_identity_pin_ref(p) == "face1" cs = continuity_settings_for_run(p) - assert cs["identity_pin_ref"] == "face1" + sc2 = p.scenes[1] + assert resolve_scene_identity_pin(sc2, char_map, cs) == "face1" guides = build_auto_continuity_guides(p, p) assert guides["scenes"][1][0]["media_ref"] == "face1" @@ -328,6 +352,17 @@ def test_character_bible_refs_in_continuity_media(): assert refs == ["f1", "b1", "d1"] +def test_scene_character_media_refs(monkeypatch): + char_map = {"c1": {"id": "c1", "face_ref": "f1", "body_ref": "b1"}} + monkeypatch.setattr( + "movie_editor.backend.timeline._load_character_map", + lambda: char_map, + ) + p = _project(scenes=[{"text": "a", "character_ids": ["c1"]}]) + from movie_editor.backend.timeline import character_media_refs_for_project + assert character_media_refs_for_project(p, char_map) == ["f1", "b1"] + + def test_character_bible_changes_generation_hash(): p = _project(scenes=[{"text": "walks"}]) h1 = generation_prompt_fingerprint(p, p)["prompt_hash"]