diff --git a/movie_editor/backend/timeline.py b/movie_editor/backend/timeline.py index bd5caaf4..586f2b04 100644 --- a/movie_editor/backend/timeline.py +++ b/movie_editor/backend/timeline.py @@ -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] diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js index 6ad1267a..fb62dac0 100644 --- a/movie_editor/frontend/inspector.js +++ b/movie_editor/frontend/inspector.js @@ -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 || "", diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 27443c3e..176cb130 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -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, diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 5ed23652..7a3a3467 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -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); diff --git a/movie_editor/frontend/timeline.js b/movie_editor/frontend/timeline.js index 4dad8cc2..c101862e 100644 --- a/movie_editor/frontend/timeline.js +++ b/movie_editor/frontend/timeline.js @@ -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", "", () => { diff --git a/movie_editor/server.py b/movie_editor/server.py index dcadf175..8198fb98 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -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, diff --git a/movie_editor/tests/test_timeline.py b/movie_editor/tests/test_timeline.py index 79025d9e..9fa263a7 100644 --- a/movie_editor/tests/test_timeline.py +++ b/movie_editor/tests/test_timeline.py @@ -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")