fix: serialize Director prompts

This commit is contained in:
Darksidewalker
2026-08-16 21:56:30 +02:00
parent 36b9b3a788
commit 009cd47509
4 changed files with 50 additions and 77 deletions
+15 -7
View File
@@ -58,8 +58,7 @@ function builderHasContent(builder) {
const ref = builder.ref;
return !!(ref && typeof ref === "object" && (
["subject_definitions", "summary", "retention_analysis", "detailed_description", "soundscape"].some(key => textValue(ref[key])) ||
(Array.isArray(ref.subject_defs) && ref.subject_defs.length) ||
(Array.isArray(ref.retention) && ref.retention.length)
(Array.isArray(ref.subject_defs) && ref.subject_defs.length) || (Array.isArray(ref.retention) && ref.retention.length)
));
}
function legacyPrompt(state, widgetPrompt) {
@@ -225,7 +224,7 @@ function install(node) {
}));
}, { passive: false });
const setStatus = (message, isError = false) => { status.textContent = message; status.classList.toggle("error", isError); };
const emit = () => { builderState.mode = mode(); state.builder_state = builderState; dataWidget.value = JSON.stringify(state); dataWidget.callback?.(dataWidget.value); if (builderWidget) { builderWidget.value = JSON.stringify(builderState); builderWidget.callback?.(builderWidget.value); } node.graph?.setDirtyCanvas(true, true); };
const emit = () => { builderState.mode = mode(); const duration = Number(node.widgets?.find(w => w.name === "duration")?.value); if (Number.isFinite(duration)) builderState.duration = duration; const resolved = builderPromptForWidget(builderState, mode()); if (promptWidget && promptWidget.value !== resolved) { promptWidget.value = resolved; promptWidget.callback?.(resolved); } state.builder_state = builderState; state.resolved_prompt = resolved; dataWidget.value = JSON.stringify(state); dataWidget.callback?.(dataWidget.value); if (builderWidget) { builderWidget.value = JSON.stringify(builderState); builderWidget.callback?.(builderWidget.value); } node.graph?.setDirtyCanvas(true, true); };
const promptStyle = () => { const v = builderState?.prompt_mode; return v === "simple" || v === "structured" ? v : "structured"; };
function legacySimplePromptFor(m) {
if (m === "REF2VA") {
@@ -245,6 +244,18 @@ function install(node) {
`non_diegetic_music: ${builderState.music || "N/A"}`,
].filter(Boolean).join("\n");
}
function builderPromptForWidget(builder, m) {
if (builder?.prompt_mode === "simple" && typeof builder.simple_prompt === "string") return builder.simple_prompt;
const r = builder?.ref || {};
if (m === "REF2VA") return `subject_definitions:\n${r.subject_definitions || ""}\n\nsummary:\n${r.summary || ""}\n\nretention_analysis:\n${r.retention_analysis || ""}\n\ndetailed_description:\n${r.detailed_description || ""}\n\noverall_soundscape:\n${r.soundscape || ""}\n\nnon_diegetic_music:\n${r.music || "N/A"}`;
const music = builder?.music || "N/A";
let head = "";
if (m === "I2VA") head = "For the target video, at 0.00 seconds into the target video, <Picture 1> (from [Shot 1]) is fully referenced.";
else if (m === "FL2VA") head = "How the reference pictures align with the target video — Picture 1 (from Shot 1) aligns with the 0.00-second mark of the target video; Picture 2 (from Shot 2) aligns with the end mark of the target video.";
else if (m === "L2VA") head = "How the reference pictures align with the target video — <Picture 1> (from [Shot 1]) aligns with the end mark of the target video.";
const body = `integrated_multimodal_description: ${builder?.imd || ""}\n\noverall_soundscape: ${builder?.soundscape || ""}\n\nnon_diegetic_music: ${music}`;
return head ? `${head}\n\n${body}` : body;
}
function previewTextFor(m, external) {
if (external) {
const t = String(externalPromptWidget()?.value || "").trim();
@@ -851,17 +862,14 @@ function install(node) {
builderState = baseDefaults;
builderState.mode = m;
}
// Workflows created before the builder stored complex text in the ordinary
// prompt widget/timeline payload. Keep that exact text editable and queue
// it unchanged instead of replacing it with blank structured fields.
if (!builderHasContent(builderState)) {
const preserved = legacyPrompt(state, promptWidget?.value);
if (preserved) {
builderState.prompt_mode = "simple";
builderState.simple_prompt = preserved;
emit();
}
}
emit();
syncNodeBounds();
render();
void Promise.all(state.items.filter(item => item.type === "video" && !item.thumbnail).map(async item => {
+17 -26
View File
@@ -250,11 +250,7 @@ def _build_simple_prompt(state: dict) -> str:
def has_builder_content(state: dict) -> bool:
"""Return whether a builder state contains an authored prompt.
Empty default fields (including the assembly-time ``N/A`` music fallback)
must not prevent a pre-builder Director prompt from being migrated.
"""
"""Return whether a builder state contains authored prompt text."""
if not isinstance(state, dict):
return False
if _ensure_str(state.get("simple_prompt")):
@@ -262,28 +258,19 @@ def has_builder_content(state: dict) -> bool:
if _ensure_str(state.get("imd")) or _ensure_str(state.get("soundscape")):
return True
ref = state.get("ref")
if not isinstance(ref, dict):
return False
return any(_ensure_str(ref.get(key)) for key in (
"subject_definitions", "summary", "retention_analysis",
"detailed_description", "soundscape",
)) or bool(ref.get("subject_defs")) or bool(ref.get("retention"))
return isinstance(ref, dict) and (
any(_ensure_str(ref.get(key)) for key in (
"subject_definitions", "summary", "retention_analysis",
"detailed_description", "soundscape",
)) or bool(ref.get("subject_defs")) or bool(ref.get("retention"))
)
def migrate_legacy_prompt(builder: dict, timeline_state: dict, widget_prompt: object = "") -> bool:
"""Preserve old Director prompts as a lossless Simple prompt.
Earlier Director revisions stored the editable prompt in the normal prompt
widget or timeline JSON, before ``builder_state`` existed. Prefer the
previously resolved text because it is the exact generation prompt; fall
back to the old global prompt plus ordered prompt blocks. Existing builder
content always wins, so loading a current workflow is idempotent.
"""
"""Move a pre-builder Director prompt into lossless Simple-prompt state."""
if not isinstance(builder, dict) or has_builder_content(builder):
return False
state = timeline_state if isinstance(timeline_state, dict) else {}
candidates = [state.get("resolved_prompt"), state.get("full_prompt")]
legacy_global = widget_prompt if _ensure_str(widget_prompt) else state.get("prompt")
raw_blocks = state.get("prompt_blocks")
blocks = raw_blocks if isinstance(raw_blocks, list) else []
ordered_blocks = sorted(enumerate(blocks), key=lambda pair: (
@@ -291,11 +278,15 @@ def migrate_legacy_prompt(builder: dict, timeline_state: dict, widget_prompt: ob
int(pair[1].get("order", pair[0])) if isinstance(pair[1], dict) else pair[0],
pair[0],
))
candidates.append("\n".join([
_ensure_str(legacy_global),
*[_ensure_str(block.get("text")) for _, block in ordered_blocks
if isinstance(block, dict) and block.get("enabled", True)],
]))
legacy_global = widget_prompt if _ensure_str(widget_prompt) else state.get("prompt")
candidates = [
state.get("resolved_prompt"),
state.get("full_prompt"),
"\n".join([_ensure_str(legacy_global), *[
_ensure_str(block.get("text")) for _, block in ordered_blocks
if isinstance(block, dict) and block.get("enabled", True)
]]),
]
for candidate in candidates:
text = _ensure_str(candidate)
if text:
+4 -2
View File
@@ -183,8 +183,10 @@ class MiniMaxH3Director:
resolved = external_prompt_overwrite
else:
resolved = build_prompt(merged)
if not any(str(merged.get(key) or "").strip() for key in ("imd", "soundscape")) and mode != "REF2VA":
resolved = build_prompt(merged) if migrated_legacy_prompt else assemble_prompt(prompt, blocks)
if (not migrated_legacy_prompt and
not any(str(merged.get(key) or "").strip() for key in ("imd", "soundscape")) and
mode != "REF2VA"):
resolved = assemble_prompt(prompt, blocks)
for issue in validate_builder_state(merged):
log_dasiwa("MiniMax H3 Director", f"[{issue['level'].upper()}] {issue['msg']}")
guide = {
@@ -1,4 +1,4 @@
"""Regression tests for preserving pre-builder MiniMax Director prompts."""
"""Regression tests for Director prompt migration and standard prompt output."""
from nodes.helper_minimax_h3_prompt_builder import (
build_prompt,
default_builder_state,
@@ -7,56 +7,28 @@ from nodes.helper_minimax_h3_prompt_builder import (
from nodes.nodes_minimax_h3_director import MiniMaxH3Director
def test_migrates_legacy_widget_prompt_losslessly():
def test_legacy_widget_prompt_is_preserved_losslessly():
prompt = "Detailed old prompt\n<d>[English] Exact dialogue.</d>"
builder = default_builder_state("FL2VA")
prompt = "A complex scene\n<d>[English] Keep every token.</d>"
assert migrate_legacy_prompt(builder, {}, prompt)
assert builder["prompt_mode"] == "simple"
assert builder["simple_prompt"] == prompt
assert build_prompt(builder) == prompt
def test_prefers_exact_prior_resolved_prompt_over_legacy_parts():
builder = default_builder_state("REF2VA")
state = {
"resolved_prompt": "Exact former generation prompt\nwith all sections intact",
"prompt": "short old prompt",
"prompt_blocks": [{"text": "old block"}],
}
assert migrate_legacy_prompt(builder, state, "widget prompt")
assert build_prompt(builder) == state["resolved_prompt"]
def test_legacy_prompt_blocks_are_ordered_and_disabled_blocks_excluded():
builder = default_builder_state("T2VA")
state = {
"prompt": "Global prompt",
"prompt_blocks": [
{"text": "Second", "start": 2, "order": 1},
{"text": "Disabled", "start": 0, "enabled": False},
{"text": "First", "start": 1, "order": 0},
],
}
assert migrate_legacy_prompt(builder, state)
assert build_prompt(builder) == "Global prompt\nFirst\nSecond"
def test_never_overwrites_current_builder_content():
builder = default_builder_state("FL2VA")
builder["imd"] = "Current authored prompt"
assert not migrate_legacy_prompt(builder, {"resolved_prompt": "Old prompt"}, "Old widget")
assert "simple_prompt" not in builder
def test_director_execution_preserves_legacy_prompt_in_guide():
prompt = "Detailed legacy prompt\n<d>[English] Exact dialogue.</d>"
def test_director_uses_legacy_prompt_when_builder_is_absent():
prompt = "Former video generation prompt"
guide = MiniMaxH3Director().build_guide(
"T2VA", prompt, 1344, 768, 5, "match", "{}", "",
)[0]
assert guide["resolved_prompt"] == prompt
assert guide["builder_state"]["simple_prompt"] == prompt
assert guide["builder_state"]["simple_prompt"] == prompt
def test_frontend_keeps_standard_prompt_widget_serialized():
source = open("js/minimax_h3_director.js", encoding="utf-8").read()
assert "promptWidget.value = resolved" in source
assert "promptWidget.callback?.(resolved)" in source
assert "state.resolved_prompt = resolved" in source