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:
co-authored by
Claude Opus 4.8
parent
41e0cc48d5
commit
a534f8bb6f
@@ -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)}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"] == {}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user