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:
@@ -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)}
|
||||
@@ -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]
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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 || "",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"] == {}
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user