opencut_ui: hard-remove the character feature (no UI surface remained)

The rebuilt editor never exposed character creation/assignment, so the feature
was vestigial: dead frontend plumbing plus generation branches that only fired
for legacy/imported projects. Removed it wholesale per decision.

Backend:
- Deleted movie_editor/backend/characters.py and the /library/characters routes.
- timeline.py: dropped normalize_character_bible / build_characters_anchor /
  _load_character_map / _scene_character_prefix / character_media_refs_for_project
  and friends. effective_anchor/effective_negative_prompt now return the project's
  manual text; resolve_scene_identity_pin(cs) returns the project-level identity
  pin only. Removed Scene.character_ids and Project.character_bible fields and the
  character_ids fingerprint key.
- Continuity auto-guides keep working off continuity_settings.identity_pin_ref.

Frontend:
- store.js: removed state.characters, the character preview-key plumbing,
  load/save/deleteCharacter, sceneCharacterIds/toggleSceneCharacter, character_ids
  in patch/archive paths and exports.
- timeline.js: dropped clip-char badge rendering; api.js: dropped character routes;
  view_bus.js: dropped chars fingerprint; tour.js: dropped demo characters + copy;
  styles.css: dropped .clip-chars/.clip-char.

Tests: removed test_characters.py and the character-bible timeline tests; added
manual-anchor / manual-negative / project-identity-pin tests. 133 passed, 4 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-20 11:08:48 +03:00
co-authored by Claude Opus 4.8
parent 41e0cc48d5
commit a534f8bb6f
13 changed files with 34 additions and 519 deletions
-94
View File
@@ -1,94 +0,0 @@
"""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)}
+1 -1
View File
@@ -55,7 +55,7 @@ def save(project: Project) -> Project:
def create(name: str = "Untitled") -> Project:
project = Project(name=name)
project.anchor = "Optional: describe characters or the world shared by all scenes"
project.anchor = "Optional: describe the subjects or world shared by all scenes"
project.scenes = [Scene(text="Describe your first scene here…")]
return save(project)
+14 -170
View File
@@ -41,155 +41,21 @@ def normalize_guide_settings(raw: Optional[dict]) -> dict[str, bool]:
}
def normalize_character_bible(raw: Optional[dict]) -> dict[str, Any]:
"""Structured character profile — text merges into the anchor; face ref can drive identity pin."""
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,
"sync_identity_pin": bool(raw.get("sync_identity_pin", True)),
}
def effective_anchor(project: Project) -> str:
"""The project's manual anchor text (prepended to every scene by Studio)."""
return (project.anchor or "").strip()
def build_character_bible_anchor(bible: Optional[dict]) -> str:
"""Render one character profile into anchor prose."""
return build_characters_anchor([bible] if bible else [])
def effective_negative_prompt(project: Project) -> str:
"""The project's manual negative prompt."""
return (project.negative_prompt or "").strip()
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 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_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_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_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"]
def resolve_scene_identity_pin(cs: dict[str, Any]) -> Optional[str]:
"""Project-level identity pin reference for continuity guides, if set."""
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]:
b = normalize_character_bible(project.character_bible)
refs: list[str] = []
for key in ("face_ref", "body_ref", "detail_ref"):
ref = b.get(key)
if ref:
refs.append(ref)
return refs
def continuity_settings_for_run(project: Project) -> dict[str, Any]:
"""Continuity knobs (per-scene identity pins are resolved in auto guides)."""
return normalize_continuity_settings(project.continuity_settings)
@@ -267,11 +133,11 @@ def build_auto_continuity_guides(full: Project, target: Project) -> Optional[dic
if not active_target:
return None
char_map = _load_character_map()
decay = cs["guide_decay"]
project_pin = resolve_scene_identity_pin(cs)
def _pin_for(sc: Scene) -> Optional[str]:
return resolve_scene_identity_pin(sc, char_map, cs)
def _pin_for(_sc: Scene) -> Optional[str]:
return project_pin
def _entries_for_index(full_idx: int, chain_idx: int, scene: Scene) -> list[dict]:
entries: list[dict] = []
@@ -327,19 +193,11 @@ def build_auto_continuity_guides(full: Project, target: Project) -> Optional[dic
def continuity_media_refs(full: Project, target: Project) -> list[str]:
"""Media-bin ids that must exist for the active continuity/guide path."""
cs = continuity_settings_for_run(full)
char_map = _load_character_map()
refs: list[str] = []
if cs["auto_enabled"]:
refs.extend(character_media_refs_for_project(full, char_map))
if 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 []:
@@ -527,8 +385,6 @@ 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)
# Source trim inside generated media (slip edit): in-point seconds and optional duration.
source_in: float = 0.0
source_dur: Optional[float] = None
@@ -564,7 +420,6 @@ 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],
source_in=float(d.get("source_in") or 0),
source_dur=d.get("source_dur"),
scene_archive=d.get("scene_archive"),
@@ -591,8 +446,7 @@ class Scene:
class Project:
id: str = field(default_factory=_new_id)
name: str = "Untitled"
anchor: str = "" # extra anchor text (character bible prepends before this)
character_bible: dict = field(default_factory=dict)
anchor: str = "" # extra anchor text prepended to every scene
# Optional master prompt in Studio combined syntax. When the user edits it and
# hits Apply, the editor reparses it into anchor + scenes + transitions (normal
# Studio behaviour). Stored verbatim so the field round-trips; not used at build.
@@ -660,7 +514,6 @@ class Project:
id=d.get("id") or _new_id(),
name=str(d.get("name", "Untitled")),
anchor=str(d.get("anchor", "")),
character_bible=dict(d.get("character_bible") or {}),
global_prompt=str(d.get("global_prompt", "")),
negative_prompt=str(d.get("negative_prompt", "")),
intro_transition=str(d.get("intro_transition", "")),
@@ -780,9 +633,8 @@ def build_combined_prompt(project: Project, include_excluded: bool = False,
if (include_excluded or not s.excluded) and is_generative_scene(s)
]
units = group_generative_units(scenes)
char_map = _load_character_map()
parts: list[str] = []
anchor = effective_anchor(project, char_map)
anchor = effective_anchor(project)
if anchor:
parts.append(anchor)
# Only inject separators for a MULTI-unit run: editorial subclips count as one unit.
@@ -791,9 +643,6 @@ 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()
@@ -819,16 +668,11 @@ 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 "",
effective_negative_prompt(project, char_map),
effective_negative_prompt(project),
"stack" if gs["stack_enabled"] else "",
])
run_hash = hashlib.sha256(run_key.encode("utf-8")).hexdigest()[:24]
-3
View File
@@ -93,9 +93,6 @@
listFiles: () => j("GET", API("/files")),
deleteFile: (group, name) => j("DELETE", API(`/files/${encodeURIComponent(group)}/${encodeURIComponent(name)}`)),
clearFiles: (group) => j("POST", API(`/files/${encodeURIComponent(group)}/clear`), {}),
characters: () => j("GET", API("/library/characters")),
saveCharacter: (item) => j("POST", API("/library/characters"), item),
deleteCharacter: (id) => j("DELETE", API(`/library/characters/${encodeURIComponent(id)}`)),
nleLibrary: () => j("GET", API("/library/nle")),
// media bin
+1 -1
View File
@@ -1,4 +1,4 @@
// Left zone (Assets): projects + media bin only. Characters/Shortcuts/Splits live in the Composer;
// Left zone (Assets): projects + media bin only. Shortcuts/Splits live in the Composer;
// per-clip effects & transitions live in the Properties inspector.
(function () {
const { el, clear } = window.dom;
+1 -72
View File
@@ -30,7 +30,6 @@
mediaPreviewId: null, // transient image preview in the player (not scene assignment)
shortcuts: [], // prompt shortcut library
shortcutCategories: [], // managed grouping list: [{name, sub_categories:[]}]
characters: [], // global character library
imageTargets: [], // where an image asset can be wired [{value,label}]
ratingLabels: [], // FunPack Studio V2 rating options
};
@@ -117,36 +116,15 @@
}
function get() { return state; }
function _assignedCharacterIds(project) {
const ids = new Set();
(project?.scenes || []).forEach((s) => (s.character_ids || []).forEach((id) => ids.add(id)));
return ids;
}
function _characterPreviewKey(project) {
const assigned = _assignedCharacterIds(project);
return (state.characters || [])
.filter((c) => assigned.has(c.id))
.map((c) => [
c.id, c.name, c.appearance, c.body, c.wardrobe, c.always_include, c.never_include,
c.face_ref, c.body_ref, c.detail_ref,
].join("\x1f"))
.sort()
.join("|");
}
function _promptPreviewKey(project) {
if (!project) return "";
return JSON.stringify({
anchor: project.anchor,
global_prompt: project.global_prompt,
intro_transition: project.intro_transition,
character_bible: project.character_bible,
characters: _characterPreviewKey(project),
scenes: (project.scenes || []).map((s) => ({
text: s.text,
excluded: s.excluded,
character_ids: s.character_ids,
transition_to_next: s.transition_to_next,
source_type: s.source?.type,
media_ref: s.source?.media_ref,
@@ -919,7 +897,7 @@
function _patchSceneTarget(id, patch) {
const s = scene(id); if (!s) return null;
const root = isGenSubclip(s) ? genUnitRoot(genUnitId(s)) : s;
const targetId = (root && isGenSubclip(s) && (patch.text != null || patch.rating != null || patch.source != null || patch.character_ids != null))
const targetId = (root && isGenSubclip(s) && (patch.text != null || patch.rating != null || patch.source != null))
? root.id : id;
return scene(targetId);
}
@@ -969,7 +947,6 @@
function _applyScenePatch(id, patch, quiet) {
const t = _patchSceneTarget(id, patch); if (!t) return;
const charsChanged = patch.character_ids != null;
const anchorChanged = _anchorPatchChanged(t, patch);
if (anchorChanged) _stampRenderSourceBeforeAnchorChange(t);
const merged = { ...patch };
@@ -977,13 +954,9 @@
if (!quiet && !window.EditorHistory?.isApplying()) _historyRecord();
Object.assign(t, merged);
if (merged.source) _syncGenUnitSource(genUnitRoot(genUnitId(t)) || t);
if (charsChanged) _invalidateGlobalPromptDraft();
if (anchorChanged) {
clearTimeout(saveTimer); saveTimer = null;
_localDirty = true; _renderAfterSave = true; notify(); commit();
} else if (charsChanged) {
notify();
_syncPromptPreview();
} else if (_patchAffectsCombinedPrompt(merged)) {
syncGlobalPromptFromTimeline();
if (quiet) scheduleSaveSilent();
@@ -1111,7 +1084,6 @@
text: sc.text || "",
source: JSON.parse(JSON.stringify(sc.source || {})),
guides: JSON.parse(JSON.stringify(sc.guides || [])),
character_ids: [...(sc.character_ids || [])],
effects: JSON.parse(JSON.stringify(sc.effects || {})),
frames: sc.frames,
frames_mode: sc.frames_mode,
@@ -1138,7 +1110,6 @@
sc.text = archive.text || "";
sc.source = JSON.parse(JSON.stringify(archive.source || { type: "carry" }));
sc.guides = JSON.parse(JSON.stringify(archive.guides || []));
sc.character_ids = [...(archive.character_ids || [])];
sc.effects = JSON.parse(JSON.stringify(archive.effects || {}));
sc.frames = archive.frames;
sc.frames_mode = archive.frames_mode;
@@ -3581,45 +3552,6 @@
notify();
}
async function loadShortcuts() { try { const r = await API.shortcuts(); state.shortcuts = r.shortcuts || []; state.shortcutCategories = r.categories || []; } catch (_) { state.shortcuts = []; state.shortcutCategories = []; } notify(); }
async function loadCharacters() { try { state.characters = (await API.characters()).characters || []; } catch (_) { state.characters = []; } notify(); }
function _characterAssignedToProject(charId) {
if (!charId || !state.project) return false;
return (state.project.scenes || []).some((s) => (s.character_ids || []).includes(charId));
}
async function saveCharacter(item) {
try {
state.characters = (await API.saveCharacter(item)).characters || state.characters;
const cid = item.id || item.original_id;
if (_characterAssignedToProject(cid)) {
_invalidateGlobalPromptDraft();
await refreshPreview(true);
} else notify();
} catch (e) { alert("Save failed: " + e.message); }
}
async function deleteCharacter(id) {
try {
const wasAssigned = _characterAssignedToProject(id);
state.characters = (await API.deleteCharacter(id)).characters || [];
if (wasAssigned) {
_invalidateGlobalPromptDraft();
await refreshPreview(true);
} else 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 { const r = await API.saveShortcut(item); state.shortcuts = r.shortcuts || state.shortcuts; if (r.categories) state.shortcutCategories = r.categories; notify(); } catch (e) { alert("Save failed: " + e.message); } }
async function deleteShortcut(name) { try { const r = await API.deleteShortcut(name); state.shortcuts = r.shortcuts || []; if (r.categories) state.shortcutCategories = r.categories; notify(); } catch (e) { console.error(e); } }
async function addCategory(category, subCategory) {
@@ -3796,7 +3728,6 @@
try { const t = await API.transitions(); state.transitions = t.transitions || []; } catch (_) { state.transitions = []; }
await loadNleLibrary();
await loadShortcuts();
await loadCharacters();
await loadMedia();
try { state.ratingLabels = (await API.ratingLabels()).labels || []; } catch (_) { state.ratingLabels = []; }
await loadModels();
@@ -3812,7 +3743,6 @@
refreshProjectList, loadProject, newProject, deleteProject, downloadProject, importProject,
patchProject, patchProjectQuiet, patchScene, patchSceneQuiet, flushSave, selectScene, addScene, removeScene, removeSelectedScenes, removeFromPlan, restoreToPlan, dismissGhost, moveScene, moveSceneTo, moveTimelineClip, scene,
addVideoClip, addImageClip, convertToVideo, convertToScene, isVideoClip, isGenerativeScene,
sceneCharacterIds, toggleSceneCharacter,
genUnitId, isGenSubclip, genUnitRoot, genUnitSceneIds,
renderPromptForScene, renderPromptMismatch, renderAnchorMismatch, renderMediaLabel, renderIsStale,
buildPreviewSegments, previewTotalSec, audioTrackEndSec, hasOverlayOrAudioContent,
@@ -3834,7 +3764,6 @@
loadMedia, uploadMedia, deleteMedia, deleteMediaMany, renameMedia, previewMedia, clearMediaPreview, assignMediaToScene, exportMediaAsset,
loadShortcuts, saveShortcut, deleteShortcut, importShortcuts, clearShortcuts, addCategory,
getEditorSettings, getEditorSetting, setEditorSetting,
loadCharacters, saveCharacter, deleteCharacter,
loadTransitions, saveTransition, deleteTransition, importTransitions, clearTransitions,
loadNleLibrary, applyNleEffect, applyNleVideoTransition,
applySplitMarkerToSelection, applyTransitionToSelection, insertShortcutIntoSelection,
-5
View File
@@ -487,11 +487,6 @@ 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; }
.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;
background: var(--ink-2); border: 1px solid var(--line); border-radius: 9px; }
+1 -11
View File
@@ -333,7 +333,7 @@
}
if (!badge) {
badge = el("div", "clip-rated");
const anchor = clipEl.querySelector(".clip-chars") || clipEl.querySelector(".clip-text");
const anchor = clipEl.querySelector(".clip-text");
if (anchor && anchor.nextSibling) clipEl.insertBefore(badge, anchor.nextSibling);
else clipEl.append(badge);
}
@@ -660,16 +660,6 @@
: ((frozen && frozen.text) || scene.text || (root && root.text) || (subclip ? "cut" : "empty scene"));
clip.append(el("div", "clip-text" + (label && label !== "empty scene" && label !== "cut" && label !== "Video clip" ? "" : " 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 = sceneRatingDisplay(st, scene);
if (rating) {
const rated = el("div", "clip-rated");
+2 -15
View File
@@ -19,7 +19,7 @@
},
{
q: "Missing media for continuity or guide stack.",
a: "A bin asset or character reference was deleted or moved. Re-upload the file in Media, or clear the broken guide or character assignment in the Inspector.",
a: "A bin asset referenced by a guide was deleted or moved. Re-upload the file in Media, or clear the broken guide in the Inspector.",
},
],
},
@@ -132,7 +132,6 @@
gen_unit_id: "tour-s1",
cut_offset_frames: 0,
guides: [],
character_ids: ["tour-char-1"],
source_in: 0,
source_dur: null,
},
@@ -153,7 +152,6 @@
gen_unit_id: "tour-s2",
cut_offset_frames: 0,
guides: [],
character_ids: [],
source_in: 0.5,
source_dur: 4.2,
},
@@ -174,7 +172,6 @@
gen_unit_id: "tour-s3",
cut_offset_frames: 0,
guides: [],
character_ids: [],
source_in: 0,
source_dur: null,
},
@@ -285,15 +282,6 @@
],
mediaPreviewId: null,
shortcuts: [{ id: "tour-sh-1", name: "golden hour", triggers: ["@golden"], replacements: ["warm sunset light"] }],
characters: [{
id: "tour-char-1",
name: "Traveler",
appearance: "Mid-30s, calm expression",
body: "",
wardrobe: "Light coat",
always_include: "",
never_include: "",
}],
imageTargets: [],
ratingLabels: ["good", "great", "bad"],
};
@@ -329,7 +317,6 @@
API.listMedia = ok({ media: demo.mediaBin });
API.transitions = ok({ transitions: demo.transitions });
API.shortcuts = ok({ shortcuts: demo.shortcuts });
API.characters = ok({ characters: demo.characters });
API.nleLibrary = ok({ effects: demo.nleEffects, video_transitions: demo.nleVideoTransitions });
API.refreshModels = ok({});
API.generate = ok({ prompt_id: "tour-demo" });
@@ -503,7 +490,7 @@
id: "composer",
title: "Composer",
target: "#composer-btn",
body: "Characters, Shortcuts, and Split markers live in the Composer — a window you open from here. Create them once and reuse them across scenes instead of retyping. (Per-clip effects and video transitions are edited in Properties.)",
body: "Shortcuts and Split markers live in the Composer — a window you open from here. Create them once and reuse them across scenes instead of retyping. (Per-clip effects and video transitions are edited in Properties.)",
pad: 6,
},
{
-2
View File
@@ -65,7 +65,6 @@
scene_count: st.preview.parsed?.scenes?.length,
} : null,
models: st.models,
chars: st.characters?.length,
media: st.mediaBin?.length,
targets: st.imageTargets?.length,
});
@@ -129,7 +128,6 @@
projects: st.projects?.length,
media: st.mediaBin?.length,
mediaPreview: st.mediaPreviewId,
chars: st.characters?.length,
shortcuts: st.shortcuts?.length,
transitions: st.transitions?.length,
nleEffects: st.nleEffects?.length,
-24
View File
@@ -1235,30 +1235,6 @@ 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/nle")
async def _nle_library(_req):
try:
-32
View File
@@ -1,32 +0,0 @@
"""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"] == {}
+14 -89
View File
@@ -15,7 +15,6 @@ from movie_editor.backend.timeline import ( # noqa: E402
Project,
Scene,
STUDIO_DEFAULT_GUIDE,
build_character_bible_anchor,
build_combined_prompt,
build_auto_continuity_guides,
build_mixed_solo_guides_payload,
@@ -26,7 +25,6 @@ from movie_editor.backend.timeline import ( # noqa: E402
effective_anchor,
effective_negative_prompt,
generation_prompt_fingerprint,
normalize_character_bible,
normalize_continuity_settings,
resolve_scene_identity_pin,
collapse_generative_units,
@@ -292,84 +290,30 @@ def test_guide_stack_accumulate_prior():
assert scene3[1]["source"] == "scene" and scene3[1]["scene_index"] == 1
def test_character_bible_merges_into_anchor():
p = _project(
anchor="dimly lit apartment",
character_bible={
"name": "Nicole",
"appearance": "long red hair, green eyes",
"always_include": "photorealistic",
},
scenes=[{"text": "walks in"}],
)
anchor = effective_anchor(p)
assert anchor.startswith("Character: Nicole.")
assert "Appearance: long red hair, green eyes." in anchor
assert anchor.endswith("dimly lit apartment")
def test_effective_anchor_is_manual_text():
p = _project(anchor="dimly lit apartment", scenes=[{"text": "walks in"}])
assert effective_anchor(p) == "dimly lit apartment"
prompt = build_combined_prompt(p)
assert "Character: Nicole." in prompt
assert prompt.startswith("dimly lit apartment")
def test_character_bible_never_include_merges_negative():
def test_effective_negative_is_manual_text():
p = _project(negative_prompt="blurry, low quality", scenes=[{"text": "a"}])
assert effective_negative_prompt(p) == "blurry, low quality"
def test_project_identity_pin_from_continuity_settings():
p = _project(
negative_prompt="blurry, low quality",
character_bible={"never_include": "extra limbs, deformed hands"},
scenes=[{"text": "a"}],
)
neg = effective_negative_prompt(p)
assert "blurry" in neg
assert "extra limbs" in neg
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(
continuity_settings={"identity_pin_ref": "other"},
continuity_settings={"identity_pin_ref": "pin1"},
scenes=[
{"id": "s1", "text": "a", "source": {"type": "carry"}},
{"id": "s2", "text": "b", "source": {"type": "carry"}, "character_ids": ["c1"]},
{"id": "s2", "text": "b", "source": {"type": "carry"}},
],
)
cs = continuity_settings_for_run(p)
sc2 = p.scenes[1]
assert resolve_scene_identity_pin(sc2, char_map, cs) == "face1"
assert resolve_scene_identity_pin(cs) == "pin1"
guides = build_auto_continuity_guides(p, p)
assert guides["scenes"][1][0]["media_ref"] == "face1"
def test_character_bible_refs_in_continuity_media():
p = _project(
character_bible={"face_ref": "f1", "body_ref": "b1", "detail_ref": "d1"},
scenes=[{"text": "a"}],
)
refs = continuity_media_refs(p, p)
assert refs == ["f1", "b1", "d1"]
p.continuity_settings = {"auto_enabled": False}
assert continuity_media_refs(p, p) == []
assert guides["scenes"][0][0]["media_ref"] == "pin1"
def test_stale_identity_pin_ignored_when_auto_off():
@@ -392,25 +336,6 @@ def test_scene_anchor_media_refs():
assert scene_anchor_media_refs(p) == ["frame1"]
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"]
p.character_bible = {"name": "Alex"}
h2 = generation_prompt_fingerprint(p, p)["prompt_hash"]
assert h1 != h2
def test_projects_store_crud(tmp_path, monkeypatch):
from movie_editor.backend import config, projects
monkeypatch.setattr(config, "PROJECTS_DIR", tmp_path / "projects")