Move character bible to global Characters bin with per-scene assign

Characters live in a shared library (left bin → Characters tab), not the
project inspector. Select a timeline clip and click characters to toggle
them on that scene; their prompt text merges per scene, face refs drive
per-scene identity pins, and assigned names show on clips and in the
inspector. Legacy project.character_bible still works when no scene
characters are assigned.
This commit is contained in:
digital-garbage
2026-06-10 17:36:43 +03:00
parent 59f2a5f4f8
commit d3a85997d3
11 changed files with 556 additions and 164 deletions
+94
View File
@@ -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)}
+147 -45
View File
@@ -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]
+3
View File
@@ -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")),
+24 -66
View File
@@ -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 || "",
+127 -5
View File
@@ -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);
}
+30 -33
View File
@@ -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,
+24 -9
View File
@@ -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;
+10
View File
@@ -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");
+24
View File
@@ -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:
+32
View File
@@ -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"] == {}
+41 -6
View File
@@ -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"]