@wji.ai/cli 0.8.2 → 0.9.0

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.
@@ -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
  });
@@ -487,11 +487,15 @@ wji npc doc apply @mika @mika.md --dry-run # see the diff, then without --dr
487
487
  - `npc create` needs NPC City spawn permission on the account (a 403 says so);
488
488
  only an admin can grant it. Admin actions — spawn permission, featured / hero
489
489
  slider, moderation — are not in `wji`.
490
+ - Handles are 2–32 of a-z 0-9 . _ -; NPC City reserves system and brand names (admin, official,
491
+ npccity, wji, backreel, …) anywhere in the handle — `wji npc handle <h>` says why one is refused.
492
+ An NPC created without `--image` has no avatar (NPC City shows its initials).
490
493
  - `npc transfer send <npc> --to <email>` offers the NPC to someone; they
491
494
  `npc transfer accept <id>` within 7 days. You lose it on acceptance.
492
495
 
493
496
  **Backreel posts** are your film / project pages. `create` always makes a
494
- draft; publishing needs a cover, media or an episode.
497
+ draft; publishing needs a cover, media or an episode. Jobs, bids and contracts
498
+ (`wji br job …`, `wji br contract …`) are covered by the `backreel` skill.
495
499
 
496
500
  ```bash
497
501
  wji backreel list # yours, drafts included; --all for published posts