From a534f8bb6fae585a2b5ed51aae7db6d66a844174 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:08:48 +0300 Subject: [PATCH] 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 --- movie_editor/backend/characters.py | 94 ------------- movie_editor/backend/projects.py | 2 +- movie_editor/backend/timeline.py | 184 ++------------------------ movie_editor/frontend/api.js | 3 - movie_editor/frontend/mediabrowser.js | 2 +- movie_editor/frontend/store.js | 73 +--------- movie_editor/frontend/styles.css | 5 - movie_editor/frontend/timeline.js | 12 +- movie_editor/frontend/tour.js | 17 +-- movie_editor/frontend/view_bus.js | 2 - movie_editor/server.py | 24 ---- movie_editor/tests/test_characters.py | 32 ----- movie_editor/tests/test_timeline.py | 103 ++------------ 13 files changed, 34 insertions(+), 519 deletions(-) delete mode 100644 movie_editor/backend/characters.py delete mode 100644 movie_editor/tests/test_characters.py diff --git a/movie_editor/backend/characters.py b/movie_editor/backend/characters.py deleted file mode 100644 index 98de7b48..00000000 --- a/movie_editor/backend/characters.py +++ /dev/null @@ -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)} \ No newline at end of file diff --git a/movie_editor/backend/projects.py b/movie_editor/backend/projects.py index f2a9b9e5..fd9e9cbe 100644 --- a/movie_editor/backend/projects.py +++ b/movie_editor/backend/projects.py @@ -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) diff --git a/movie_editor/backend/timeline.py b/movie_editor/backend/timeline.py index 2bf2a107..45a76cdf 100644 --- a/movie_editor/backend/timeline.py +++ b/movie_editor/backend/timeline.py @@ -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] diff --git a/movie_editor/frontend/api.js b/movie_editor/frontend/api.js index 87125144..9a20a2dd 100644 --- a/movie_editor/frontend/api.js +++ b/movie_editor/frontend/api.js @@ -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 diff --git a/movie_editor/frontend/mediabrowser.js b/movie_editor/frontend/mediabrowser.js index 56bd98d9..679c8cf7 100644 --- a/movie_editor/frontend/mediabrowser.js +++ b/movie_editor/frontend/mediabrowser.js @@ -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; diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index be200ef8..04593b60 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -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, diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 40ea21f6..0343fe8d 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -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; } diff --git a/movie_editor/frontend/timeline.js b/movie_editor/frontend/timeline.js index 71bb727e..e61c7c31 100644 --- a/movie_editor/frontend/timeline.js +++ b/movie_editor/frontend/timeline.js @@ -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"); diff --git a/movie_editor/frontend/tour.js b/movie_editor/frontend/tour.js index d72ae9c7..d43e5e22 100644 --- a/movie_editor/frontend/tour.js +++ b/movie_editor/frontend/tour.js @@ -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, }, { diff --git a/movie_editor/frontend/view_bus.js b/movie_editor/frontend/view_bus.js index 6e9bceca..83486016 100644 --- a/movie_editor/frontend/view_bus.js +++ b/movie_editor/frontend/view_bus.js @@ -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, diff --git a/movie_editor/server.py b/movie_editor/server.py index 98d36330..04fc045b 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -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: diff --git a/movie_editor/tests/test_characters.py b/movie_editor/tests/test_characters.py deleted file mode 100644 index e65c7e67..00000000 --- a/movie_editor/tests/test_characters.py +++ /dev/null @@ -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"] == {} \ No newline at end of file diff --git a/movie_editor/tests/test_timeline.py b/movie_editor/tests/test_timeline.py index f13ca104..9d8f5fc3 100644 --- a/movie_editor/tests/test_timeline.py +++ b/movie_editor/tests/test_timeline.py @@ -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")