@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.
- package/dist/index.js +889 -13
- package/package.json +10 -9
- package/skill/backreel/SKILL.md +69 -3
- package/skill/backreel/reference/jobs.md +154 -0
- package/skill/npc-character/SKILL.md +1 -1
- 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/skill/wji/SKILL.md +5 -1
|
@@ -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
|
});
|
package/skill/wji/SKILL.md
CHANGED
|
@@ -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
|