Add character bible to Movie Editor and rated badge on clips

Character bible stores name, appearance, body, wardrobe, always/never
include tags, and reference images on the project. Text merges into the
generation anchor; never_include augments the negative prompt; face ref
can sync to the identity pin. Timeline clips show "Rated: …" when set.
This commit is contained in:
digital-garbage
2026-06-10 17:30:53 +03:00
parent 80e2613a7d
commit 59f2a5f4f8
7 changed files with 279 additions and 12 deletions
+89 -6
View File
@@ -41,6 +41,86 @@ 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 build_character_bible_anchor(bible: Optional[dict]) -> str:
"""Render bible fields into anchor prose prepended before the manual anchor."""
b = normalize_character_bible(bible)
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 + ".")
return " ".join(parts).strip()
def effective_anchor(project: Project) -> str:
"""Character bible + manual anchor (project.anchor) sent to Studio."""
bible_text = build_character_bible_anchor(project.character_bible)
manual = (project.anchor or "").strip()
if bible_text and manual:
return f"{bible_text} {manual}"
return bible_text or manual
def effective_negative_prompt(project: Project) -> str:
"""Project negative prompt plus bible never_include (generation only)."""
manual = (project.negative_prompt or "").strip()
never = normalize_character_bible(project.character_bible)["never_include"]
if never and manual:
return f"{manual}, {never}"
return never or manual
def resolve_identity_pin_ref(project: Project) -> Optional[str]:
"""Face reference from the bible wins when sync_identity_pin is on."""
b = normalize_character_bible(project.character_bible)
if b["sync_identity_pin"] and b["face_ref"]:
return b["face_ref"]
return normalize_continuity_settings(project.continuity_settings).get("identity_pin_ref")
def 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 with bible-driven identity pin applied."""
cs = normalize_continuity_settings(project.continuity_settings)
pin = resolve_identity_pin_ref(project)
if pin:
cs = {**cs, "identity_pin_ref": pin}
return cs
def normalize_continuity_settings(raw: Optional[dict]) -> dict[str, Any]:
"""Automated cross-scene stability — guides, mid-scene anchor, identity pin.
@@ -104,7 +184,7 @@ def _decayed_strength(base: float, scene_index: int, decay: float) -> float:
def build_auto_continuity_guides(full: Project, target: Project) -> Optional[dict]:
"""Build per-run funpack_scene_guides when auto continuity is on."""
cs = normalize_continuity_settings(full.continuity_settings)
cs = continuity_settings_for_run(full)
if not cs["auto_enabled"] or normalize_guide_settings(full.guide_settings)["stack_enabled"]:
return None
@@ -163,8 +243,8 @@ def build_auto_continuity_guides(full: Project, target: Project) -> Optional[dic
def continuity_media_refs(full: Project, target: Project) -> list[str]:
"""Extra media-bin ids to copy for auto continuity (pin + prior anchors)."""
cs = normalize_continuity_settings(full.continuity_settings)
refs: list[str] = []
cs = continuity_settings_for_run(full)
refs: list[str] = list(character_bible_media_refs(full))
if cs["auto_enabled"] and cs["identity_pin_ref"]:
refs.append(cs["identity_pin_ref"])
payload = build_auto_continuity_guides(full, target)
@@ -359,7 +439,8 @@ class Scene:
class Project:
id: str = field(default_factory=_new_id)
name: str = "Untitled"
anchor: str = "" # text prepended to every scene (character anchor)
anchor: str = "" # extra anchor text (character bible prepends before this)
character_bible: dict = field(default_factory=dict)
# 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.
@@ -408,6 +489,7 @@ 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", "")),
@@ -519,7 +601,7 @@ def build_combined_prompt(project: Project, include_excluded: bool = False,
scenes = [s for s in project.scenes if include_excluded or not s.excluded]
units = group_generative_units(scenes)
parts: list[str] = []
anchor = (project.anchor or "").strip()
anchor = effective_anchor(project)
if anchor:
parts.append(anchor)
# Only inject separators for a MULTI-unit run: editorial subclips count as one unit.
@@ -551,12 +633,13 @@ def generation_prompt_fingerprint(project: Project, target: Optional[Project] =
gen_prompt = build_combined_prompt(tgt, for_generation=True)
text_hash = hashlib.sha256(gen_prompt.encode("utf-8")).hexdigest()[:24]
active = [s for s in tgt.scenes if not s.excluded]
cs = normalize_continuity_settings(project.continuity_settings)
cs = continuity_settings_for_run(project)
gs = normalize_guide_settings(project.guide_settings)
run_key = "\n".join([
text_hash,
"|".join(_scene_run_fingerprint(s) for s in active),
cs.get("identity_pin_ref") or "",
normalize_character_bible(project.character_bible).get("never_include") or "",
"stack" if gs["stack_enabled"] else "",
])
run_hash = hashlib.sha256(run_key.encode("utf-8")).hexdigest()[:24]
+66 -1
View File
@@ -283,6 +283,69 @@
body.append(strip);
}
function bibleRefPicker(st, label, refKey, bible) {
const ref = bible[refKey] || null;
const asset = ref ? (st.mediaBin || []).find((m) => m.id === ref) : null;
const block = el("div", "bible-ref");
const prev = el("div", "bible-ref-prev");
if (asset && asset.kind === "image") {
const img = el("img"); img.src = window.MovieEditorAPI.mediaUrl(asset.id); img.loading = "lazy"; prev.append(img);
} else prev.append(el("span", "media-icon", asset ? "▶" : "◇"));
block.append(prev);
const info = el("div", "bible-ref-info");
info.append(el("div", "bible-ref-name", asset ? asset.name : "No image selected"));
const pick = el("select");
pick.dataset.k = "bible-ref-" + refKey;
pick.append(new Option("— none —", ""));
(st.mediaBin || []).filter((m) => m.kind === "image").forEach((m) => {
const o = new Option(m.name, m.id); if (m.id === ref) o.selected = true; pick.append(o);
});
pick.onchange = () => S.patchCharacterBible({ [refKey]: pick.value || null });
info.append(field(label, pick));
block.append(info);
return block;
}
function renderCharacterBible(st) {
const bible = S.normCharacterBible(st.project.character_bible);
const tag = el("div", "insp-tag"); tag.textContent = "Character bible"; body.append(tag);
body.append(el("div", "insp-hint", "Structured character profile — merged into the anchor on generate. Face ref can drive the identity pin."));
const name = el("input"); name.value = bible.name; name.dataset.k = "bible-name";
name.placeholder = "Character name";
name.oninput = () => S.patchCharacterBible({ name: name.value }, true);
body.append(field("Name", name));
const mkTa = (key, label, rows, placeholder) => {
const ta = el("textarea"); ta.rows = rows; ta.value = bible[key] || ""; ta.dataset.k = "bible-" + key;
ta.placeholder = placeholder;
ta.oninput = () => S.patchCharacterBible({ [key]: ta.value }, true);
body.append(field(label, ta));
};
mkTa("appearance", "Appearance", 2, "Face, hair, eyes, skin, distinguishing marks…");
mkTa("body", "Body", 2, "Build, height, proportions…");
mkTa("wardrobe", "Wardrobe", 2, "Default outfit or style…");
mkTa("always_include", "Always include", 2, "Tags and phrases always kept in the anchor");
mkTa("never_include", "Never include", 2, "Appended to the negative prompt on generate");
const refTag = el("div", "insp-tag sp-sub"); refTag.textContent = "Reference images"; body.append(refTag);
const refs = el("div", "bible-refs");
refs.append(bibleRefPicker(st, "Face (identity pin)", "face_ref", bible));
refs.append(bibleRefPicker(st, "Body", "body_ref", bible));
refs.append(bibleRefPicker(st, "Detail (tattoo / accessory)", "detail_ref", bible));
body.append(refs);
const pinRow = el("label", "chk bible-pin");
const pinCb = el("input"); pinCb.type = "checkbox"; pinCb.checked = bible.sync_identity_pin;
pinCb.onchange = () => S.patchCharacterBible({ sync_identity_pin: pinCb.checked });
pinRow.append(pinCb);
pinRow.append(el("span", null, "Use face ref as identity pin"));
body.append(pinRow);
if (bible.sync_identity_pin && bible.face_ref) {
body.append(el("div", "insp-hint", "Identity pin is synced from the face reference (Engine settings → Continuity)."));
}
}
function renderProject(st) {
const p = st.project;
title.textContent = "Project";
@@ -305,9 +368,11 @@
body.append(row3);
body.append(el("div", "insp-hint", "Link FPS / Frames / Width / Height in Models → Linked inputs (Source = Project …)."));
renderCharacterBible(st);
const promptTag = el("div", "insp-tag"); promptTag.textContent = "Prompt"; body.append(promptTag);
const anchor = el("textarea"); anchor.rows = 2; anchor.value = p.anchor || ""; anchor.dataset.k = "pj-anchor";
anchor.placeholder = "Character / world description prepended to every scene";
anchor.placeholder = "Extra world / setting context (character bible prepends above this)";
anchor.oninput = () => S.patchProjectQuiet({ anchor: anchor.value });
body.append(field("Anchor", anchor));
body.append(field("Opening transition (anchor → scene 1)", transitionSelect(p.intro_transition || "",
+31 -1
View File
@@ -177,6 +177,36 @@
function patchProject(patch) { if (!state.project) return; Object.assign(state.project, patch); notify(); scheduleSave(); }
function patchProjectQuiet(patch) { if (!state.project) return; Object.assign(state.project, patch); scheduleSaveSilent(); }
function normCharacterBible(raw) {
const b = raw || {};
return {
name: b.name || "",
appearance: b.appearance || "",
body: b.body || "",
wardrobe: b.wardrobe || "",
always_include: b.always_include || "",
never_include: b.never_include || "",
face_ref: b.face_ref || null,
body_ref: b.body_ref || null,
detail_ref: b.detail_ref || null,
sync_identity_pin: b.sync_identity_pin !== false,
};
}
function _characterBiblePinPatch(bible) {
if (!bible.sync_identity_pin || !bible.face_ref) return null;
return { ...(state.project.continuity_settings || {}), identity_pin_ref: bible.face_ref };
}
function patchCharacterBible(patch, quiet) {
if (!state.project) return;
const next = { ...normCharacterBible(state.project.character_bible), ...patch };
const updates = { character_bible: next };
const pin = _characterBiblePinPatch(next);
if (pin) updates.continuity_settings = pin;
quiet ? patchProjectQuiet(updates) : patchProject(updates);
}
function scene(id) { return state.project?.scenes.find((s) => s.id === id) || null; }
function genUnitId(sc) { return (sc && sc.gen_unit_id) || (sc && sc.id) || ""; }
@@ -1179,7 +1209,7 @@
window.Store = {
get, set, subscribe, init,
refreshProjectList, loadProject, newProject, deleteProject, downloadProject, importProject,
patchProject, patchProjectQuiet, patchScene, patchSceneQuiet, flushSave, selectScene, addScene, removeScene, dismissGhost, moveScene, moveSceneTo, scene,
patchProject, patchProjectQuiet, patchCharacterBible, normCharacterBible, patchScene, patchSceneQuiet, flushSave, selectScene, addScene, removeScene, dismissGhost, moveScene, moveSceneTo, scene,
genUnitId, isGenSubclip, genUnitRoot, genUnitSceneIds,
buildPreviewSegments, previewTotalSec, segmentDurationSec,
addAudioTrack, updateAudioTrack, removeAudioTrack,
+12
View File
@@ -362,6 +362,15 @@ input[type=number] { font-family: var(--font-mono); }
.insp-tag { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 1px; color: var(--amber); text-transform: uppercase; }
.insp-hint { font-size: 11px; color: var(--faint); margin: -4px 0 12px; }
.insp-block { margin-bottom: 16px; }
.bible-refs { display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px; }
.bible-ref { display: flex; gap: 10px; align-items: flex-start; padding: 8px; background: var(--ink-2);
border: 1px solid var(--line); border-radius: 8px; }
.bible-ref-prev { width: 52px; height: 52px; flex-shrink: 0; border-radius: 6px; overflow: hidden;
background: var(--ink-0); border: 1px solid var(--line); display: flex; align-items: center; justify-content: center; }
.bible-ref-prev img { width: 100%; height: 100%; object-fit: cover; }
.bible-ref-info { flex: 1; min-width: 0; }
.bible-ref-name { font-size: 11px; color: var(--muted); margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bible-pin { margin-bottom: 12px; }
/* global prompt — first entry in the inspector list (scrolls with content) */
.insp-global { margin: 0 0 14px; padding: 10px 10px 8px;
@@ -553,6 +562,9 @@ body.tl-reordering { cursor: grabbing; user-select: none; }
.clip-text { font-size: 12px; line-height: 1.35; color: var(--text); overflow: hidden;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.clip-text.empty { color: var(--faint); font-style: italic; }
.clip-rated { font-size: 10px; font-family: var(--font-mono); color: var(--amber-2); line-height: 1.2;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; }
.clip.has-media .clip-rated { position: relative; z-index: 1; }
.clip-actions { position: absolute; top: 5px; right: 8px; display: flex; gap: 2px; opacity: 0; transition: opacity .12s; }
.clip:hover .clip-actions { opacity: 1; }
.ic-btn { width: 18px; height: 18px; padding: 0; font-size: 11px; line-height: 1; border: 1px solid var(--line);
+8
View File
@@ -246,6 +246,14 @@
const label = scene.text || (root && root.text) || (subclip ? "cut" : "empty scene");
clip.append(el("div", "clip-text" + (label && label !== "empty scene" && label !== "cut" ? "" : " empty"), label));
const rating = ((root && root.rating) || scene.rating || "").trim();
if (rating) {
const rated = el("div", "clip-rated");
rated.title = "Rated for FunPack Studio conditioning on next generation";
rated.textContent = "Rated: " + rating;
clip.append(rated);
}
const actions = el("div", "clip-actions");
const mk = (label, title, cls, fn) => { const b = el("button", "ic-btn" + (cls ? " " + cls : ""), label); b.title = title; b.onclick = (e) => { e.stopPropagation(); fn(); }; return b; };
actions.append(mk("⟈", "Split clip at playhead", "", () => {
+4 -3
View File
@@ -24,6 +24,7 @@ from .backend.timeline import (
Project,
build_combined_prompt,
collapse_generative_units,
effective_negative_prompt,
gen_unit_id,
group_generative_units,
)
@@ -235,15 +236,15 @@ def _run_sampler_inputs(target: Project, scene_count: int, full: Optional[Projec
from .backend.timeline import (
build_auto_continuity_guides,
build_scene_guides_payload,
continuity_settings_for_run,
is_mixed_source,
normalize_continuity_settings,
normalize_guide_settings,
)
proj = full or target
samp = dict(target.sampler_inputs or {})
active = [s for s in target.scenes if not s.excluded]
cs = normalize_continuity_settings(proj.continuity_settings)
cs = continuity_settings_for_run(proj)
manual_stack = normalize_guide_settings(proj.guide_settings)["stack_enabled"]
auto_guides = build_auto_continuity_guides(proj, target) if cs["auto_enabled"] else None
@@ -768,7 +769,7 @@ if web is not None and PromptServer is not None:
"num_frames_per_scene": effective_frames,
"frame_rate": target.frame_rate,
"width": target.width, "height": target.height,
"negative_prompt": target.negative_prompt or None,
"negative_prompt": effective_negative_prompt(target) or None,
"max_scenes": active_scene_count,
"studio_inputs": _run_studio_inputs(target, active_scenes, prompt_changed=prompt_changed),
"sampler_inputs": sampler_inputs,
+69 -1
View File
@@ -15,14 +15,20 @@ from movie_editor.backend.timeline import ( # noqa: E402
Project,
Scene,
STUDIO_DEFAULT_GUIDE,
build_character_bible_anchor,
build_combined_prompt,
generation_prompt_fingerprint,
build_auto_continuity_guides,
build_mixed_solo_guides_payload,
build_scene_anchors_payload,
build_scene_guides_payload,
continuity_media_refs,
continuity_settings_for_run,
effective_anchor,
effective_negative_prompt,
generation_prompt_fingerprint,
normalize_character_bible,
normalize_continuity_settings,
resolve_identity_pin_ref,
collapse_generative_units,
group_generative_units,
is_mixed_source,
@@ -268,6 +274,68 @@ 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")
prompt = build_combined_prompt(p)
assert "Character: Nicole." in prompt
def test_character_bible_never_include_merges_negative():
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_character_bible_face_ref_drives_identity_pin():
p = _project(
character_bible={"face_ref": "face1", "sync_identity_pin": True},
continuity_settings={"identity_pin_ref": "other"},
scenes=[
{"id": "s1", "text": "a", "source": {"type": "carry"}},
{"id": "s2", "text": "b", "source": {"type": "carry"}},
],
)
assert resolve_identity_pin_ref(p) == "face1"
cs = continuity_settings_for_run(p)
assert cs["identity_pin_ref"] == "face1"
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"]
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")