Commit 2f5671874869d3f1292f4d87ec2f2c6e0d2fb9b5

Authored by yibo
1 parent 2cc989c9

Add Stage2 prompt assembler with word-budget truncation (T7)

buildStage2Prompt() renders MVCP M.15's confirmed one-step six-section
template and enforces the two dynamic risks M.15.1 flagged: caps
appearanceDescriptor/garmentDescriptor at ~40 words each, and
truncates motionDescriptor if it alone would push detailed_description
past H6's 500-word cap (the only placeholder that lands in that
section).

Also fixes the footwear-drift the S1 spike found (model self-selected
shoes when the prompt didn't constrain them) — pins footwear to
<Picture 1> in both subject_definitions and retention_analysis, and
says <Picture 2> doesn't affect it. Propagated the same fix into the
M.15 template in the MVCP doc so the doc and the code don't drift.

Tests check the mechanical M.15.1 items that are checkable from the
rendered string (H1/H5/H6/H9) plus the truncation behavior and the
footwear fix. H13 (reference counts) is a submission-payload concern,
covered by T8's tests instead.
docs/todo/dressup-dance-room-mvcp.md
... ... @@ -280,18 +280,19 @@ erDiagram
280 280 `spike/s1-s2-s4-2026-09-02/RESULTS.md`),原来为兜底两步法保留的
281 281 `<Picture 1>`=合成图版本已删除,不再维护两份模板。
282 282  
283   -⚠️ **实测中发现的一个副作用**:产出视频里角色的鞋子是模型自己选的(本次
284   -测试自作主张配了黑色短靴),prompt 里没提鞋子这条约束。跟
285   -`CODEX-IMAGEGEN.md` 记录的 Stage1 图片编辑"鞋子自作主张"是同一类问题——
286   -**正式模板应在 `detailed_description` 或 `retention_analysis` 里显式规定
287   -鞋子处理方式**(沿用人物图的鞋、还是随衣服图搭配、还是不入镜),避免同一
288   -角色不同视频里鞋子不一致。下面的模板尚未加入这条修正,留给实现时补上。
  283 +⚠️ **实测中发现的一个副作用,已在下面的模板里修正(T7,2026-09-02)**:
  284 +产出视频里角色的鞋子原本是模型自己选的(S1 实测自作主张配了黑色短靴),
  285 +prompt 里没提鞋子这条约束。跟 `CODEX-IMAGEGEN.md` 记录的 Stage1 图片编辑
  286 +"鞋子自作主张"是同一类问题——**规则定为"沿用人物图 `<Picture 1>` 的鞋,
  287 +`<Picture 2>` 衣服图不影响鞋子"**,已写进 `subject_definitions`/
  288 +`retention_analysis` 两段(各加一句,不占 `detailed_description` 的词数
  289 +预算)。
289 290  
290 291 ```
291 292 subject_definitions:
292 293 <Subject 1> is the young woman whose visual identity is defined by <Picture 1>: {{appearanceDescriptor}}. Preserve her face, hairstyle, and body exactly as shown; only her outfit, pose, motion, and framing change in the target video.
293   -<Picture 1> is the character reference frame for <Subject 1>, showing her face, hairstyle, and body in a neutral standing pose, wearing a neutral outfit that does not appear in the target video.
294   -<Picture 2> is the garment reference image: {{garmentDescriptor}}, laid flat with no person wearing it. Only its garment attributes (silhouette, color, fabric, trim, cut) transfer onto <Subject 1>; no other content of this image appears in the target video.
  294 +<Picture 1> is the character reference frame for <Subject 1>, showing her face, hairstyle, and body in a neutral standing pose, wearing a neutral outfit that does not appear in the target video; her footwear as shown here is retained unchanged in the target video.
  295 +<Picture 2> is the garment reference image: {{garmentDescriptor}}, laid flat with no person wearing it. Only its garment attributes (silhouette, color, fabric, trim, cut) transfer onto <Subject 1>; no other content of this image appears in the target video, and it does not affect her footwear.
295 296 <Video 1> is the reference choreography clip. It defines the dance structure, phrasing, and camera rhythm that the target video follows; it is not a visual identity source, and none of its performer, background, or imagery appears in the target video.
296 297 <Audio 1> is the background music track carried on <Video 1>, reused verbatim as the music bed of the target video. It contains no speech.
297 298  
... ... @@ -299,8 +300,8 @@ summary:
299 300 [reference generation + attribute transfer + audio reuse] The target video shows <Subject 1>, now wearing the outfit transferred from <Picture 2>, performing {{motionDescriptor}} in a vertical studio setting, following the pacing and camera rhythm established in <Video 1>, with the music of <Audio 1> reused as its track.
300 301  
301 302 retention_analysis:
302   -<Subject 1> (appears in [Shot 1]): fully_preserved - her face, hairstyle, and body are retained unchanged throughout; her outfit changes to the garment shown in <Picture 2>.
303   -<Picture 1>: fully_preserved - it supplies the face, hairstyle, and body of <Subject 1> and the opening framing of the shot.
  303 +<Subject 1> (appears in [Shot 1]): fully_preserved - her face, hairstyle, and body are retained unchanged throughout; her outfit changes to the garment shown in <Picture 2>, her footwear stays as shown in <Picture 1>.
  304 +<Picture 1>: fully_preserved - it supplies the face, hairstyle, body, and footwear of <Subject 1> and the opening framing of the shot.
304 305 <Picture 2>: attribute_transfer - only the garment's silhouette, color, fabric, and trim transfer onto <Subject 1>; the image itself does not appear.
305 306 <Video 1>: weak_reference - only the choreography phrasing and the camera rhythm are referenced; no performer, background, or imagery from it is retained.
306 307 <Audio 1>: fully_copy - the music track is reused verbatim as the non-diegetic music bed.
... ...
prompt-template.js 0 → 100644
  1 +// Stage2 six-section prompt assembler (dev-plan.md T7; template source of
  2 +// truth: docs/todo/dressup-dance-room-mvcp.md M.15, one-step version
  3 +// confirmed by the S1 spike). Includes the footwear fix M.15 flagged as
  4 +// still outstanding ("her footwear... is retained unchanged" / "does not
  5 +// affect her footwear") so the same "shoes self-selected" drift the S1
  6 +// spike hit doesn't recur.
  7 +const MAX_DESCRIPTOR_WORDS = 40; // ~2-3 concrete features, per docs/h3skill.md's own guidance
  8 +const DETAILED_DESCRIPTION_MAX_WORDS = 500; // H6
  9 +
  10 +function words(text) {
  11 + return text.trim().split(/\s+/).filter(Boolean);
  12 +}
  13 +
  14 +function truncateWords(text, maxWords) {
  15 + const w = words(text);
  16 + return w.length <= maxWords ? text.trim() : w.slice(0, maxWords).join(" ");
  17 +}
  18 +
  19 +function wordCount(text) {
  20 + return words(text).length;
  21 +}
  22 +
  23 +function extractSection(prompt, name, nextName) {
  24 + const re = new RegExp(`^${name}:\\n([\\s\\S]*?)\\n\\n${nextName}:`, "m");
  25 + const match = prompt.match(re);
  26 + return match ? match[1] : "";
  27 +}
  28 +
  29 +function render({ appearanceDescriptor, garmentDescriptor, motionDescriptor }) {
  30 + return `subject_definitions:
  31 +<Subject 1> is the young woman whose visual identity is defined by <Picture 1>: ${appearanceDescriptor}. Preserve her face, hairstyle, and body exactly as shown; only her outfit, pose, motion, and framing change in the target video.
  32 +<Picture 1> is the character reference frame for <Subject 1>, showing her face, hairstyle, and body in a neutral standing pose, wearing a neutral outfit that does not appear in the target video; her footwear as shown here is retained unchanged in the target video.
  33 +<Picture 2> is the garment reference image: ${garmentDescriptor}, laid flat with no person wearing it. Only its garment attributes (silhouette, color, fabric, trim, cut) transfer onto <Subject 1>; no other content of this image appears in the target video, and it does not affect her footwear.
  34 +<Video 1> is the reference choreography clip. It defines the dance structure, phrasing, and camera rhythm that the target video follows; it is not a visual identity source, and none of its performer, background, or imagery appears in the target video.
  35 +<Audio 1> is the background music track carried on <Video 1>, reused verbatim as the music bed of the target video. It contains no speech.
  36 +
  37 +summary:
  38 +[reference generation + attribute transfer + audio reuse] The target video shows <Subject 1>, now wearing the outfit transferred from <Picture 2>, performing ${motionDescriptor} in a vertical studio setting, following the pacing and camera rhythm established in <Video 1>, with the music of <Audio 1> reused as its track.
  39 +
  40 +retention_analysis:
  41 +<Subject 1> (appears in [Shot 1]): fully_preserved - her face, hairstyle, and body are retained unchanged throughout; her outfit changes to the garment shown in <Picture 2>, her footwear stays as shown in <Picture 1>.
  42 +<Picture 1>: fully_preserved - it supplies the face, hairstyle, body, and footwear of <Subject 1> and the opening framing of the shot.
  43 +<Picture 2>: attribute_transfer - only the garment's silhouette, color, fabric, and trim transfer onto <Subject 1>; the image itself does not appear.
  44 +<Video 1>: weak_reference - only the choreography phrasing and the camera rhythm are referenced; no performer, background, or imagery from it is retained.
  45 +<Audio 1>: fully_copy - the music track is reused verbatim as the non-diegetic music bed.
  46 +
  47 +detailed_description:
  48 +A vibrant, mobile-first vertical dance video with warm, glossy studio lighting, saturated color, and a clean contemporary look sustained from the first frame to the last. [Shot 1] The shot begins from <Picture 1>, with <Subject 1> now wearing the outfit transferred from <Picture 2>. She stands centered in a medium-full vertical frame against a softly lit seamless backdrop that falls off into gentle shadow toward the edges. A warm key light sits slightly camera-left, a cooler rim light separates her shoulders and hair from the background, and a soft fill keeps her face evenly exposed. She begins in a relaxed neutral stance, weight settled on her back foot, hands loose at her sides, chin level with the lens. On the first musical accent she starts ${motionDescriptor}, and the routine unfolds continuously from there with no cut. Her weight transfers smoothly from foot to foot on the beat, hips leading each transfer and shoulders following a fraction behind, so the movement reads as one connected chain rather than a series of separate poses. Her arms trace clean, unhurried arcs that extend fully at the peak of each phrase and soften on the release, wrists relaxed and fingers naturally extended. Between phrases she adds small directional changes: a quarter turn away from the lens and back, a step that closes distance with the camera, a shift of gaze that returns to the lens on the downbeat. The camera holds a Static Shot through the opening beats, then performs a slow Push In with small amplitude at slow speed, tightening from medium-full to medium while keeping her head and hands inside the frame at every point of the routine. Her expression stays confident and playful, with a light smile and eyes that meet the lens at the end of each phrase. The fabric of the transferred outfit swings and settles naturally with each step, turn, and arm extension, and her hair carries the momentum of her head movement half a beat behind it. The pacing, phrasing, and camera rhythm follow the structure established in <Video 1>: the routine builds energy through the middle of the clip, reaches its widest and fastest movement around the two-thirds point, then settles back toward stillness. The closing beats return her to a stance, framing, and lighting that closely match her opening position, so the clip loops cleanly for repeated live playback. No text, graphics, watermarks, or overlays appear anywhere in the frame at any point.
  49 +
  50 +overall_soundscape:
  51 +Soft ambient room tone of an enclosed studio sits under the music, with faint fabric rustle as <Subject 1> moves and light rhythmic footfall on the floor as her weight transfers. No speech, no crowd, and no off-screen sound events occur at any point.
  52 +
  53 +non_diegetic_music:
  54 +An upbeat instrumental track drives the routine from the first frame to the last; its tempo, instrumentation, and dynamics are copied verbatim from <Audio 1>. Its accents align with the weight transfers and arm extensions of <Subject 1>, and its energy build through the middle of the clip matches the build in her movement.`;
  55 +}
  56 +
  57 +// Builds the Stage2 prompt from a character/garment/dance-ref triple,
  58 +// truncating descriptors that would blow the word budgets in M.15.1.
  59 +// character/garment/danceRef are the raw store records (M.3/M.4/M.5).
  60 +function buildStage2Prompt({ character, garment, danceRef }) {
  61 + let appearanceDescriptor = truncateWords(character.appearanceDescriptor, MAX_DESCRIPTOR_WORDS);
  62 + let garmentDescriptor = truncateWords(garment.appearanceDescriptor, MAX_DESCRIPTOR_WORDS);
  63 + let motionDescriptor = danceRef.motionDescriptor;
  64 +
  65 + let prompt = render({ appearanceDescriptor, garmentDescriptor, motionDescriptor });
  66 + const detailedDescriptionWords = wordCount(extractSection(prompt, "detailed_description", "overall_soundscape"));
  67 +
  68 + // motionDescriptor is the only placeholder inside detailed_description
  69 + // (H6's 350-500 word section) — if it alone pushes the section over
  70 + // budget, trim it and re-render. It appears in `summary` too, but that
  71 + // section has no word cap (H10 caps it by sentence count, not words).
  72 + if (detailedDescriptionWords > DETAILED_DESCRIPTION_MAX_WORDS) {
  73 + const excess = detailedDescriptionWords - DETAILED_DESCRIPTION_MAX_WORDS;
  74 + const motionWords = wordCount(motionDescriptor);
  75 + motionDescriptor = truncateWords(motionDescriptor, Math.max(1, motionWords - excess));
  76 + prompt = render({ appearanceDescriptor, garmentDescriptor, motionDescriptor });
  77 + }
  78 +
  79 + return { prompt, appearanceDescriptor, garmentDescriptor, motionDescriptor };
  80 +}
  81 +
  82 +module.exports = { buildStage2Prompt, truncateWords, wordCount, extractSection, MAX_DESCRIPTOR_WORDS, DETAILED_DESCRIPTION_MAX_WORDS };
... ...
prompt-template.test.js 0 → 100644
  1 +// Checks the Stage2 prompt assembler (dev-plan.md T7) against the
  2 +// mechanical items from MVCP M.15.1, plus the truncation behavior and the
  3 +// footwear fix (S1 spike finding). Run: node --test prompt-template.test.js
  4 +const test = require("node:test");
  5 +const assert = require("node:assert/strict");
  6 +const { buildStage2Prompt, truncateWords, wordCount, extractSection, MAX_DESCRIPTOR_WORDS } = require("./prompt-template.js");
  7 +
  8 +const SAMPLE = {
  9 + character: { appearanceDescriptor: "shoulder-length straight black hair, a slender build, natural fair skin, and a friendly neutral expression" },
  10 + garment: { appearanceDescriptor: "a fitted red qipao-style dress with gold trim embroidery and a side slit" },
  11 + danceRef: { motionDescriptor: "a lively hip-hop inspired routine with hip sways and raised-arm turns" },
  12 +};
  13 +
  14 +test("truncateWords/wordCount behave as expected", () => {
  15 + assert.equal(wordCount("one two three"), 3);
  16 + assert.equal(truncateWords("one two three", 5), "one two three");
  17 + assert.equal(truncateWords("one two three four five six", 3), "one two three");
  18 +});
  19 +
  20 +test("M.15.1 H6: detailed_description is 350-500 words for a representative fill", () => {
  21 + const { prompt } = buildStage2Prompt(SAMPLE);
  22 + const n = wordCount(extractSection(prompt, "detailed_description", "overall_soundscape"));
  23 + assert.ok(n >= 350 && n <= 500, `detailed_description word count ${n} outside 350-500`);
  24 +});
  25 +
  26 +test("M.15.1 H1: total prompt stays well under the 7000-character cap", () => {
  27 + const { prompt } = buildStage2Prompt(SAMPLE);
  28 + assert.ok(prompt.length < 7000, `prompt is ${prompt.length} chars`);
  29 +});
  30 +
  31 +test("M.15.1 H9: retention_analysis only uses whitelisted relation markers", () => {
  32 + const { prompt } = buildStage2Prompt(SAMPLE);
  33 + const analysis = extractSection(prompt, "retention_analysis", "detailed_description");
  34 + const WHITELIST = ["fully_preserved", "weak_reference", "fully_copy", "attribute_transfer"];
  35 + const found = analysis.match(/- ([a-z_]+) -|: ([a-z_]+) -/g) || [];
  36 + assert.ok(found.length > 0, "expected to find relation markers in retention_analysis");
  37 + for (const marker of WHITELIST) assert.ok(analysis.includes(marker), `expected marker "${marker}" to appear`);
  38 +});
  39 +
  40 +test("M.15.1 H5: single-shot clip (every [Shot N] reference is shot 1), no MM:SS timestamps", () => {
  41 + const { prompt } = buildStage2Prompt(SAMPLE);
  42 + const shotRefs = prompt.match(/\[Shot (\d+)\]/g) || [];
  43 + assert.ok(shotRefs.length > 0, "expected at least one [Shot N] reference");
  44 + assert.ok(shotRefs.every((s) => s === "[Shot 1]"), `expected every shot reference to be [Shot 1], got: ${shotRefs}`);
  45 + assert.doesNotMatch(prompt, /At \d{2}:\d{2}\.\d{3}/);
  46 +});
  47 +
  48 +test("footwear fix (S1 spike finding): prompt pins footwear to <Picture 1> and says <Picture 2> doesn't affect it", () => {
  49 + const { prompt } = buildStage2Prompt(SAMPLE);
  50 + assert.match(prompt, /footwear.*retained unchanged/);
  51 + assert.match(prompt, /does not affect her footwear/);
  52 +});
  53 +
  54 +test("appearanceDescriptor/garmentDescriptor over the word budget get truncated", () => {
  55 + const longDescriptor = Array.from({ length: 100 }, (_, i) => `word${i}`).join(" ");
  56 + const { prompt, appearanceDescriptor, garmentDescriptor } = buildStage2Prompt({
  57 + character: { appearanceDescriptor: longDescriptor },
  58 + garment: { appearanceDescriptor: longDescriptor },
  59 + danceRef: SAMPLE.danceRef,
  60 + });
  61 + assert.equal(wordCount(appearanceDescriptor), MAX_DESCRIPTOR_WORDS);
  62 + assert.equal(wordCount(garmentDescriptor), MAX_DESCRIPTOR_WORDS);
  63 + assert.ok(!prompt.includes("word99"), "expected the tail of the long descriptor to be gone");
  64 +});
  65 +
  66 +test("an oversized motionDescriptor is truncated so detailed_description stays within the 500-word H6 cap", () => {
  67 + const longMotion = Array.from({ length: 300 }, (_, i) => `move${i}`).join(" ");
  68 + const { prompt, motionDescriptor } = buildStage2Prompt({ ...SAMPLE, danceRef: { motionDescriptor: longMotion } });
  69 + const n = wordCount(extractSection(prompt, "detailed_description", "overall_soundscape"));
  70 + assert.ok(n <= 500, `detailed_description word count ${n} exceeds 500 after truncation`);
  71 + assert.ok(wordCount(motionDescriptor) < 300, "expected motionDescriptor itself to have been shortened");
  72 +});
... ...