@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 +10 -9
- package/skill/short-drama/SKILL.md +9 -2
- package/skill/short-drama/examples/night-shift/project.json +5 -4
- package/skill/short-drama/reference/cinematography-low-key.md +5 -6
- package/skill/short-drama/reference/cinematography.md +1 -1
- package/skill/short-drama/reference/craft-motion.md +21 -1
- package/skill/short-drama/reference/craft-shots.md +24 -0
- package/skill/short-drama/reference/schema.md +5 -1
- package/skill/short-drama/reference/stage-characters.md +26 -16
- package/skill/short-drama/reference/stage-storyboard.md +31 -5
- package/skill/short-drama/reference/workspace-layout.md +1 -1
- package/skill/short-drama/scripts/selftest.mjs +117 -8
- package/skill/short-drama/scripts/short-drama.mjs +235 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wji.ai/cli",
|
|
3
|
-
"version": "0.8.
|
|
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": ">=
|
|
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**
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.**
|
|
82
|
-
glass, a door frame, a hanging lamp, the shoulder of the other person (an
|
|
83
|
-
it from the scene's anchors where you can, so it also carries continuity.
|
|
84
|
-
|
|
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`;
|
|
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-
|
|
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
|
|
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
|
-
│
|
|
69
|
-
│
|
|
70
|
-
│
|
|
71
|
-
│
|
|
72
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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:
|
|
171
|
-
face
|
|
172
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
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
|
|
858
|
-
|
|
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,
|
|
2311
|
-
eq(GATES.filter((g) => !g.advisory).length,
|
|
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, '✓
|
|
2434
|
-
eq(rep.failed, 0, 'nothing failed'); eq(rep.hard,
|
|
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
|
|
419
|
-
* clean frame
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3165
|
-
|
|
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
|
-
|
|
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
|
|
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
|
});
|