@wji.ai/cli 0.8.2 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wji.ai/cli",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "description": "Command line interface for the WJI AI platform",
5
5
  "license": "UNLICENSED",
6
6
  "author": "WJI AI",
@@ -28,18 +28,19 @@
28
28
  "README.md"
29
29
  ],
30
30
  "engines": {
31
- "node": ">=20"
31
+ "node": ">=22.23.4"
32
+ },
33
+ "scripts": {
34
+ "build": "tsup && chmod +x dist/index.js",
35
+ "dev": "tsup --watch",
36
+ "typecheck": "tsc -p tsconfig.json --noEmit",
37
+ "start": "node dist/index.js",
38
+ "prepublishOnly": "pnpm run build"
32
39
  },
33
40
  "dependencies": {
34
41
  "commander": "^12.1.0"
35
42
  },
36
43
  "publishConfig": {
37
44
  "access": "public"
38
- },
39
- "scripts": {
40
- "build": "tsup && chmod +x dist/index.js",
41
- "dev": "tsup --watch",
42
- "typecheck": "tsc -p tsconfig.json --noEmit",
43
- "start": "node dist/index.js"
44
45
  }
45
- }
46
+ }
@@ -138,6 +138,7 @@ ending), change the guideline first, then the episodes.
138
138
  ```bash
139
139
  node {baseDir}/scripts/short-drama.mjs init <dir> --title "<title>" --episodes N --minutes M --genre "<genre>" --register 台湾国语 [--adapt 抽核] [--ratio 9:16] [--video h3|seedance|seedance2|seedance2fast|wan] [--resolution 480P|…] [--accent "台湾口音国语"] [--style photoreal|realistic|ghibli] [--references sheets|keyframes] [--duration auto|fixed] [--look "<lens phrase>"] [--cinematography low-key|sweet-romance|period-nostalgia|costume-idol|costume-dark|scifi-hard|scifi-soft|none] [--action gun-fu|hk-kungfu|wuxia-fantasy|historical-realist|bullet-time|superhero|street-brawl|car-chase|none] [--violence blood|implied] [--grammar drama|action|faceslap|comedy] [--comedy slapstick|deadpan|romcom|none] [--composition standard|art-first] [--story revenge,sweet,…] [--market mainland|overseas] [--format short|medium|movie] [--lang zh] [--image-lang en|zh]
140
140
  # --image-lang zh: character / scene / prop image prompts in Chinese (the default is English)
141
+ # --foreground low|medium|high: how often cuts shoot past a foreground (default medium; high for 唯美 / 美感优先)
141
142
  # params.camera (body / lens / aperture) is edited in project.json
142
143
  ```
143
144
  An existing `project.json` means a resumed run: `validate` and `manifest` tell you where it stands.
@@ -297,14 +298,20 @@ Read `{baseDir}/reference/stage-storyboard.md`.
297
298
  Every cut is a set-up, so besides its beats and framing it carries the **lens** (`focal`, or let the
298
299
  shot size pick it), the **key light relative to the camera** (`light.direction` from the vocabulary
299
300
  plus `light.note` saying what it picks out, what fills the shadow side and how it changes over the
300
- cut) and the **foreground** to throw out of focus. All three are gated and all three go into the
301
+ cut) and the **foreground** if the story gives one a reason (`params.foregroundLevel`: `low` / `medium` / `high`). All three are gated and all three go into the
301
302
  prompt, alongside the camera package and each speaker's delivery.
302
303
 
304
+ **Everyone on screen is alive** (gate `idle-motion`, sheets mode): a model freezes whoever the prompt gives no
305
+ motion. In a cut with two or more visible people, all but the one carrying the action get a small
306
+ motion in `idle` (`"still"` when keeping still is the performance). See `craft-motion.md`.
307
+
308
+ **Two people talking** (gate `two-person-framing`, sheets mode): a two-shot once, then over-the-shoulder (`ots`) for the exchange, a clean single for the punchline. A single names only its person; every look states a screen direction (`eyeline`); the sides stay locked. See `reference/craft-shots.md`.
309
+
303
310
  **Every cut is lit and composed in the preset's look.** Read the preset's guide
304
311
  (`{baseDir}/reference/cinematography-<preset>.md`) before the first storyboard. Name the source
305
312
  in `light.source`, stage the person against it, and choose the camera side that gives the
306
313
  preset's preferred direction (the table in `cinematography.md`). Compose as the guide says,
307
- shoot past a foreground, and stay within the preset's stop (T2.8; T2 for `sweet-romance` and `costume-idol`, which
314
+ and stay within the preset's stop (T2.8; T2 for `sweet-romance` and `costume-idol`, which
308
315
  start on a T1.3 package and name their bokeh; T4 for `period-nostalgia`). Both costume presets (`costume-idol`, `costume-dark`) also need a 全景 / 大全景 with negative
309
316
  space in every segment.
310
317
  For `sweet-romance` and `costume-idol`, shoot the 甜宠 set pieces (心动对视, 回眸, 共伞…) as
@@ -12,7 +12,8 @@
12
12
  "videoResolution": "768P",
13
13
  "references": "keyframes",
14
14
  "style": "realistic",
15
- "register": "大陆普通话"
15
+ "register": "大陆普通话",
16
+ "foregroundLevel": "high"
16
17
  },
17
18
  "source": {
18
19
  "kind": "original",
@@ -278,7 +279,7 @@
278
279
  "image": {
279
280
  "prompt": "Portrait of a young East Asian woman in her mid-twenties, contemporary 2020s Chinese city, night-shift convenience store clerk, slim and upright, hair pulled into a loose low ponytail, faded navy jacket over a striped store uniform, faint shadows under alert eyes, thin lips pressed together, three-quarter view bust, plain neutral background, soft directional key light from the upper left with a cool fill, shallow depth of field",
280
281
  "negativePrompt": "plastic skin, over-smoothed face, perfectly symmetrical face, doll-like features, dead eyes without highlights, helmet hair, flat textureless fabric, stiff mannequin pose, logo, brand name, text, watermark",
281
- "sheet": "Single character model sheet on ONE 16:9 landscape canvas, pure white background. semi-realistic character illustration, painterly rendering with soft blended edges and visible brush texture, anatomically grounded. LEFT ZONE (about 34% of the canvas width): one bust portrait, front view, head and shoulders, both shoulders complete, flat straight bottom edge. RIGHT-TOP ZONE: three FULL-BODY views side by side (front, side, back) on one shared ground line, same height, same head-to-body ratio, PROPORTIONS ARE CRITICAL, no stretching or squashing. RIGHT-BOTTOM ZONE: four small isolated close-up studies (low ponytail tie, jacket cuff wear, store name tag, worn canvas sneakers); the detail studies give way, not the figures. Thin hairline rules separate the zones. Subject: a young East Asian woman in her mid-twenties, contemporary Chinese night-shift convenience store clerk, faded navy jacket over a striped uniform, alert tired eyes; all views share the SAME face as the bust portrait. LIGHTING IN THE LEFT ZONE ONLY: soft directional key from the upper left with ambient occlusion under the chin. LIGHTING IN THE RIGHT ZONES: flat even orthographic lighting, no cast shadows. No text, no watermark.",
282
+ "sheet": "Single character sheet on ONE 16:9 landscape canvas, three panels left to right, grey-white seamless studio background, studio lighting. semi-realistic character illustration, painterly rendering with soft blended edges and visible brush texture, anatomically grounded. LEFT: one headshot, the face filling the full height of the panel, facial features large and clear. CENTER: full-body front view, the face completely covered by a flat grey box so no facial features are visible. RIGHT: full-body back view, same height and body proportions as the front view, feet on the same ground line. Subject: a young East Asian woman in her mid-twenties, contemporary Chinese night-shift convenience store clerk, faded navy jacket over a striped uniform, alert tired eyes. Same costume on both full-body views. No side view, no extra characters, no scenery, no text, no watermark.",
282
283
  "tags": [
283
284
  "semi-realistic",
284
285
  "painterly",
@@ -442,7 +443,7 @@
442
443
  "image": {
443
444
  "prompt": "Portrait of an East Asian man in his early sixties, contemporary 2020s China, retired security guard, stocky build with a slight stoop, grey close-cropped crew cut, washed-out grey zip jacket, thick fingers, deep crow's feet, weathered skin with uneven tone and visible pores, three-quarter view bust, plain neutral background, soft directional key light from the upper left with a cool fill, shallow depth of field",
444
445
  "negativePrompt": "plastic skin, over-smoothed face, perfectly symmetrical face, young face, dead eyes without highlights, helmet hair, flat textureless fabric, stiff mannequin pose, logo, brand name, text, watermark",
445
- "sheet": "Single character model sheet on ONE 16:9 landscape canvas, pure white background. semi-realistic character illustration, painterly rendering with soft blended edges and visible brush texture, anatomically grounded. LEFT ZONE (about 34% of the canvas width): one bust portrait, front view, head and shoulders, both shoulders complete, flat straight bottom edge. RIGHT-TOP ZONE: three FULL-BODY views side by side (front, side, back) on one shared ground line, same height, same head-to-body ratio, PROPORTIONS ARE CRITICAL, no stretching or squashing. RIGHT-BOTTOM ZONE: four small isolated close-up studies (crew-cut hairline, jacket zipper pull, thick scarred knuckles, worn leather shoes); the detail studies give way, not the figures. Thin hairline rules separate the zones. Subject: a stocky East Asian man in his early sixties, contemporary retired security guard, grey crew cut, washed-out grey zip jacket, slight stoop; all views share the SAME face as the bust portrait. LIGHTING IN THE LEFT ZONE ONLY: soft directional key from the upper left with ambient occlusion under the chin. LIGHTING IN THE RIGHT ZONES: flat even orthographic lighting, no cast shadows. No text, no watermark.",
446
+ "sheet": "Single character sheet on ONE 16:9 landscape canvas, three panels left to right, grey-white seamless studio background, studio lighting. semi-realistic character illustration, painterly rendering with soft blended edges and visible brush texture, anatomically grounded. LEFT: one headshot, the face filling the full height of the panel, facial features large and clear. CENTER: full-body front view, the face completely covered by a flat grey box so no facial features are visible. RIGHT: full-body back view, same height and body proportions as the front view, feet on the same ground line. Subject: a stocky East Asian man in his early sixties, contemporary retired security guard, grey crew cut, washed-out grey zip jacket, slight stoop. Same costume on both full-body views. No side view, no extra characters, no scenery, no text, no watermark.",
446
447
  "tags": [
447
448
  "semi-realistic",
448
449
  "painterly",
@@ -538,7 +539,7 @@
538
539
  "image": {
539
540
  "prompt": "Portrait of a tall thin East Asian man around forty, contemporary 2020s Chinese city at night, black hooded raincoat zipped to the chin, hood pulled low so only the jaw and unshaven stubble show, rain beading on the wet plastic, three-quarter view bust, plain neutral background, hard cool rim light from behind and a dim key from the left, shallow depth of field",
540
541
  "negativePrompt": "plastic skin, over-smoothed face, perfectly symmetrical face, full face clearly visible, dead eyes, dry raincoat, flat textureless fabric, stiff mannequin pose, logo, brand name, text, watermark",
541
- "sheet": "Single character model sheet on ONE 16:9 landscape canvas, pure white background. semi-realistic character illustration, painterly rendering with soft blended edges and visible brush texture, anatomically grounded. LEFT ZONE (about 34% of the canvas width): one bust portrait, front view, head and shoulders, both shoulders complete, flat straight bottom edge. RIGHT-TOP ZONE: three FULL-BODY views side by side (front, side, back) on one shared ground line, same height, same head-to-body ratio, PROPORTIONS ARE CRITICAL, no stretching or squashing. RIGHT-BOTTOM ZONE: four small isolated close-up studies (raincoat hood seam, zipper pulled to the chin, wet rubber boots, stubbled jaw); the detail studies give way, not the figures. Thin hairline rules separate the zones. Subject: a tall thin East Asian man around forty, contemporary, black hooded raincoat with the hood low over the face, stubble; all views share the SAME face as the bust portrait. LIGHTING IN THE LEFT ZONE ONLY: dim key from the left with a cool rim light. LIGHTING IN THE RIGHT ZONES: flat even orthographic lighting, no cast shadows. No text, no watermark.",
542
+ "sheet": "Single character sheet on ONE 16:9 landscape canvas, three panels left to right, grey-white seamless studio background, studio lighting. semi-realistic character illustration, painterly rendering with soft blended edges and visible brush texture, anatomically grounded. LEFT: one headshot, the face filling the full height of the panel, facial features large and clear. CENTER: full-body front view, the face completely covered by a flat grey box so no facial features are visible. RIGHT: full-body back view, same height and body proportions as the front view, feet on the same ground line. Subject: a tall thin East Asian man around forty, contemporary, black hooded raincoat with the hood low over the face, stubble. Same costume on both full-body views. No side view, no extra characters, no scenery, no text, no watermark.",
542
543
  "tags": [
543
544
  "semi-realistic",
544
545
  "painterly",
@@ -78,13 +78,12 @@ the negative space is usually the dark part of the frame.
78
78
  - Write the placement into `frame`: `she sits in the lower-left third, the unlit shelves filling
79
79
  the right two-thirds of the frame`.
80
80
 
81
- **前景 — a foreground layer.** Most cuts shoot *past* something: the edge of a shelf, rain on the
82
- glass, a door frame, a hanging lamp, the shoulder of the other person (an over-the-shoulder). Take
83
- it from the scene's anchors where you can, so it also carries continuity. The existing `photography`
84
- gate already requires a named `foreground` on at least half the cuts of a segment, said to be out
85
- of focus. Aim higher than that minimum in this look.
81
+ **前景 — a foreground layer.** Where the story gives a reason, shoot *past* something: the edge of a
82
+ shelf, rain on the glass, a door frame, a hanging lamp, the shoulder of the other person (an
83
+ over-the-shoulder). Take it from the scene's anchors where you can, so it also carries continuity.
84
+ `params.foregroundLevel` sets how often; this look suits `medium` or `high`.
86
85
 
87
- **Three layers.** A defocused foreground, the person in the shadow in the mid-ground, and a
86
+ **Three layers, when a cut has a foreground.** A defocused foreground, the person in the shadow in the mid-ground, and a
88
87
  practical in the background that falls into bokeh. That depth is what the wide aperture exists for.
89
88
 
90
89
  ## 4. Lens — wide open, chosen per scene
@@ -46,7 +46,7 @@ storyboard (stage 5).
46
46
  | `scenes[].lighting[].prompt` | the preset's `framePhrase`, plus the sources that light the space | `scene-design` |
47
47
  | every `cut.frame` | the `framePhrase`, the direction phrase, `light.source` verbatim, the camera phrase, the foreground | `photography` |
48
48
  | every `cut.light` | `direction` (never one of the preset's `bannedLight` on a person), `source`, `note` | `photography` |
49
- | each segment | at least `preferredShare` of the cuts with people keyed from `preferredLight`; at least half with a foreground | `photography` |
49
+ | each segment | at least `preferredShare` of the cuts with people keyed from `preferredLight`; the episode's foreground share within `params.foregroundLevel` | `photography` |
50
50
  | every `cut.aperture` / `params.camera.aperture` | `maxStop` or faster | `photography` |
51
51
  | video prompt, `sheets` mode | the Look line carries the preset's `videoPhrase` (for `wan`: a `画面:` line with `videoPhraseZh`). `prompt segment` writes it for you | `video-prompt` |
52
52
 
@@ -19,7 +19,27 @@ text for a machine, not prose for a reader.
19
19
  what is received → one or two visible signals in a channel the size can read (gaze, breath,
20
20
  body set, object handling) → the decision (an action taken or withheld) → the landing at the
21
21
  end boundary. A wide cannot carry an eyelid; a close-up cannot carry a full-body retreat.
22
- Keeping still is a valid performance when it is the character's strategy.
22
+ Keeping still is a valid performance when it is the character's strategy — and even then they
23
+ breathe and blink (`idle: "still"`).
24
+
25
+ ### Everyone on screen is alive (gate `idle-motion`, sheets mode)
26
+
27
+ A model freezes whoever the prompt gives no motion, so a sub-character stands in the background like
28
+ a photo. `motion` is the one action that carries the cut; everyone else who is visible gets a small
29
+ motion of their own in `cut.idle: { C02: "shifts her weight, picking at her sleeve" }`. The skeleton
30
+ writes it into the shot. The rule, per cut with two or more visible people: all but the one carrying
31
+ the action have one (an over-the-shoulder's shoulder does not count). Give the speaker one too when
32
+ they would otherwise only talk.
33
+
34
+ - **Take it from why they are there.** Waiting: shifts weight, fiddles with a strap, checks the door.
35
+ Working: the work carries on. Listening: a small nod, the eyes moving over the other's face. Tense:
36
+ a tightening jaw, a thumb rubbing a finger. Do not reuse one motion across people or cuts: two people
37
+ breathing in time reads as a loop.
38
+ - **Small enough not to steal the cut:** one motion, in place. A line, a moving mouth or a change of
39
+ place is a cut's action, not an idle (gated, as is a length over 90 characters).
40
+ - **It must not break the boundaries:** hands, held props and positions end where `end` says.
41
+ - **Crowds and extras** with no sheet go in `background` as a motion, not a list of people: "shoppers
42
+ drifting past far behind her".
23
43
  4. **Camera** — `cameraNote`. A lock-off is written like a move: the framing, **what still moves
24
44
  inside it**, how long it holds (`hold`). An empty camera line is read as "free to drift", and
25
45
  the model grows a push-in nobody asked for. A move names its `motivation` (reveal / pressure
@@ -88,6 +88,30 @@ adjective:
88
88
  tighter) is a statement of power — make it on purpose.
89
89
  - **Over-the-shoulder** names whose shoulder is in the foreground (`foreground`); that decides
90
90
  whose shot it is.
91
+
92
+ ### Two people talking
93
+
94
+ A model puts into the shot everyone the shot names, and "looking at him" gives it no direction. In
95
+ sheets mode the gate `two-person-framing` holds every two-person segment to these, and `prompt
96
+ segment` writes the phrases for you:
97
+
98
+ - **Default pattern:** a two-shot once to set who stands where → **over-the-shoulder**, alternating,
99
+ for the exchange → a **clean single** for the punchline or the biggest reaction. A reaction lands
100
+ harder alone, so do not put every shot over a shoulder.
101
+ - **A single is single.** `characters` holds one person, the shot opens `Only @imageK in frame.`, and
102
+ the rest of the shot never describes the other person. Their reaction gets its own cut.
103
+ - **Every look has a direction.** `eyeline: { C01: "screen-left down" }` — `screen-left` or
104
+ `screen-right`, then `level`, `up` or `down`. The direction is toward the other person, so it follows
105
+ from the side they stand on. `"away screen-left"` is a deliberate look elsewhere; `"task"` keeps the
106
+ eyes on what the hands are doing. Height comes from the blocking: a person kneeling looks up, the
107
+ one standing looks down.
108
+ - **The sides are locked.** `axis.screenLeft` / `screenRight` become a `Sides:` line in the prompt, and
109
+ every shot holds them. Moving a person across needs `axisSide: cross` with a `note`.
110
+ - **Over-the-shoulder is its own setting.** `ots: "C01"` is the person whose shoulder is near the lens;
111
+ `characters` holds both people. The prompt says their face is not visible, so only one face is left
112
+ for the lip-sync and the eyes. It counts as a foreground for `params.foregroundLevel`, and that person
113
+ needs no `eyeline`. When one person is much lower, shoot up past the low shoulder or down past the
114
+ high one, and keep the sides.
91
115
  - **Crossing the axis** is legal with a neutral shot, an in-shot crossing, a deliberate
92
116
  disorientation or a re-established geography — `axisSide: cross` with a `note` that says which;
93
117
  the new working side is then inherited by the cuts after it.
@@ -66,6 +66,7 @@ All commands accept either the directory or the `project.json` path.
66
66
  | `lookPhraseZh` | empty | the same look phrase in the drama's language, gated in a **Chinese** prompt (`wan`) where the English `lookPhrase` would be both out of place and extra length for the content filter to judge |
67
67
  | `speechLeadSeconds` | 0.5 | added to a cut's dialogue seconds before the fit check — the mouth opens after the cut starts; also where a line starts in the `full` soundtrack prompt |
68
68
  | `audioTrack` | `post` | where the soundtrack is made. **`post`** (the default): the clips carry dialogue, ambience and in-picture sfx but **no music** (`No music.` / `non_diegetic_music: N/A` / `无配乐`) and no `post: true` sfx — music and sound design cannot carry across separately generated segments, so they are made once over the **edited episode**, on its timecode (`prompt score E01`, then `mix-over-video.md`); no per-segment audio node. `bed` (ambience + sfx + timed music, no voices) / `full` (the bed plus every line in the connected `@Audio1..3` voices — at most three speakers): a Seed Audio node per segment, music in the clip prompt. `none`: no soundtrack work |
69
+ | `foregroundLevel` | `medium`; `high` for 唯美 / 美感优先 (`composition: art-first`, `cinematography: sweet-romance` or `costume-idol`, a genre naming 唯美 or 美感) | how often a cut shoots past a foreground, measured across the episode: `low` up to 25 % of the cuts, `medium` 25–50 %, `high` 50 % or more (gate `photography`; `--foreground` at init). Which cuts: `stage-storyboard.md` |
69
70
  | `scoreFps` | 24 | frame rate `prompt score` writes SMPTE `HH:MM:SS:FF` at (`--fps` overrides); set it to the edit's real fps |
70
71
  | `subtitles` | false | `true` drops the subtitles-off sentence requirement from the prompts |
71
72
  | `maxFrameChars` | 900 | frames longer than this are flagged as padding (advisory) |
@@ -190,7 +191,7 @@ id or list) and `gag` (`setup` / `build` / `punchline`) with `runningGag`.
190
191
  | `profile.evidence[]` | source | verbatim quotes, never translated |
191
192
  | `image.prompt` | **`params.imagePromptLang`** (English by default) | portrait prompt; **no names or aliases**; must state ethnicity and era |
192
193
  | `image.negativePrompt` | `imagePromptLang` | style-dependent, see stage-characters |
193
- | `image.sheet` | `imagePromptLang` | 16:9 three-zone sheet prompt; no names; contains the style's render phrase (`STYLE_PRESETS[style].render`, or `.renderZh` in Chinese) |
194
+ | `image.sheet` | `imagePromptLang` | 16:9 three-panel sheet (headshot filling the height, front full body with the face covered by a grey box, back full body), grey-white studio background, studio lighting; no names; contains the style's render phrase (`STYLE_PRESETS[style].render`, or `.renderZh` in Chinese) |
194
195
  | `voice.prompt` | English | ≤400 characters, a static voice identity, **no quoted lines** |
195
196
  | `voice.videoDirection` | **English** | ≤160 characters, one clause: how they speak (pace, where the voice sits, what it does at the end of a phrase, accent). It is pasted beside **every line this character says** in a video prompt, and gated there — compress `voice.prompt`, do not repeat it |
196
197
  | `identityEn` | **English** | ≤80 characters, the name-free way a prompt refers to this person (`the woman in her mid thirties in headphones`). Distinct per character; no names. Used to attribute dialogue in `keyframes` mode and anywhere a `@imageK` label is not available |
@@ -324,6 +325,9 @@ Duration: `Σ lines (chars ÷ charsPerSecond) + Σ actions × actionSeconds`.
324
325
  | `prompt` | the video prompt, shape by `params.video` (below); gate `prompt-hygiene` (no `…`, no ids / paths / notes, subtitles off unless `params.subtitles`, H3 lips-closed clause, sheets-mode `@imageK` per shot) |
325
326
  | `segments[].axis` | `{ between: [C01, C02], workingSide, screenLeft, screenRight }`, required when two or more people appear in the segment |
326
327
  | `segments[].grammar` / `exceptions` | `grammar` (a string or an array) says how the segment is cut — `drama` · `action` · `faceslap` · `comedy`; missing, it is `params.grammar`; the old `action: true` means `action`. Each grammar's rules apply (gates `grammar`, `action`, `faceslap`, `comedy`; advisory `action-advice`, `grammar-advice`) and the prompt carries each grammar's line. `exceptions: [{ rule, reason }]` waives one rule for this segment when the user asks — rules `open-wide` `one-move` `impact-reaction` `no-graphic` `reveal-insert` `reaction-chain` `punchline-reaction` `setup-first` (the old `actionExceptions` still works) |
328
+ | `cuts[].idle` | sheets mode: `{ <id>: "shifts her weight" }`, a small motion in place for a person other than the one carrying the action, or `"still"` (breathing and blinking only). In a cut with two or more visible people, all but one need one (gate `idle-motion`) |
329
+ | `cuts[].eyeline` | sheets mode, two-person segments, **required**: `{ <id>: "screen-left\|screen-right level\|up\|down" }` for everyone on screen, or `"away screen-left"`, or `"task"` (gate `two-person-framing`) |
330
+ | `cuts[].ots` | the person whose shoulder is near the lens in an over-the-shoulder; `characters` holds both people; counts as a foreground; that person needs no `eyeline` |
327
331
  | `segments[].sound` | `{ ambience, sfx: [{ cut, event, post? }], music }` — ambience identical across a scene's segments; `music` is `N/A`, an intent string, or `{ intent, cues: [{ at: <cut number \| "10.5s">, do: enter \| swell \| duck \| drop \| silence \| out, note }] }` — gate `sound`. An sfx with `post: true` is **sound design outside the story world** (stinger, riser, whoosh, impact hit, reverse cymbal) — never in the clip prompt, made over the edit. In `audioTrack: post` the `music` is the **spotting intent** for the episode score: it never enters the clip, and `prompt score E01` lays it, the post sfx, the lines and the scene beds on the episode timeline. In `bed` / `full`, `prompt audio E01-01` derives the segment's Seed Audio soundtrack prompt from it (timecoded `[start s:end s]` ambience / sfx / music, plus the lines with `--full`) — gate `audio-track` |
328
332
  | `wji.assets.edit` / `wji.assets.mix` (episode) | `audioTrack: post` only — the locked edit of the episode (clips cut together, no music) and the final mixed episode (edit + score + sound design). `manifest` asks for the edit once every clip is bound, then for the mix |
329
333
  | `segments[].refOverride[]` | optional — the Director pins this segment's **picture** references instead of the derived set (environment, people, props): `[{ entity, asset?: "sheet" \| "headshot" \| "lighting:<state>", faceOf?, why?, use?, zh? }]` in the order they will be connected (`@image1…`). `faceOf: "C01"` says this picture supplies that character's face, so their lines bind to it even when a body sheet of theirs comes later. Voices and the previous clip are still appended after the pictures. An empty array means "derived" |
@@ -61,23 +61,32 @@ the `seedNote` and `render dialogue` puts them beside each line for the polish p
61
61
  **must** ban `photorealistic`. Both gated. Always add `logo, brand name, text` — a "store
62
62
  uniform" comes back wearing a real chain's badge otherwise (seen in testing), and a real brand
63
63
  on a character is a problem in every shot after it.
64
- - `image.sheet`: the model-sheet layout, written out in full every time:
64
+ - `image.sheet`: the three-panel sheet, written out in full every time. Left to right on
65
+ one 16:9 canvas, grey-white seamless studio background, studio lighting:
65
66
 
66
67
  ```
67
- ┌──────────┬────────────────────────────┐
68
- │ bust │ front side back │ ONE 16:9 canvas, pure white background
69
- │ portrait ├────────────────────────────┤ LEFT ZONE ≈ 34 % width: head-and-shoulders bust,
70
- │ (face │ detail · detail · detail │ both shoulders complete, flat bottom edge
71
- │ ref) │ │ RIGHT-TOP: three full-body views, same height,
72
- └──────────┴────────────────────────────┘ same head-to-body ratio, PROPORTIONS ARE CRITICAL
73
- RIGHT-BOTTOM: 4–5 small detail studies;
74
- the detail studies give way, not the figures
68
+ ┌────────────┬────────────┬────────────┐
69
+ │ headshot │ front │ back │ ONE 16:9 canvas
70
+ │ face fills│ full body │ full body │ grey-white studio background
71
+ │ the height│ grey box │ │ studio lighting
72
+ │ │ over face │ │
73
+ └────────────┴────────────┴────────────┘
75
74
  ```
76
75
 
77
- Plus: `all views share the SAME face as the bust portrait`, `LIGHTING IN THE LEFT ZONE ONLY:
78
- soft directional key…`, `LIGHTING IN THE RIGHT ZONES: flat even orthographic lighting`, the
79
- style's render phrase (`STYLE_PRESETS[style].render` — `node scripts/short-drama.mjs` exports
80
- it; copy it verbatim), `No text, no watermark`. Gated: `16:9`, render phrase, no names.
76
+ ```
77
+ Single character sheet on ONE 16:9 landscape canvas, three panels left to right,
78
+ grey-white seamless studio background, studio lighting. <style render phrase>.
79
+ LEFT: one headshot, the face filling the full height of the panel, facial features
80
+ large and clear. CENTER: full-body front view, the face completely covered by a flat
81
+ grey box so no facial features are visible. RIGHT: full-body back view, same height
82
+ and body proportions as the front view, feet on the same ground line. Subject:
83
+ <ethnicity, age, era, identity, hair, outfit>. Same costume on both full-body views.
84
+ No side view, no extra characters, no scenery, no text, no watermark.
85
+ ```
86
+
87
+ The face lives only in the left headshot. The center panel is the costume and the body.
88
+ Copy the style render phrase verbatim (`STYLE_PRESETS[style].render` — `node scripts/short-drama.mjs`
89
+ exports it). Gated: `16:9`, render phrase, no names.
81
90
 
82
91
  ## Voice
83
92
 
@@ -167,9 +176,10 @@ invents a voice that fits it, which is why the headshot must be approved first.
167
176
  one reference, so pass only the headshot node. About 3.6 credits a sample.
168
177
 
169
178
  Generate headshots first (a set of headshots is a batch — quote the total), **look at them**
170
- (download and open), then sheets and voices from the approved headshots. Check a sheet for: one
171
- face across all views, three full bodies of equal height, no squashed figures, white background.
172
- Regenerate rather than accept a sheet with two faces — every shot inherits it. Listen to the voice
179
+ (download and open), then sheets and voices from the approved headshots. Check a sheet for: the
180
+ headshot face fills the panel height, the front full body has its face covered by a flat grey
181
+ box, the back full body matches that height, grey-white studio background. Regenerate rather
182
+ than accept a sheet with a visible face on the front body — every shot inherits it. Listen to the voice
173
183
  before you attach it: the age and the weight of the voice should match the face.
174
184
 
175
185
  Attach and bind:
@@ -163,11 +163,37 @@ tables are in `cinematography.md` and the preset's guide.
163
163
 
164
164
  ### Foreground
165
165
 
166
- Depth needs something to be shallow *against*. `foreground` names what sits between the lens and
167
- the subject — a console edge, a curtain of rain off an awning, a blurred shoulder, a doorframe —
168
- and the frame prompt says it is thrown out of focus. `""` is a deliberately clean frame, and the
169
- gate allows it for up to half the cuts of a segment: a segment of clean frames reads flat, and a
170
- drama where every shot is clean reads like a corporate video.
166
+ `foreground` names what sits between the lens and the subject — a console edge, a curtain of rain
167
+ off an awning, a blurred shoulder, a doorframe — and the frame prompt says it is thrown out of focus.
168
+ `""` is a deliberately clean frame.
169
+
170
+ **How often** is `params.foregroundLevel`, gated across the episode so scenes can differ:
171
+
172
+ | Level | Cuts with a foreground | Use |
173
+ |---|---|---|
174
+ | `low` | up to 25 % | clean, direct, face-led; only where the story asks for one |
175
+ | `medium` (default) | 25–50 % | where the story or the shot type gives a reason |
176
+ | `high` (default for 唯美 / 美感优先) | 50 % or more | a layered, voyeuristic or crowded look; soft, composed beauty |
177
+
178
+ 唯美 / 美感优先 means `composition: art-first`, `cinematography: sweet-romance` or `costume-idol`, or a genre
179
+ that says 唯美 or 美感. Naming `foregroundLevel` overrides this.
180
+
181
+ **Which cuts** — story first, then shot type, then shot size. Remove it in your head: if the audience
182
+ loses no meaning, shoot the cut clean.
183
+
184
+ 1. **Story decides whether.** Threat (the barrel in front of him), watching or hiding (a doorframe, a
185
+ curtain), trapped or outnumbered (bodies closing in), distance between two people, power (the
186
+ powerful one's shoulder or weapon), closeness (her hair, soft), a clue that matters later.
187
+ 2. **Shot type decides what.** Over-the-shoulder always has one (the listener's shoulder); POV has what
188
+ they look past; an establishing shot has a layer of the place. Usually clean: a reaction close-up, an
189
+ insert, the 打脸 proof or reveal, an action impact, a moment of isolation.
190
+ 3. **Shot size decides how much.** A wide can take a large shape on one edge; a medium one edge; a close
191
+ a soft sliver, never near the eyes or mouth; an extreme close-up almost never.
192
+
193
+ Take it from the scene: it must already be there and on the camera's side of the action. Keep it off
194
+ the face, the mouth, the key prop and the action. Prefer simple still shapes (edges, frames, shoulders)
195
+ over foreground faces and hands, which can read as an extra person. Do not repeat one object across
196
+ cuts unless it is a story motif. At `high`, keep the same reasons and simply use them more.
171
197
 
172
198
  Directing feel (not gated): reverse angles for dialogue (asker's close-up, answerer's close-up);
173
199
  the first cut of an episode moves; a key action gets its own 2 s insert; a reaction shot after a
@@ -73,7 +73,7 @@ the headshot + sheet as image references and the voice as audio.
73
73
  ```
74
74
 
75
75
  - headshot: `canvas add image -n "C01 headshot" -m <image model> -r 1:1 -p "<image.prompt>"`
76
- - sheet: `canvas add image -n "C01 sheet" --from <headshotNode> -m <image model> -r 16:9 -p "@image1 is the face to match. <image.sheet>"`
76
+ - sheet: `canvas add image -n "C01 sheet" --from <headshotNode> -m <image model> -r 16:9 -p "@image1 is the face to match exactly. <image.sheet>"`
77
77
  - voice: `canvas add audio -n "C01 voice" -m <audio/tts model> -p "<voice.prompt>"` (optional; skip when no voice model is enabled)
78
78
  - title: `canvas add title -t "C01 林小夏" --at <x>,<y>` — one per row, rows 700 px apart
79
79
 
@@ -14,7 +14,7 @@ import {
14
14
  paramsOf, parseEpRange, promptSkeleton, renderCharacter, renderOutline, renderProp, renderScene, renderScript,
15
15
  renderSegment, runGates, seedScript, seedStoryboard, seedanceAnchors, slug, timestamp, writeJsonFile,
16
16
  wanLegendLine, wanSheetLegendLine, wanShotHeader, segmentReferences, imageReferences, audioReferences, voiceSamplePrompt, voiceSampleSeconds, nonWhitespace,
17
- speechDirection, cameraPhrase, lightPhrase, foregroundPhrase, focalOf, LIGHT_DIRECTIONS, identityOf, isEnglish,
17
+ speechDirection, cameraPhrase, lightPhrase, idlePhrase, idleSentence, IDLE_MAX_CHARS, eyelinePhrase, eyelineValid, onlyInFramePhrase, overShoulderPhrase, sidesLine, foregroundPhrase, foregroundBand, foregroundForLook, focalOf, LIGHT_DIRECTIONS, identityOf, isEnglish,
18
18
  renderGuideline, renderDialogue, scriptSelfCheck, beatOffsets, turnPoints, parseAge, talkAndAction, normalizeRegister, REGISTERS, REGISTER_ACCENT,
19
19
  cutStart, segmentCharacters, keyScenes, shotTexts, motionSentence, cameraClause, PURPOSE_KINDS, ANGLES, HEIGHTS, NO_SUBTITLES, LIPS_CLOSED,
20
20
  audioPrompt, musicOf, cueSeconds, AUDIO_MODEL, AUDIO_MAX_CHARS, AUDIO_MAX_SECONDS, scoreSheet, smpte,
@@ -850,18 +850,127 @@ const breaks = (gateId, project, episode, needle) => {
850
850
  breaks('photography', PROJECT, noNote, 'light.note is required');
851
851
  const wrongDir = clone(E01); wrongDir.storyboard.segments[0].cuts[0].light.direction = 'top';
852
852
  breaks('photography', PROJECT, wrongDir, 'keyed from directly above');
853
- // Foreground: named in the frame, said to be out of focus, and present on at least half the cuts.
853
+ // Foreground: named in the frame, said to be out of focus, and within params.foregroundLevel across the episode.
854
854
  ok(foregroundPhrase(cut).includes('out of focus'), 'the foreground phrase says it is defocused');
855
855
  eq(foregroundPhrase({ foreground: '' }), '', 'a clean frame adds nothing');
856
856
  ok(foregroundPhrase({ foreground: '\u6536\u94f6\u53f0' }, 'zh').includes('\u524d\u666f'), 'the Chinese foreground phrase says 前景');
857
- const noFg = clone(E01); noFg.storyboard.segments[0].cuts.forEach((c) => { c.foreground = ''; });
858
- breaks('photography', PROJECT, noFg, 'no depth');
857
+ const noFg = clone(E01); noFg.storyboard.segments.forEach((seg) => seg.cuts.forEach((c) => { c.foreground = ''; }));
858
+ // The fixture sits at `high`: no foreground at all is too few there, and fine at `low`.
859
+ breaks('photography', PROJECT, noFg, 'params.foregroundLevel high allows');
860
+ const asLevel = (level) => ({ ...PROJECT, params: { ...PROJECT.params, foregroundLevel: level } });
861
+ ok(!failures('photography', asLevel('low'), noFg).some((f) => f.includes('foregroundLevel')), 'low accepts an episode of clean frames');
862
+ breaks('photography', asLevel('low'), E01, 'params.foregroundLevel low allows');
863
+ breaks('photography', asLevel('medium'), E01, 'params.foregroundLevel medium allows');
864
+ breaks('params', asLevel('sometimes'), E01, 'params.foregroundLevel must be one of');
865
+ const levelFor = (params) => paramsOf({ params }).foregroundLevel;
866
+ eq(levelFor({}), 'medium', 'the default level is medium');
867
+ eq(levelFor({ composition: 'standard', cinematography: 'low-key', genre: '复仇' }), 'medium', 'a standard look stays medium');
868
+ eq(levelFor({ composition: 'art-first' }), 'high', '艺术优先 defaults to high');
869
+ eq(levelFor({ cinematography: 'sweet-romance' }), 'high', 'the 唯美 romance preset defaults to high');
870
+ eq(levelFor({ cinematography: 'costume-idol' }), 'high', 'the 唯美 costume preset defaults to high');
871
+ eq(levelFor({ genre: '唯美古风' }), 'high', 'a 唯美 genre defaults to high');
872
+ eq(levelFor({ genre: '美感優先' }), 'high', 'a 美感優先 genre defaults to high');
873
+ eq(levelFor({ composition: 'art-first', foregroundLevel: 'low' }), 'low', 'a named level beats the look');
874
+ eq(foregroundForLook({ cinematography: 'costume-dark' }), 'medium', 'the dark costume preset is not 唯美');
875
+ eq(foregroundBand('low', 40).max, 10, 'low allows a quarter of the cuts');
876
+ eq(foregroundBand('medium', 40).min, 10, 'medium starts at a quarter');
877
+ eq(foregroundBand('medium', 40).max, 20, 'medium stops at half');
878
+ eq(foregroundBand('high', 40).min, 20, 'high starts at half');
879
+ ok(foregroundBand('nope', 40) === null, 'an unknown level has no band');
880
+ for (const [k, pr] of Object.entries(CINEMATOGRAPHY_PRESETS)) if (pr) ok(!/foreground/i.test(pr.videoPhrase) && !/前景/.test(pr.videoPhraseZh), `${k} leaves the foreground to the level`);
859
881
  const missingFg = clone(E01); delete missingFg.storyboard.segments[0].cuts[0].foreground;
860
882
  breaks('photography', PROJECT, missingFg, 'foreground is required');
861
883
  const unsaidFg = clone(E01); unsaidFg.storyboard.segments[0].cuts[0].frame = unsaidFg.storyboard.segments[0].cuts[0].frame.replace('the dripping edge of the striped awning', 'a wall');
862
884
  breaks('photography', PROJECT, unsaidFg, 'must name the foreground');
863
885
  }
864
886
 
887
+ {
888
+ // Everyone on screen is alive: a model freezes whoever the prompt gives no motion.
889
+ eq(idlePhrase('shifts her weight', '@image3'), '@image3 shifts her weight.', 'an idle reads as a sentence');
890
+ eq(idlePhrase('still', '@image3'), '@image3 holds still, only breathing and blinking.', 'still keeps the breath');
891
+ ok(idlePhrase('轻轻换了一下重心', '@image3', 'zh').endsWith('轻轻换了一下重心。'), 'the Chinese idle');
892
+ eq(idleSentence({ characters: ['A', 'B'], idle: { B: 'taps a finger' } }, (id) => id), 'B taps a finger. ', 'only the people with an idle are written');
893
+ const p = clone(PROJECT); p.params.references = 'sheets';
894
+ const only = (f) => f.filter((m) => m.startsWith('E01-02'));
895
+ const build = (idle) => {
896
+ const ep = clone(E01); const seg = ep.storyboard.segments.find((s) => s.id === 'E01-02');
897
+ const two = seg.cuts[2]; // C01 + C02
898
+ if (idle) two.idle = idle;
899
+ seg.prompt = promptSkeleton(p, ep, seg.id).video.prompt;
900
+ return { ep, seg, two };
901
+ };
902
+ const bare = build();
903
+ ok(only(failures('idle-motion', p, bare.ep)).some((m) => m.includes('have an idle')), 'a second visible person with no idle fails');
904
+ const good = build({ C02: 'shifts her weight and picks at her sleeve' });
905
+ eq(only(failures('idle-motion', p, good.ep)).filter((m) => !m.includes('cameraNote.hold')).length, 0, 'one idle for the second person passes');
906
+ ok(good.seg.prompt.includes('shifts her weight and picks at her sleeve.'), 'the skeleton writes the idle');
907
+ const stillOne = build({ C02: 'still' });
908
+ ok(stillOne.seg.prompt.includes('holds still, only breathing and blinking.'), 'still is written with the breath');
909
+ eq(only(failures('idle-motion', p, stillOne.ep)).filter((m) => !m.includes('cameraNote.hold')).length, 0, 'keeping still is allowed when it is the performance');
910
+ ok(only(failures('idle-motion', p, build({ C02: 'walks to the door' }).ep)).some((m) => m.includes('too big')), 'a change of place is not an idle');
911
+ ok(only(failures('idle-motion', p, build({ C02: 'says something quietly' }).ep)).some((m) => m.includes('too big')), 'a line is not an idle');
912
+ ok(only(failures('idle-motion', p, build({ C02: 'x'.repeat(IDLE_MAX_CHARS + 1) }).ep)).some((m) => m.includes('one small motion')), 'an idle stays small');
913
+ const unwritten = build({ C02: 'taps a finger' }); unwritten.seg.prompt = unwritten.seg.prompt.replace(/taps a finger\.\s*/, '');
914
+ ok(only(failures('idle-motion', p, unwritten.ep)).some((m) => m.includes('must carry the idle')), 'an idle left out of the prompt fails');
915
+ const onlyHold = build({ C02: 'taps a finger' }); onlyHold.two.cameraNote = { ...onlyHold.two.cameraNote, hold: 'only the sign flickers' };
916
+ ok(only(failures('idle-motion', p, onlyHold.ep)).some((m) => m.includes('drop "only"')), '"only the sign" contradicts the people moving');
917
+ const offScreen = build({ C03: 'taps a finger' });
918
+ ok(only(failures('idle-motion', p, offScreen.ep)).some((m) => m.includes('not on screen')), 'an idle for someone off screen fails');
919
+ eq(failures('idle-motion', PROJECT, E01).length, 0, 'keyframes mode is exempt');
920
+ }
921
+
922
+ {
923
+ // Two people on screen, sheets mode: who is in frame, where they look, which side they stand on.
924
+ eq(eyelinePhrase('screen-right down', '@image3'), '@image3 looks toward screen-right and down.', 'an eyeline has a direction and a height');
925
+ eq(eyelinePhrase('task', '@image3'), '@image3 keeps their eyes on what their hands are doing.', 'task keeps the eyes on the work');
926
+ ok(eyelinePhrase('away screen-left', '@image3').includes('looks away'), 'away says so');
927
+ ok(eyelinePhrase('screen-left up', '@image3', 'zh').includes('左侧上方'), 'the Chinese eyeline');
928
+ ok(eyelineValid('screen-left level') && eyelineValid('task') && eyelineValid('away screen-right down'), 'valid eyelines');
929
+ ok(!eyelineValid('at him') && !eyelineValid('screen-right sideways'), 'an eyeline without a direction is refused');
930
+ const p = clone(PROJECT); p.params.references = 'sheets';
931
+ const framed = () => {
932
+ const ep = clone(E01);
933
+ const seg = ep.storyboard.segments.find((s) => s.id === 'E01-02'); // C01 | C02 | C01+C02 | C02 | C02, C02 screen left
934
+ const look = { C01: 'screen-left level', C02: 'screen-right level' };
935
+ seg.cuts.forEach((c) => { c.eyeline = Object.fromEntries((c.characters || []).map((id) => [id, look[id]])); });
936
+ seg.prompt = promptSkeleton(p, ep, seg.id).video.prompt;
937
+ return { ep, seg };
938
+ };
939
+ const only = (f) => f.filter((m) => m.startsWith('E01-02'));
940
+ const { ep: okEp, seg: okSeg } = framed();
941
+ eq(only(failures('two-person-framing', p, okEp)).length, 0, 'the skeleton writes everything the gate asks for');
942
+ ok(okSeg.prompt.includes('Sides: '), 'the skeleton locks the sides');
943
+ ok(okSeg.prompt.includes('Only @image'), 'the skeleton opens a single with who is in frame');
944
+ ok(okSeg.prompt.includes('looks toward screen-left'), 'the skeleton states the eyeline');
945
+ const noLook = framed(); delete noLook.seg.cuts[0].eyeline;
946
+ ok(only(failures('two-person-framing', p, noLook.ep)).some((m) => m.includes('is required')), 'an eyeline is required');
947
+ const vague = framed(); vague.seg.cuts[0].eyeline = { C01: 'at him' };
948
+ ok(only(failures('two-person-framing', p, vague.ep)).some((m) => m.includes('not valid')), '"at him" is refused');
949
+ const wrong = framed(); wrong.seg.cuts[0].eyeline = { C01: 'screen-right level' };
950
+ ok(only(failures('two-person-framing', p, wrong.ep)).some((m) => m.includes('so looking at the other person is screen-left')), 'looking away from the other person is caught');
951
+ const away = framed(); away.seg.cuts[0].eyeline = { C01: 'away screen-right' }; away.seg.prompt = promptSkeleton(p, away.ep, away.seg.id).video.prompt;
952
+ ok(!only(failures('two-person-framing', p, away.ep)).some((m) => m.includes('so looking')), 'a deliberate look away passes');
953
+ const noSides = framed(); noSides.seg.prompt = noSides.seg.prompt.replace(/Sides: [^\n]*\n/, '');
954
+ ok(only(failures('two-person-framing', p, noSides.ep)).some((m) => m.includes('must lock the sides')), 'a prompt without the sides line fails');
955
+ const leaked = framed();
956
+ const labelOf = /(@image\d+) looks toward screen-right/.exec(leaked.seg.prompt)[1];
957
+ leaked.seg.prompt = leaked.seg.prompt.replace(/(Shot 1 \([^)]*\): )/, `$1Behind him stands ${labelOf}. `).replace(/(\[Shot 1\] )/, `$1Behind him stands ${labelOf}. `);
958
+ ok(only(failures('two-person-framing', p, leaked.ep)).some((m) => m.includes('must not describe')), 'a single that names the other person fails');
959
+ const ots = framed(); const twoShot = ots.seg.cuts[2];
960
+ twoShot.ots = 'C01'; delete twoShot.eyeline.C01;
961
+ ots.seg.prompt = promptSkeleton(p, ots.ep, ots.seg.id).video.prompt;
962
+ ok(ots.seg.prompt.includes('their face not visible'), 'the skeleton writes the over-the-shoulder');
963
+ ok(ots.seg.prompt.includes('lower-right foreground'), 'the near shoulder sits on its own side of the axis');
964
+ eq(only(failures('two-person-framing', p, ots.ep)).length, 0, 'over-the-shoulder needs no eyeline from the shoulder');
965
+ const badOts = framed(); badOts.seg.cuts[0].ots = 'C02';
966
+ ok(only(failures('two-person-framing', p, badOts.ep)).some((m) => m.includes('ots must be one of')), 'ots must be on screen with one other');
967
+ eq(onlyInFramePhrase('@image3', 'zh'), '画面中只有@image3。', 'the Chinese single');
968
+ ok(overShoulderPhrase('@image3', 'left', 'zh').includes('左下方前景'), 'the Chinese over-the-shoulder');
969
+ ok(sidesLine({ screenLeft: 'A', screenRight: 'B' }, (id) => id, 'zh').includes('A始终在画面左侧'), 'the Chinese sides line');
970
+ // Keyframes carry the blocking in their pictures: the gate stays out of the way.
971
+ eq(failures('two-person-framing', PROJECT, E01).length, 0, 'keyframes mode is exempt');
972
+ }
973
+
865
974
  {
866
975
  // The house cinematography (params.cinematography low-key): practical sources, no flat light on a
867
976
  // person, most cuts keyed from the side or behind, shot wide open; the look rides every prompt.
@@ -2307,8 +2416,8 @@ const breaks = (gateId, project, episode, needle) => {
2307
2416
 
2308
2417
  // ── gate inventory (crew engine) ────────────────────────────────────────────
2309
2418
  {
2310
- eq(GATES.length, 67, 'gate count');
2311
- eq(GATES.filter((g) => !g.advisory).length, 61, 'hard gate count');
2419
+ eq(GATES.length, 69, 'gate count');
2420
+ eq(GATES.filter((g) => !g.advisory).length, 63, 'hard gate count');
2312
2421
  eq(GATES.filter((g) => g.advisory).length, 6, 'advisory gate count');
2313
2422
  ok(!GATES.find((g) => g.id === 'boundary-contrast').advisory, 'boundary-contrast is hard');
2314
2423
  ok(GATES.find((g) => g.id === 'boundary-contrast-episode').advisory, 'boundary-contrast-episode is advisory');
@@ -2430,8 +2539,8 @@ const breaks = (gateId, project, episode, needle) => {
2430
2539
  const results = runGates(makeContext(PROJECT, { 1: E01 }));
2431
2540
  const rep = formatGates(results);
2432
2541
  eq(Object.keys(rep).sort().join(','), 'advisory,failed,hard,lines,notes,summary,text', 'formatGates shape');
2433
- eq(rep.summary, '✓ 61 gate(s) passed, 6 advisory (1 note)', 'fixture summary');
2434
- eq(rep.failed, 0, 'nothing failed'); eq(rep.hard, 61, 'hard count'); eq(rep.advisory, 6, 'advisory count');
2542
+ eq(rep.summary, '✓ 63 gate(s) passed, 6 advisory (1 note)', 'fixture summary');
2543
+ eq(rep.failed, 0, 'nothing failed'); eq(rep.hard, 63, 'hard count'); eq(rep.advisory, 6, 'advisory count');
2435
2544
  // The fixture now raises one advisory (E01-03 carries the 爽点 and is not cut as faceslap),
2436
2545
  // exactly as it does in the wji-cli copy — advisory lines print, failures still must not.
2437
2546
  eq(rep.lines.filter((l) => l.includes('✗')).length, 0, 'a green run prints no failed gates');
@@ -19,6 +19,7 @@
19
19
  // [--market mainland|overseas] (mainland holds the supernatural to a rational explanation)
20
20
  // [--format short|medium|movie] (短剧 1–3 min, 中剧 4–15 min, 电影 in ~5-minute parts — see FORMATS)
21
21
  // [--register 台湾国语] (语体 — decides how lines are written and the accent spoken)
22
+ // [--foreground low|medium|high] (how often cuts shoot past a foreground — see FOREGROUND_LEVELS)
22
23
  // [--image-lang en|zh] (language of the character / scene / prop image prompts)
23
24
  // validate <project> [--stage outline|characters|art|script|storyboard] [--ep 1-3]
24
25
  // checkup <project> [--stage …] [--ep …] every gate with ✓/✗, exit 0
@@ -199,6 +200,28 @@ export const AUDIO_MAX_REFS = 3;
199
200
  * `bed` (ambience + sfx + music) / `full` (the bed plus the lines): a Seed Audio node per segment.
200
201
  */
201
202
  export const AUDIO_TRACK_MODES = ['post', 'none', 'bed', 'full'];
203
+ // Share of an episode's cuts that may carry a foreground. Measured per episode, not per segment, so a
204
+ // siege can be full of them and a quiet confession can have none.
205
+ export const FOREGROUND_LEVELS = Object.freeze({
206
+ low: { min: 0, max: 0.25 },
207
+ medium: { min: 0.25, max: 0.5 },
208
+ high: { min: 0.5, max: 1 },
209
+ });
210
+ /**
211
+ * The level a project gets when it does not name one: `high` for 唯美 or 美感优先 — the art-first
212
+ * composition, the two 唯美 cinematography presets, or a genre that says so — `medium` otherwise.
213
+ */
214
+ export function foregroundForLook(params) {
215
+ const aesthetic = params?.composition === 'art-first'
216
+ || ['sweet-romance', 'costume-idol'].includes(params?.cinematography)
217
+ || /唯美|美感/.test(String(params?.genre || ''));
218
+ return aesthetic ? 'high' : 'medium';
219
+ }
220
+ /** The foreground cut count a level allows in an episode of `cuts` cuts. */
221
+ export function foregroundBand(level, cuts) {
222
+ const band = FOREGROUND_LEVELS[level];
223
+ return band ? { min: Math.floor(band.min * cuts), max: Math.ceil(band.max * cuts) } : null;
224
+ }
202
225
  /** No per-segment audio node in these modes. */
203
226
  export const NO_SEGMENT_TRACK = ['post', 'none'];
204
227
  /** What the music does at a cue point. */
@@ -413,10 +436,58 @@ export function speechDirection(character, delivery, lang = 'en') {
413
436
  return bits.join(lang === 'zh' ? '\uff0c' : ', ');
414
437
  }
415
438
 
439
+ // Two people on screen. A model puts into the shot everyone the shot names, and "looking at him" gives
440
+ // it no direction, so sheets-mode prompts say who is in frame, which way each person looks and which
441
+ // side each stands on (gate two-person-framing).
442
+ export const EYELINE_DIRECTIONS = ['screen-left', 'screen-right'];
443
+ export const EYELINE_HEIGHTS = ['level', 'up', 'down'];
444
+ /** "screen-right down" · "away screen-left" · "task" (eyes on what the hands are doing). */
445
+ export function parseEyeline(value) {
446
+ const words = String(value || '').trim().split(/\s+/).filter(Boolean);
447
+ if (words[0] === 'task') return { task: true };
448
+ const away = words[0] === 'away';
449
+ const [to, height = 'level'] = away ? words.slice(1) : words;
450
+ return { to, height, away };
451
+ }
452
+ export function eyelineValid(value) {
453
+ const e = parseEyeline(value);
454
+ return Boolean(e.task) || (EYELINE_DIRECTIONS.includes(e.to) && EYELINE_HEIGHTS.includes(e.height));
455
+ }
456
+ export function eyelinePhrase(value, who, lang = 'en') {
457
+ const e = parseEyeline(value);
458
+ const zh = lang === 'zh';
459
+ if (e.task) return zh ? `${who}的视线落在手上的活上。` : `${who} keeps their eyes on what their hands are doing.`;
460
+ const side = zh ? (e.to === 'screen-left' ? '左' : '右') : e.to;
461
+ if (zh) return `${who}的视线${e.away ? '移开,' : ''}朝画面${side}侧${e.height === 'up' ? '上方' : e.height === 'down' ? '下方' : '平视'}。`;
462
+ return `${who} ${e.away ? 'looks away toward' : 'looks toward'} ${side}${e.height === 'up' ? ' and up' : e.height === 'down' ? ' and down' : ' at eye level'}.`;
463
+ }
464
+ export function sidesLine(axis, whoOf, lang = 'en') {
465
+ const left = whoOf(axis.screenLeft), right = whoOf(axis.screenRight);
466
+ return lang === 'zh' ? `站位:${left}始终在画面左侧,${right}始终在画面右侧。` : `Sides: ${left} stays on screen left and ${right} on screen right in every shot.`;
467
+ }
468
+ export function onlyInFramePhrase(who, lang = 'en') {
469
+ return lang === 'zh' ? `画面中只有${who}。` : `Only ${who} in frame.`;
470
+ }
471
+ export function overShoulderPhrase(who, side, lang = 'en') {
472
+ return lang === 'zh'
473
+ ? `越过${who}的肩拍摄:其肩与后脑虚化在画面${side === 'left' ? '左' : '右'}下方前景,看不到其脸。`
474
+ : `Over-the-shoulder from behind ${who}: their shoulder and the back of their head soft in the lower-${side} foreground, their face not visible.`;
475
+ }
476
+ /** What a shot opens with in a two-person segment: who is in frame, then where each person looks. */
477
+ export function shotFraming(cut, seg, whoOf, lang = 'en') {
478
+ if (segmentCharacters(seg).length < 2) return '';
479
+ const chars = cut.characters || [];
480
+ const parts = [];
481
+ if (cut.ots) parts.push(overShoulderPhrase(whoOf(cut.ots), seg.axis?.screenLeft === cut.ots ? 'left' : 'right', lang));
482
+ else if (chars.length === 1) parts.push(onlyInFramePhrase(whoOf(chars[0]), lang));
483
+ for (const id of chars) if (id !== cut.ots && cut.eyeline?.[id]) parts.push(eyelinePhrase(cut.eyeline[id], whoOf(id), lang));
484
+ return parts.length ? `${parts.join(' ')} ` : '';
485
+ }
486
+
416
487
  /**
417
488
  * The blurred thing between the lens and the subject. Depth needs something to be shallow against,
418
- * so a cut names one and the prompt says it is out of focus; an empty `foreground` is a deliberate
419
- * clean frame and the gate limits how many of those a segment may have.
489
+ * so a cut that needs one names it and the prompt says it is out of focus; an empty `foreground` is a
490
+ * deliberate clean frame. params.foregroundLevel sets how many of each an episode has.
420
491
  */
421
492
  export function foregroundPhrase(cut, lang = 'en') {
422
493
  const fg = String(cut?.foreground || '').trim();
@@ -435,6 +506,23 @@ export function motionSentence(cut, lang = 'en') {
435
506
  return `Cue: ${t}. Action: ${a}. End: ${r}.`;
436
507
  }
437
508
 
509
+ // Everyone on screen is alive. A model freezes whoever the prompt gives no motion, so each person other
510
+ // than the one carrying the cut's action gets a small motion of their own (gate idle-motion).
511
+ export const IDLE_STILL = 'still';
512
+ // A line, a mouth that moves or a change of place is a cut's action, not an idle.
513
+ export const IDLE_TOO_BIG = /\b(says?|speaks?|talks?|whispers?|shouts?|mouths?|chews?|walks?|crosses|leaves|enters|runs?|sits? down|stands? up)\b|[说說讲講喊嚼]|走向|离开|離開|坐下|站起/i;
514
+ export const IDLE_MAX_CHARS = 90;
515
+ export function idlePhrase(idle, who, lang = 'en') {
516
+ const text = String(idle || '').trim().replace(/[.。]+$/, '');
517
+ if (text === IDLE_STILL) return lang === 'zh' ? `${who}保持不动,只有呼吸与眨眼。` : `${who} holds still, only breathing and blinking.`;
518
+ return lang === 'zh' ? `${who}${text}。` : `${who} ${text}.`;
519
+ }
520
+ /** The idle motions of a cut, in on-screen order. */
521
+ export function idleSentence(cut, whoOf, lang = 'en') {
522
+ const parts = (cut.characters || []).filter((id) => cut.idle?.[id]).map((id) => idlePhrase(cut.idle[id], whoOf(id), lang));
523
+ return parts.length ? `${parts.join(' ')} ` : '';
524
+ }
525
+
438
526
  /**
439
527
  * A lock-off is written (what still moves, held), a move is written (why, when, along what, ending
440
528
  * where) — an empty camera line is read by the model as permission to drift.
@@ -614,8 +702,8 @@ export const CINEMATOGRAPHY_PRESETS = {
614
702
  'low-key': {
615
703
  label: 'low-key, practical-lit, negative space, wide open',
616
704
  framePhrase: 'low-key practical lighting',
617
- videoPhrase: 'low-key cinematography lit only by practical sources in the scene, faces held in shadow and shaped by side, back and rim light with no flat fill, compositions with generous negative space and layered out-of-focus foregrounds, shallow depth of field',
618
- videoPhraseZh: '低调光影,光线只来自场景内的现场光源,人物处于阴影中,以侧光、逆光、轮廓光勾勒,不打平光补光,构图留白,前景虚化增加层次,大光圈浅景深',
705
+ videoPhrase: 'low-key cinematography lit only by practical sources in the scene, faces held in shadow and shaped by side, back and rim light with no flat fill, compositions with generous negative space, shallow depth of field',
706
+ videoPhraseZh: '低调光影,光线只来自场景内的现场光源,人物处于阴影中,以侧光、逆光、轮廓光勾勒,不打平光补光,构图留白,大光圈浅景深',
619
707
  sourceHint: 'the practical in or just outside the frame that the key comes from (a lamp, a window, a screen, a sign), not a film light',
620
708
  // 包围光 / 世界光: light from the lens axis or from nowhere flattens the face and kills the shadow side.
621
709
  bannedLight: ['frontal', 'ambient'],
@@ -630,8 +718,8 @@ export const CINEMATOGRAPHY_PRESETS = {
630
718
  'sweet-romance': {
631
719
  label: 'sweet romance: soft high-key glow, backlit halo, pastel, dreamy bokeh',
632
720
  framePhrase: 'soft romantic lighting',
633
- videoPhrase: 'soft romantic cinematography of a modern idol romance drama, bright airy high-key light from soft motivated sources, faces softly and flatteringly lit with a warm backlight haloing the hair, gentle low-contrast pastel grade with creamy highlights and lifted shadows, dreamy diffusion and soft halation, shot wide open with an extremely shallow depth of field, dreamy oversized creamy bokeh balls and soft out-of-focus foregrounds',
634
- videoPhraseZh: '唯美浪漫偶像剧质感,明亮通透的柔光,光线来自窗光、夕阳、串灯等柔和光源,人物面部柔和明亮,暖色逆光在发丝上形成光晕,低反差粉嫩柔和色调,高光奶油般柔和,暗部通透不死黑,柔焦朦胧,全开大光圈极浅景深,梦幻的大颗奶油光斑散景与柔焦前景',
721
+ videoPhrase: 'soft romantic cinematography of a modern idol romance drama, bright airy high-key light from soft motivated sources, faces softly and flatteringly lit with a warm backlight haloing the hair, gentle low-contrast pastel grade with creamy highlights and lifted shadows, dreamy diffusion and soft halation, shot wide open with an extremely shallow depth of field, dreamy oversized creamy bokeh balls',
722
+ videoPhraseZh: '唯美浪漫偶像剧质感,明亮通透的柔光,光线来自窗光、夕阳、串灯等柔和光源,人物面部柔和明亮,暖色逆光在发丝上形成光晕,低反差粉嫩柔和色调,高光奶油般柔和,暗部通透不死黑,柔焦朦胧,全开大光圈极浅景深,梦幻的大颗奶油光斑散景',
635
723
  sourceHint: 'the soft source in or just outside the frame that the light comes from (a big window, the low sun, overcast sky, string lights, a café lamp), not a film light',
636
724
  // 底光 / 顶光: the horror and interrogation lights — they carve eye sockets and chins.
637
725
  bannedLight: ['underlight', 'top'],
@@ -685,8 +773,8 @@ export const CINEMATOGRAPHY_PRESETS = {
685
773
  'costume-dark': {
686
774
  label: 'dark historical realism: firelight and lattice windows, faces in shadow, smoke, earthy texture',
687
775
  framePhrase: 'low-key firelit period lighting',
688
- videoPhrase: 'gritty low-key historical cinematography with realistic texture, lit only by candles, oil lamps, braziers, torches and daylight or moonlight through lattice windows, faces carved by side and back light and falling into deep shadow, smoke and dust hanging in shafts of light, desaturated earthy palette of ochre, umber and cold steel blue, worn fabric, weathered wood and stone, sweeping wide and extreme-wide shots with vast negative space of darkness, smoke and sky, compositions with negative space and layered out-of-focus foregrounds, shallow depth of field',
689
- videoPhraseZh: '写实质感的暗黑古装影调,光线只来自蜡烛、油灯、火盆、火把,以及透过窗棂的日光或月光,人物面部被侧光与逆光雕刻,隐入浓重阴影,烟尘在光束中悬浮,低饱和的赭石、深棕与冷铁蓝色调,布料磨旧,木石斑驳,大量全景与大全景,大面积留白于黑暗、烟尘与天空之中,构图留白,前景虚化增加层次,大光圈浅景深',
776
+ videoPhrase: 'gritty low-key historical cinematography with realistic texture, lit only by candles, oil lamps, braziers, torches and daylight or moonlight through lattice windows, faces carved by side and back light and falling into deep shadow, smoke and dust hanging in shafts of light, desaturated earthy palette of ochre, umber and cold steel blue, worn fabric, weathered wood and stone, sweeping wide and extreme-wide shots with vast negative space of darkness, smoke and sky, compositions with negative space, shallow depth of field',
777
+ videoPhraseZh: '写实质感的暗黑古装影调,光线只来自蜡烛、油灯、火盆、火把,以及透过窗棂的日光或月光,人物面部被侧光与逆光雕刻,隐入浓重阴影,烟尘在光束中悬浮,低饱和的赭石、深棕与冷铁蓝色调,布料磨旧,木石斑驳,大量全景与大全景,大面积留白于黑暗、烟尘与天空之中,构图留白,大光圈浅景深',
690
778
  sourceHint: 'the period source the key comes from (a candle, an oil lamp, a brazier, a torch, daylight or moonlight through a lattice window), never an electric light',
691
779
  bannedLight: ['frontal', 'ambient'],
692
780
  bannedWhy: 'floods the face (包围光 / 世界光) — stage the person so the flame or the lattice window sits beside or behind them',
@@ -1221,6 +1309,8 @@ export function slug(name) {
1221
1309
  export function paramsOf(project) {
1222
1310
  const p = { ...DEFAULT_PARAMS, ...(project.params || {}) };
1223
1311
  p.thresholds = { ...DEFAULT_PARAMS.thresholds, ...(project.params?.thresholds || {}) };
1312
+ // Named, or read from the look, so a later change of look carries the level with it.
1313
+ if (project.params?.foregroundLevel == null) p.foregroundLevel = foregroundForLook(p);
1224
1314
  // The model decides how long a segment may be; an explicit param still wins, but it is then gated.
1225
1315
  const mode = videoModel(p);
1226
1316
  if (project.params?.maxSegmentSeconds == null) p.maxSegmentSeconds = mode.maxSegmentSeconds;
@@ -1619,6 +1709,7 @@ export const GATES = [
1619
1709
  if (!ADAPT_MODES.includes(params.adaptMode)) out.push(`params.adaptMode must be one of ${ADAPT_MODES.join(' / ')}`);
1620
1710
  if (!VIDEO_MODES.includes(params.video)) out.push(`params.video must be one of ${VIDEO_MODES.join(' / ')}`);
1621
1711
  if (!AUDIO_TRACK_MODES.includes(params.audioTrack)) out.push(`params.audioTrack must be one of ${AUDIO_TRACK_MODES.join(' / ')}`);
1712
+ if (!FOREGROUND_LEVELS[params.foregroundLevel]) out.push(`params.foregroundLevel must be one of ${Object.keys(FOREGROUND_LEVELS).join(' / ')}`);
1622
1713
  if (!REGISTERS.includes(params.register)) out.push(`params.register (语体) must be one of ${REGISTERS.join(' / ')} — it decides how the lines are written and the accent the model speaks`);
1623
1714
  if (!REFERENCE_MODES.includes(params.references)) out.push(`params.references must be one of ${REFERENCE_MODES.join(' / ')}`);
1624
1715
  if (!DURATION_MODES.includes(params.videoDuration)) out.push(`params.videoDuration must be one of ${DURATION_MODES.join(' / ')}`);
@@ -3100,15 +3191,16 @@ export const GATES = [
3100
3191
  }
3101
3192
  return out;
3102
3193
  }),
3103
- gate('photography', 'storyboard', 'every cut names its lens and stop, its key-light direction, and enough foreground; the cinematography preset (named source, preferred light, wide open)', (ctx) => {
3194
+ gate('photography', 'storyboard', 'every cut names its lens and stop, its key-light direction, and a foreground share within params.foregroundLevel; the cinematography preset (named source, preferred light, wide open)', (ctx) => {
3104
3195
  const { project, params } = ctx;
3105
3196
  const out = [];
3106
3197
  const kit = params.camera || DEFAULT_PARAMS.camera;
3107
3198
  const look = cinematographyOf(params);
3108
- for (const [, e] of ctx.episodes) {
3199
+ for (const [epId, e] of ctx.episodes) {
3200
+ let epCuts = 0, epForeground = 0;
3109
3201
  for (const seg of e.storyboard?.segments || []) {
3110
3202
  const cuts = seg.cuts || [];
3111
- let withForeground = 0;
3203
+ epCuts += cuts.length;
3112
3204
  let withPeople = 0, shaped = 0, withBokeh = 0, wides = 0;
3113
3205
  cuts.forEach((c, k) => {
3114
3206
  const where = `${seg.id} cut ${k + 1}`;
@@ -3132,10 +3224,11 @@ export const GATES = [
3132
3224
  // Foreground.
3133
3225
  const fg = String(c.foreground || '').trim();
3134
3226
  if (fg) {
3135
- withForeground += 1;
3227
+ epForeground += 1;
3136
3228
  if (frame.trim() && !frame.includes(fg)) out.push(`${where}: frame prompt must name the foreground "${fg}"`);
3137
3229
  if (frame.trim() && !/out of focus|foreground blur|defocus|bokeh|soft foreground/i.test(frame)) out.push(`${where}: the foreground is named but never said to be out of focus`);
3138
3230
  }
3231
+ else if (c.ots) epForeground += 1; // the near shoulder is the foreground
3139
3232
  if (c.foreground === undefined) out.push(`${where}: foreground is required — name what sits between the lens and the subject, or "" for a deliberately clean frame`);
3140
3233
  if (!look) return;
3141
3234
  // The cinematography preset: a named source, no banned light on a person, shot wide open.
@@ -3161,8 +3254,10 @@ export const GATES = [
3161
3254
  if (look && look.bokehShare && cuts.length && withBokeh < look.bokehShare * cuts.length) out.push(`${seg.id}: only ${withBokeh} of ${cuts.length} frames name their bokeh — at least ${Math.round(look.bokehShare * 100)} % must say what melts into bokeh behind or in front of the subject (params.cinematography ${params.cinematography})`);
3162
3255
  if (look && look.widePerSegment && cuts.length && wides < look.widePerSegment) out.push(`${seg.id}: no wide or extreme-wide cut — ${params.cinematography} needs at least ${look.widePerSegment} per segment, a 全景 / 大全景 with vast negative space`);
3163
3256
  if (look && withPeople && shaped < look.preferredShare * withPeople) out.push(`${seg.id}: only ${shaped} of ${withPeople} cuts with people are ${look.preferredWhat} (${look.preferredLight.join(' / ')}) — at least ${Math.round(look.preferredShare * 100)} % must (params.cinematography ${params.cinematography}); re-stage the person against the source or move the camera`);
3164
- // "Reasonably" means most of them: a segment of clean frames is flat.
3165
- if (cuts.length && withForeground * 2 < cuts.length) out.push(`${seg.id}: only ${withForeground} of ${cuts.length} cuts have a foreground element — at least half must, or the segment has no depth`);
3257
+ }
3258
+ const band = foregroundBand(params.foregroundLevel, epCuts);
3259
+ if (band && (epForeground < band.min || epForeground > band.max)) {
3260
+ out.push(`E${pad2(epId)}: ${epForeground} of ${epCuts} cuts have a foreground — params.foregroundLevel ${params.foregroundLevel} allows ${band.min}–${band.max}. Change which cuts carry one (story first), or change the level`);
3166
3261
  }
3167
3262
  }
3168
3263
  return out;
@@ -3553,7 +3648,7 @@ export const GATES = [
3553
3648
  const text = texts.get(k + 1);
3554
3649
  if (!text) return; // the dialect gate reports the missing shot
3555
3650
  const lines = claimedLines(scene, [c]);
3556
- if (mode.family === 'h3' && lines.length && (c.characters || []).length >= 2 && !/does not speak|lips remain completely closed/.test(text)) out.push(`${seg.id} [Shot ${k + 1}]: two people on screen while one speaks — say the other "does not speak; ${LIPS_CLOSED}" or the lip-sync lands on the more visible face (measured 3/3 vs 2/3)`);
3651
+ if (mode.family === 'h3' && lines.length && (c.characters || []).length >= 2 && !c.ots && !/does not speak|lips remain completely closed/.test(text)) out.push(`${seg.id} [Shot ${k + 1}]: two people on screen while one speaks — say the other "does not speak; ${LIPS_CLOSED}" or the lip-sync lands on the more visible face (measured 3/3 vs 2/3)`);
3557
3652
  if (sheetOf) for (const id of c.characters || []) {
3558
3653
  const label = sheetOf.get(id);
3559
3654
  if (label && !text.includes(label)) out.push(`${seg.id} shot ${k + 1}: ${characterById(project, id)?.identityEn || id} is on screen but the shot never binds ${label} — a person bound in one shot and unbound in the next becomes two people`);
@@ -3564,6 +3659,93 @@ export const GATES = [
3564
3659
  }
3565
3660
  return out;
3566
3661
  }),
3662
+ gate('two-person-framing', 'storyboard', 'sheets mode, two-person segments: a single names only its person, every look has a screen direction toward the other, the sides are locked, over-the-shoulder is said', (ctx) => {
3663
+ const { project, params } = ctx;
3664
+ const out = [];
3665
+ if (params.references !== 'sheets') return out;
3666
+ const lang = videoModel(params).promptLang === 'zh' ? 'zh' : 'en';
3667
+ for (const [, e] of ctx.episodes) {
3668
+ for (const seg of e.storyboard?.segments || []) {
3669
+ const people = segmentCharacters(seg);
3670
+ if (people.length < 2) continue;
3671
+ const cuts = seg.cuts || [];
3672
+ const axis = isObject(seg.axis) ? seg.axis : null;
3673
+ const labels = characterLabels(segmentReferences(project, e, seg));
3674
+ const whoOf = (id) => labels.get(id) || identityOf(characterById(project, id));
3675
+ const prompt = String(seg.prompt || '');
3676
+ const texts = prompt.trim() ? shotTexts(prompt, videoModel(params).family, params.references) : new Map();
3677
+ if (prompt.trim() && axis?.screenLeft && axis?.screenRight && !cuts.some((c) => c.axisSide === 'cross')) {
3678
+ const line = sidesLine(axis, whoOf, lang);
3679
+ if (!prompt.includes(line)) out.push(`${seg.id}: the prompt must lock the sides — add "${line}"`);
3680
+ }
3681
+ cuts.forEach((c, k) => {
3682
+ const where = `${seg.id} cut ${k + 1}`;
3683
+ const chars = c.characters || [];
3684
+ const text = texts.get(k + 1);
3685
+ if (c.ots) {
3686
+ if (!chars.includes(c.ots) || chars.length !== 2) out.push(`${where}: ots must be one of exactly two people on screen — the one whose shoulder is near the lens`);
3687
+ else if (text && !text.includes(overShoulderPhrase(whoOf(c.ots), axis?.screenLeft === c.ots ? 'left' : 'right', lang))) out.push(`${where}: the shot must say the over-the-shoulder — paste "${overShoulderPhrase(whoOf(c.ots), axis?.screenLeft === c.ots ? 'left' : 'right', lang)}"`);
3688
+ } else if (chars.length === 1 && text) {
3689
+ const phrase = onlyInFramePhrase(whoOf(chars[0]), lang);
3690
+ if (!text.includes(phrase)) out.push(`${where}: a single must open with "${phrase}"`);
3691
+ for (const other of people.filter((id) => id !== chars[0])) {
3692
+ const label = labels.get(other);
3693
+ const identity = String(characterById(project, other)?.identityEn || '').trim();
3694
+ if ((label && new RegExp(`${label}(?!\\d)`).test(text)) || (identity && text.includes(identity))) out.push(`${where}: a single on ${chars[0]} must not describe ${other} — whoever a shot names ends up on screen`);
3695
+ }
3696
+ }
3697
+ for (const id of chars) {
3698
+ if (id === c.ots) continue;
3699
+ const value = c.eyeline?.[id];
3700
+ if (!value) { out.push(`${where}: eyeline.${id} is required — "screen-left|screen-right level|up|down", "away screen-left" or "task"`); continue; }
3701
+ if (!eyelineValid(value)) { out.push(`${where}: eyeline.${id} "${value}" is not valid — "screen-left|screen-right level|up|down", "away screen-left" or "task"`); continue; }
3702
+ const look = parseEyeline(value);
3703
+ if (axis && !look.task && !look.away && c.axisSide !== 'cross' && (id === axis.screenLeft || id === axis.screenRight)) {
3704
+ const toward = id === axis.screenLeft ? 'screen-right' : 'screen-left';
3705
+ if (look.to !== toward) out.push(`${where}: ${id} stands on ${id === axis.screenLeft ? 'screen left' : 'screen right'}, so looking at the other person is ${toward}, not ${look.to} — or say "away ${look.to}"`);
3706
+ }
3707
+ if (text && !text.includes(eyelinePhrase(value, whoOf(id), lang))) out.push(`${where}: the shot must state the eyeline — paste "${eyelinePhrase(value, whoOf(id), lang)}"`);
3708
+ }
3709
+ });
3710
+ }
3711
+ }
3712
+ return out;
3713
+ }),
3714
+ gate('idle-motion', 'storyboard', 'sheets mode: in a cut with two or more visible faces, everyone but the one carrying the action has a small motion of their own, written into the shot', (ctx) => {
3715
+ const { project, params } = ctx;
3716
+ const out = [];
3717
+ if (params.references !== 'sheets') return out;
3718
+ const lang = videoModel(params).promptLang === 'zh' ? 'zh' : 'en';
3719
+ for (const [, e] of ctx.episodes) {
3720
+ for (const seg of e.storyboard?.segments || []) {
3721
+ const labels = characterLabels(segmentReferences(project, e, seg));
3722
+ const whoOf = (id) => labels.get(id) || identityOf(characterById(project, id));
3723
+ const prompt = String(seg.prompt || '');
3724
+ const texts = prompt.trim() ? shotTexts(prompt, videoModel(params).family, params.references) : new Map();
3725
+ (seg.cuts || []).forEach((c, k) => {
3726
+ const where = `${seg.id} cut ${k + 1}`;
3727
+ const visible = (c.characters || []).filter((id) => id !== c.ots);
3728
+ for (const [id, idle] of Object.entries(isObject(c.idle) ? c.idle : {})) {
3729
+ if (!(c.characters || []).includes(id)) out.push(`${where}: idle.${id} — that person is not on screen`);
3730
+ const text = String(idle || '').trim();
3731
+ if (text.length > IDLE_MAX_CHARS) out.push(`${where}: idle.${id} is ${text.length} chars, max ${IDLE_MAX_CHARS} — one small motion`);
3732
+ if (IDLE_TOO_BIG.test(text)) out.push(`${where}: idle.${id} "${text}" is too big for an idle — a line, a moving mouth or a change of place is a cut's action`);
3733
+ const phrase = idlePhrase(idle, whoOf(id), lang);
3734
+ const shot = texts.get(k + 1);
3735
+ if (shot && !shot.includes(phrase)) out.push(`${where}: the shot must carry the idle — paste "${phrase}"`);
3736
+ }
3737
+ if (visible.length < 2) return;
3738
+ const idled = visible.filter((id) => String(c.idle?.[id] || '').trim());
3739
+ if (idled.length < visible.length - 1) {
3740
+ const missing = visible.filter((id) => !idled.includes(id));
3741
+ out.push(`${where}: ${visible.length} people are visible but only ${idled.length} have an idle — give all but the one carrying the action a small motion in cut.idle (${missing.join(', ')}), or "${IDLE_STILL}" when keeping still is the performance`);
3742
+ }
3743
+ if (/^\s*(only|nothing but)\b|只有|只剩/i.test(String(c.cameraNote?.hold || ''))) out.push(`${where}: cameraNote.hold says only "${c.cameraNote.hold}" moves, but the people in frame move too — drop "only"`);
3744
+ });
3745
+ }
3746
+ }
3747
+ return out;
3748
+ }),
3567
3749
  gate('audio-track', 'storyboard', 'Seed Audio node per segment: the derived prompt fits the model (≤ 120 s, ≤ 2048 chars, ≤ 3 voices in full mode)', (ctx) => {
3568
3750
  const { project, params } = ctx;
3569
3751
  const out = [];
@@ -3599,6 +3781,9 @@ export const GATES = [
3599
3781
  const action = String(c.motion?.action || '');
3600
3782
  if (/\b(does not|doesn't|never)\b/i.test(action) || /没有|不去|不[看说动]/.test(action)) out.push(`${where}: motion.action is phrased as what does not happen — a model cannot film an absence; write the action that happens instead (a hold, a hand that stays)`);
3601
3783
  });
3784
+ const scene = (e.scenes || [])[Number(seg.sceneIndex) - 1];
3785
+ const twoFaceLines = scene ? cuts.filter((c) => (c.characters || []).length >= 2 && !c.ots && claimedLines(scene, [c]).length).length : 0;
3786
+ if (twoFaceLines >= 2) out.push(`${seg.id}: ${twoFaceLines} lines are played with both faces in frame — try the pattern in craft-shots.md: a two-shot once, over-the-shoulder for the exchange, a clean single for the punchline`);
3602
3787
  if (cuts.length >= 4 && ups >= 2 && downs === 0) out.push(`${seg.id}: every cut tightens — by mid-segment there is nowhere left to go; step back out once or hold the working size`);
3603
3788
  }
3604
3789
  }
@@ -3999,11 +4184,24 @@ function checkSpeechDirections(seg, prompt, lines, project, lang, refs = []) {
3999
4184
  return out;
4000
4185
  }
4001
4186
 
4187
+ /** Which image reference carries each person's face in a segment. */
4188
+ export function characterLabels(refs) {
4189
+ const images = refs.filter((r) => r.kind === 'image');
4190
+ const labels = new Map();
4191
+ for (const r of images) {
4192
+ const who = r.faceOf || (r.asset === 'sheet' || r.asset === 'headshot' ? r.entity : null);
4193
+ if (who && !labels.has(who)) labels.set(who, r.label);
4194
+ }
4195
+ for (const r of images.filter((x) => x.asset === 'headshot' || x.faceOf)) labels.set(r.faceOf || r.entity, r.label);
4196
+ return labels;
4197
+ }
4198
+
4002
4199
  function shotParagraphs(prompt) {
4003
4200
  const map = new Map();
4004
4201
  const re = /\[Shot (\d+)\]\s*([\s\S]*?)(?=\n\[Shot \d+\]|\n\s*\n|$)/g;
4005
4202
  let m;
4006
- while ((m = re.exec(prompt))) map.set(Number(m[1]), m[2].trim());
4203
+ // The soundscape line also says "at [Shot 1]": the shot's own paragraph comes first and is the one kept.
4204
+ while ((m = re.exec(prompt))) if (!map.has(Number(m[1]))) map.set(Number(m[1]), m[2].trim());
4007
4205
  return map;
4008
4206
  }
4009
4207
 
@@ -4215,7 +4413,7 @@ export function seedStoryboard(project, episode) {
4215
4413
  });
4216
4414
  return {
4217
4415
  ...episode,
4218
- storyboard: { plan: segmentPlan(project, episode), seedScenes, visualPlans, segments: [] },
4416
+ storyboard: { plan: { ...segmentPlan(project, episode), foregroundLevel: paramsOf(project).foregroundLevel }, seedScenes, visualPlans, segments: [] },
4219
4417
  };
4220
4418
  }
4221
4419
 
@@ -4503,7 +4701,17 @@ export function promptSkeleton(project, episode, segmentId) {
4503
4701
  const clipMusic = params.audioTrack === 'post' ? '' : musicOf(sound).intent;
4504
4702
  // Who else is on screen while `speaker` has a line: the H3 idiom keeps the lip-sync off them.
4505
4703
  // In keyframes mode the @imageN labels are frames, so a person is named by identity instead.
4506
- const othersOnScreen = (c, speaker) => (c.characters || []).filter((id) => id !== speaker).map((id) => (params.references === 'sheets' && sheetLabels.get(id) ? `the person in ${sheetLabels.get(id)}` : identityOf(characterById(project, id))));
4704
+ const framingLabels = characterLabels(allRefs);
4705
+ const whoOf = (id) => framingLabels.get(id) || identityOf(characterById(project, id));
4706
+ const framingLang = videoModel(params).promptLang === 'zh' ? 'zh' : 'en';
4707
+ const idleOf = (c) => (params.references === 'sheets' ? idleSentence(c, whoOf, framingLang) : '');
4708
+ const framingOf = (c) => (params.references === 'sheets' ? shotFraming(c, seg, whoOf, framingLang) : '');
4709
+ const sidesOf = () => {
4710
+ const axis = isObject(seg.axis) ? seg.axis : null;
4711
+ if (params.references !== 'sheets' || !axis?.screenLeft || !axis?.screenRight || cuts.some((c) => c.axisSide === 'cross')) return [];
4712
+ return [sidesLine(axis, whoOf, framingLang)];
4713
+ };
4714
+ const othersOnScreen = (c, speaker) => (c.characters || []).filter((id) => id !== speaker && id !== c.ots).map((id) => (params.references === 'sheets' && sheetLabels.get(id) ? `the person in ${sheetLabels.get(id)}` : identityOf(characterById(project, id))));
4507
4715
  let video;
4508
4716
  if (mode.family === 'h3') {
4509
4717
  const sheets = params.references === 'sheets';
@@ -4519,14 +4727,14 @@ export function promptSkeleton(project, episode, segmentId) {
4519
4727
  // In sheets mode the cut's own `frame` goes into the prompt and already carries the lens, the
4520
4728
  // light and the foreground (gated there), so only the shot size and camera move are added.
4521
4729
  const body = sheets
4522
- ? `${c.frame || '…'} ${SIZES[c.size] || c.size}, ${c.camera}.`
4730
+ ? `${framingOf(c)}${c.frame || '…'} ${SIZES[c.size] || c.size}, ${c.camera}.`
4523
4731
  : `… anchor on @image${k + 1} · ${SIZES[c.size] || c.size}, ${c.camera}, ${cameraPhrase(c, params)}. ${lightPhrase(c)}.${foregroundPhrase(c)}`;
4524
- return `${head}${body} ${motionSentence(c)} ${cameraClause(c)} ${d}${lips}`.replace(/\s+/g, ' ').trim();
4732
+ return `${head}${body} ${motionSentence(c)} ${idleOf(c)}${cameraClause(c)} ${d}${lips}`.replace(/\s+/g, ' ').trim();
4525
4733
  });
4526
4734
  // Keyframes are aligned by the alignment line; the voices are named either way.
4527
4735
  const voiceLegend = allRefs.filter((r) => r.kind !== 'image').map((r) => `${r.label} is ${r.use}.`);
4528
4736
  const lead = sheets
4529
- ? allRefs.map((r) => `${r.label} is ${r.use}.`)
4737
+ ? [...allRefs.map((r) => `${r.label} is ${r.use}.`), ...sidesOf()]
4530
4738
  : [h3AlignmentLine(cuts), ...voiceLegend];
4531
4739
  const look = sheets ? [`Look: ${lookOf()}.`, ''] : [];
4532
4740
  const soundscape = [sound.ambience || '…', ...cuts.map((c, k) => (sfxOf(k) ? `at [Shot ${k + 1}] ${sfxOf(k)}` : '')).filter(Boolean)].join('; ');
@@ -4537,18 +4745,18 @@ export function promptSkeleton(project, episode, segmentId) {
4537
4745
  // Wan's prompt is Chinese: the lens and the light direction translate, the note and the
4538
4746
  // foreground are the author's English prose, so they are left as blanks to write in Chinese.
4539
4747
  const shot = `${cameraPhrase(c, params, 'zh')}。${LIGHT_DIRECTIONS_ZH[c.light?.direction] || '…'},…。前景:${c.foreground ? '…(虚化)' : '无'}。`;
4540
- return [`${wanShotHeader(cuts, k)}…${shot}${motionSentence(c, 'zh')}${cameraClause(c, 'zh')}`, ...talk].join('\n');
4748
+ return [`${wanShotHeader(cuts, k)}${framingOf(c)}…${shot}${motionSentence(c, 'zh')}${idleOf(c)}${cameraClause(c, 'zh')}`, ...talk].join('\n');
4541
4749
  });
4542
4750
  const sfxAll = cuts.map((c, k) => (sfxOf(k) ? `镜头${k + 1}:${sfxOf(k)}` : '')).filter(Boolean).join(';');
4543
4751
  video = [
4544
- params.references === 'sheets' ? wanSheetLegendLine(allRefs) : wanLegendLine(cuts, allRefs), '',
4752
+ params.references === 'sheets' ? wanSheetLegendLine(allRefs) : wanLegendLine(cuts, allRefs), ...sidesOf(), '',
4545
4753
  ...blocks.flatMap((b) => [b, '']),
4546
4754
  ...(cine && params.references === 'sheets' ? [`画面:${cine.videoPhraseZh}${params.lookPhraseZh ? `,${params.lookPhraseZh}` : ''}。`, ''] : []),
4547
4755
  ...(grammarLines(seg, params, 'zh').length ? [...grammarLines(seg, params, 'zh'), ''] : []),
4548
4756
  `音频:对白为${accent || '…'},环境音${sound.ambience || '…'}${sfxAll ? `;${sfxAll}` : ''}${clipMusic ? `;配乐:${clipMusic}` : params.audioTrack === 'post' ? ';无配乐' : ''}。${params.subtitles ? '' : `${NO_SUBTITLES.zh}。`}`,
4549
4757
  ].join('\n');
4550
4758
  } else if (params.references === 'sheets') {
4551
- const legend = allRefs.map((r) => `${r.label} is ${r.use}.`);
4759
+ const legend = [...allRefs.map((r) => `${r.label} is ${r.use}.`), ...sidesOf()];
4552
4760
  const shots = cuts.map((c, k) => {
4553
4761
  const talk = dialogue[k].map((l) => {
4554
4762
  const base = `${l.speakerRef}${l.direction ? `, ${l.direction},` : ''} says: ${l.line}`;
@@ -4559,7 +4767,7 @@ export function promptSkeleton(project, episode, segmentId) {
4559
4767
  return `${base} while ${others.join(' and ')} ${others.length > 1 ? 'do' : 'does'} not speak, ${LIPS_CLOSED}`;
4560
4768
  }).join(' ');
4561
4769
  // The frame already carries the lens, the light and the foreground — do not repeat them.
4562
- return `Shot ${k + 1} (${starts[k].toFixed(1)}–${(starts[k] + Number(c.seconds)).toFixed(1)} s, ${SIZES[c.size] || c.size}, ${c.camera}): ${c.frame || '…'} ${motionSentence(c)} ${cameraClause(c)} ${talk}`.replace(/\s+/g, ' ').trim();
4770
+ return `Shot ${k + 1} (${starts[k].toFixed(1)}–${(starts[k] + Number(c.seconds)).toFixed(1)} s, ${SIZES[c.size] || c.size}, ${c.camera}): ${framingOf(c)}${c.frame || '…'} ${motionSentence(c)} ${idleOf(c)}${cameraClause(c)} ${talk}`.replace(/\s+/g, ' ').trim();
4563
4771
  });
4564
4772
  const sfxAll = cuts.map((c, k) => (sfxOf(k) ? `shot ${k + 1}: ${sfxOf(k)}` : '')).filter(Boolean).join('; ');
4565
4773
  video = [...legend, '', ...shots, '', ...actionLine, `Look: ${lookOf()}. Maintain continuity in character identities, clothing, prop ownership, scene layout and the camera axis across every shot.`, `Sound: ${sound.ambience || '…'}${sfxAll ? `; ${sfxAll}` : ''}.${accent && dialogue.flat().length ? ` All dialogue in ${accent}.` : ''}${clipMusic ? ` Music: ${clipMusic}.` : ' No music.'}${params.subtitles ? '' : ` ${NO_SUBTITLES.en[0].toUpperCase()}${NO_SUBTITLES.en.slice(1)}.`}`].join('\n');
@@ -5098,6 +5306,7 @@ export function initProject(opts) {
5098
5306
  lookPhrase: opts.look || DEFAULT_PARAMS.lookPhrase,
5099
5307
  style: opts.style || DEFAULT_PARAMS.style,
5100
5308
  imagePromptLang: opts.imageLang || DEFAULT_PARAMS.imagePromptLang,
5309
+ ...(opts.foreground ? { foregroundLevel: opts.foreground } : {}),
5101
5310
  // Picked from the genre unless named; `--cinematography none` opts out.
5102
5311
  cinematography: cine,
5103
5312
  // Named, or read from the genre only when it names an action style outright.
@@ -5253,7 +5462,7 @@ function main(argv) {
5253
5462
  adapt: flag(rest, '--adapt'), ratio: flag(rest, '--ratio'), video: flag(rest, '--video'), style: flag(rest, '--style'), lang: flag(rest, '--lang'),
5254
5463
  resolution: flag(rest, '--resolution'), accent: flag(rest, '--accent'),
5255
5464
  references: flag(rest, '--references'), duration: flag(rest, '--duration'), look: flag(rest, '--look'),
5256
- register: flag(rest, '--register'), imageLang: flag(rest, '--image-lang'),
5465
+ register: flag(rest, '--register'), imageLang: flag(rest, '--image-lang'), foreground: flag(rest, '--foreground'),
5257
5466
  cinematography: flag(rest, '--cinematography'), action: flag(rest, '--action'), violence: flag(rest, '--violence'), grammar: flag(rest, '--grammar'), comedy: flag(rest, '--comedy'), composition: flag(rest, '--composition'), story: flag(rest, '--story'), market: flag(rest, '--market'), format: flag(rest, '--format'),
5258
5467
  source: flag(rest, '--source'), sourcePath: flag(rest, '--source-path'),
5259
5468
  });