@wji.ai/cli 0.6.1 → 0.7.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.
Files changed (35) hide show
  1. package/README.md +19 -2
  2. package/dist/commands/backreel-enquiry.js +171 -0
  3. package/dist/commands/backreel-episode.js +243 -0
  4. package/dist/commands/backreel-fund.js +299 -0
  5. package/dist/commands/backreel.js +141 -18
  6. package/dist/commands/gen.js +5 -1
  7. package/dist/commands/npc-album.js +201 -0
  8. package/dist/commands/npc-live.js +316 -0
  9. package/dist/commands/npc.js +26 -2
  10. package/dist/core/backreel.js +23 -0
  11. package/dist/core/client.js +18 -1
  12. package/dist/core/image.js +106 -0
  13. package/dist/core/npc.js +1 -1
  14. package/dist/core/vod.js +149 -0
  15. package/package.json +1 -1
  16. package/skill/backreel/SKILL.md +171 -0
  17. package/skill/backreel/reference/crowdfunding.md +82 -0
  18. package/skill/backreel/reference/enquiries.md +80 -0
  19. package/skill/backreel/reference/episodes.md +104 -0
  20. package/skill/backreel/reference/images.md +76 -0
  21. package/skill/npc-character/SKILL.md +111 -0
  22. package/skill/npc-character/examples/nagi.character.md +117 -0
  23. package/skill/npc-character/examples/nagi.stage.md +34 -0
  24. package/skill/npc-character/examples/yuzu.character.md +110 -0
  25. package/skill/npc-character/examples/yuzu.stage.md +30 -0
  26. package/skill/npc-character/reference/character-craft.md +153 -0
  27. package/skill/npc-character/reference/document-format.md +151 -0
  28. package/skill/npc-character/reference/interview.md +44 -0
  29. package/skill/npc-character/reference/life.md +92 -0
  30. package/skill/npc-character/reference/runtime.md +95 -0
  31. package/skill/npc-character/reference/stage-craft.md +109 -0
  32. package/skill/npc-character/reference/visual-kit.md +81 -0
  33. package/skill/npc-character/reference/voice.md +101 -0
  34. package/skill/wji/SKILL.md +1 -1
  35. package/skill/wji/reference/commands.md +25 -5
@@ -0,0 +1,299 @@
1
+ import { Command } from 'commander';
2
+ import { POSTS, backreelClient, idOf, loadPost, oneOf } from '../core/backreel.js';
3
+ import { color, detail, info, render, relativeTime, success, table, truncate } from '../core/output.js';
4
+ import { confirm } from '../core/prompt.js';
5
+ const CURRENCIES = ['hkd', 'usd', 'twd', 'sgd', 'eur', 'gbp'];
6
+ const MAX_TIERS = 10;
7
+ const num = (value, flag, min = 0) => {
8
+ const n = Number(value);
9
+ if (!Number.isFinite(n) || n < min)
10
+ throw new Error(`${flag} must be a number ≥ ${min}`);
11
+ return n;
12
+ };
13
+ /** The server rejects a tier below the minimum donation (and below 1) with a bare 500. */
14
+ function tierAmount(value, minAmount) {
15
+ const floor = Math.max(1, minAmount || 0);
16
+ const n = num(value, '--amount');
17
+ if (n < floor)
18
+ throw new Error(`--amount must be at least ${floor} (the minimum donation)`);
19
+ return Math.round(n);
20
+ }
21
+ function itemIndex(n, count, what) {
22
+ const i = Number(n);
23
+ if (!Number.isInteger(i) || i < 1 || i > count)
24
+ throw new Error(`${what} ${n} does not exist — there are ${count}`);
25
+ return i - 1;
26
+ }
27
+ /** campaign as the update DTO takes it: the owner view plus avatar ids, minus derived totals. */
28
+ function campaignDto(c) {
29
+ return {
30
+ goalAmount: c?.goalAmount ?? 0,
31
+ offlineRaisedAmount: c?.offlineRaisedAmount ?? 0,
32
+ deadline: c?.deadline ?? null,
33
+ offlineSupporters: (c?.offlineSupporters || []).map((s, i) => ({
34
+ name: s.name,
35
+ amount: s.amount ?? 0,
36
+ sort: i,
37
+ avatar: idOf(s.avatar) ?? null,
38
+ avatarUrl: s.avatarUrl ?? null,
39
+ })),
40
+ };
41
+ }
42
+ /** donation config as the update DTO takes it (the read view adds `remaining` per tier). */
43
+ function donationDto(d) {
44
+ return {
45
+ enabled: !!d?.enabled,
46
+ currency: d?.currency || 'hkd',
47
+ minAmount: d?.minAmount ?? 0,
48
+ tiers: (d?.tiers || []).map((t) => ({ id: t.id, title: t.title, amount: t.amount, perks: t.perks || '', limit: t.limit ?? null })),
49
+ };
50
+ }
51
+ function requireCampaign(post) {
52
+ if (post.kind !== 'crowdfunding') {
53
+ throw new Error(`"${post.title}" is a ${post.kind} post — campaign fields need --kind crowdfunding (which drops its episodes)`);
54
+ }
55
+ }
56
+ const put = (client, id, body) => client.put(`${POSTS}/list/${id}`, body);
57
+ function printFund(post) {
58
+ const c = post.campaign;
59
+ const d = post.donation || {};
60
+ // The campaign view reports the stats currency, which reads HKD until the first donation;
61
+ // the configured donation currency is the real one (it cannot change after that).
62
+ const cur = (d.currency || c?.currency || 'hkd').toUpperCase();
63
+ if (c) {
64
+ const pct = c.goalAmount ? ` (${Math.round(((c.raisedAmount || 0) / c.goalAmount) * 100)}%)` : '';
65
+ detail([
66
+ ['goal', `${c.goalAmount} ${cur}`],
67
+ ['raised', `${c.raisedAmount} ${cur}${pct} = offline ${c.offlineRaisedAmount ?? 0} + online ${(c.raisedAmount || 0) - (c.offlineRaisedAmount || 0)}`],
68
+ ['supporters', c.supporterCount],
69
+ ['deadline', c.deadline || color.dim('none')],
70
+ ]);
71
+ process.stdout.write('\n');
72
+ }
73
+ else {
74
+ info(color.dim(`${post.kind} post — no campaign (goal / offline supporters are crowdfunding-only); donations still work`));
75
+ }
76
+ detail([
77
+ ['donations', d.enabled ? color.green('on') : 'off'],
78
+ ['currency', (d.currency || 'hkd').toUpperCase()],
79
+ ['minimum', d.minAmount ?? 0],
80
+ ]);
81
+ if ((d.tiers || []).length) {
82
+ process.stdout.write('\n');
83
+ table(d.tiers.map((t, i) => ({ ...t, n: i + 1 })), [
84
+ { header: '#', value: (t) => t.n },
85
+ { header: 'tier', value: (t) => truncate(t.title, 24) },
86
+ { header: 'amount', value: (t) => t.amount },
87
+ { header: 'left', value: (t) => (t.limit ? `${t.remaining}/${t.limit}` : '∞') },
88
+ { header: 'perks', value: (t) => truncate(t.perks, 40) },
89
+ ]);
90
+ }
91
+ }
92
+ function printSupporters(post) {
93
+ table((post.campaign?.offlineSupporters || []).map((s, i) => ({ ...s, n: i + 1 })), [
94
+ { header: '#', value: (s) => s.n },
95
+ { header: 'name', value: (s) => s.name },
96
+ { header: 'amount', value: (s) => s.amount },
97
+ { header: 'avatar', value: (s) => idOf(s.avatar) || '' },
98
+ ]);
99
+ }
100
+ export function fundCommand(globals) {
101
+ const fund = new Command('fund')
102
+ .aliases(['funding', 'campaign'])
103
+ .description('Crowdfunding campaign, donation settings and tiers, offline supporters, donation records');
104
+ fund.command('show <postId>', { isDefault: true })
105
+ .description('Campaign progress, donation settings and tiers')
106
+ .action(async (postId) => {
107
+ const client = backreelClient(globals);
108
+ const post = await loadPost(client, postId);
109
+ render({ campaign: post.campaign, donation: post.donation, kind: post.kind }, () => printFund(post));
110
+ });
111
+ fund.command('set <postId>')
112
+ .description('Campaign goal / offline total / deadline and donation switches; only what you pass changes')
113
+ .option('--goal <amount>', 'campaign goal (≥1 to publish a crowdfunding post)')
114
+ .option('--offline-raised <amount>', 'money raised outside Backreel, added to the progress bar')
115
+ .option('--deadline <date>', 'ISO date (e.g. 2026-12-31); "none" clears — donations stop after it')
116
+ .option('--donations <on|off>', 'accept online donations (Stripe)')
117
+ .option('--currency <code>', `${CURRENCIES.join(' | ')} — locked once a donation exists`)
118
+ .option('--min <amount>', 'minimum donation')
119
+ .action(async (postId, opts) => {
120
+ const client = backreelClient(globals);
121
+ const post = await loadPost(client, postId);
122
+ const body = {};
123
+ if (opts.goal !== undefined || opts.offlineRaised !== undefined || opts.deadline !== undefined) {
124
+ requireCampaign(post);
125
+ const campaign = campaignDto(post.campaign);
126
+ if (opts.goal !== undefined)
127
+ campaign.goalAmount = num(opts.goal, '--goal');
128
+ if (opts.offlineRaised !== undefined)
129
+ campaign.offlineRaisedAmount = num(opts.offlineRaised, '--offline-raised');
130
+ if (opts.deadline !== undefined) {
131
+ if (['none', ''].includes(String(opts.deadline).toLowerCase()))
132
+ campaign.deadline = null;
133
+ else {
134
+ const date = new Date(opts.deadline);
135
+ if (Number.isNaN(date.getTime()))
136
+ throw new Error('--deadline must be a date like 2026-12-31');
137
+ campaign.deadline = date.toISOString();
138
+ }
139
+ }
140
+ body.campaign = campaign;
141
+ }
142
+ if (opts.donations !== undefined || opts.currency !== undefined || opts.min !== undefined) {
143
+ const donation = donationDto(post.donation);
144
+ if (opts.donations !== undefined)
145
+ donation.enabled = oneOf(String(opts.donations).toLowerCase(), ['on', 'off'], '--donations') === 'on';
146
+ if (opts.currency !== undefined)
147
+ donation.currency = oneOf(String(opts.currency).toLowerCase(), CURRENCIES, '--currency');
148
+ if (opts.min !== undefined)
149
+ donation.minAmount = Math.round(num(opts.min, '--min'));
150
+ body.donation = donation;
151
+ }
152
+ if (!Object.keys(body).length)
153
+ throw new Error('Nothing to change — see `wji backreel fund set --help`');
154
+ const updated = await put(client, postId, body);
155
+ render(updated, (p) => {
156
+ success(`Updated ${Object.keys(body).join(' and ')} of "${p.title}"`);
157
+ printFund(p);
158
+ });
159
+ });
160
+ // ── donation tiers ─────────────────────────────────────────────────────────
161
+ const tier = fund.command('tier').description(`Donation tiers (≤${MAX_TIERS}): a title, an amount, perks, an optional cap`);
162
+ tier.command('add <postId>')
163
+ .requiredOption('--title <text>', 'tier name (≤60)')
164
+ .requiredOption('--amount <n>', 'price, ≥ the minimum donation')
165
+ .option('--perks <text>', 'what supporters get (≤1000)')
166
+ .option('--limit <n>', 'number of slots (default unlimited)')
167
+ .action(async (postId, opts) => {
168
+ const client = backreelClient(globals);
169
+ const post = await loadPost(client, postId);
170
+ const donation = donationDto(post.donation);
171
+ if (donation.tiers.length >= MAX_TIERS)
172
+ throw new Error(`Already ${MAX_TIERS} tiers`);
173
+ donation.tiers.push({ id: undefined, title: opts.title, amount: tierAmount(opts.amount, donation.minAmount), perks: opts.perks || '', limit: opts.limit ? num(opts.limit, '--limit', 1) : null });
174
+ const updated = await put(client, postId, { donation });
175
+ render(updated.donation, () => {
176
+ success(`Added tier "${opts.title}"${donation.enabled ? '' : color.dim(' — donations are off; `fund set --donations on`')}`);
177
+ printFund(updated);
178
+ });
179
+ });
180
+ tier.command('set <postId> <n>')
181
+ .option('--title <text>', 'tier name')
182
+ .option('--amount <n>', 'price')
183
+ .option('--perks <text>', 'perks')
184
+ .option('--limit <n>', 'slots; 0 = unlimited')
185
+ .action(async (postId, n, opts) => {
186
+ const client = backreelClient(globals);
187
+ const post = await loadPost(client, postId);
188
+ const donation = donationDto(post.donation);
189
+ const t = donation.tiers[itemIndex(n, donation.tiers.length, 'Tier')];
190
+ if (opts.title !== undefined)
191
+ t.title = opts.title;
192
+ if (opts.amount !== undefined)
193
+ t.amount = tierAmount(opts.amount, donation.minAmount);
194
+ if (opts.perks !== undefined)
195
+ t.perks = opts.perks;
196
+ if (opts.limit !== undefined)
197
+ t.limit = Number(opts.limit) > 0 ? num(opts.limit, '--limit', 1) : null;
198
+ const updated = await put(client, postId, { donation });
199
+ render(updated.donation, () => {
200
+ success(`Updated tier ${n}`);
201
+ printFund(updated);
202
+ });
203
+ });
204
+ tier.command('rm <postId> <n...>')
205
+ .option('-y, --yes', 'skip confirmation')
206
+ .action(async (postId, ns, opts) => {
207
+ const client = backreelClient(globals);
208
+ const post = await loadPost(client, postId);
209
+ const donation = donationDto(post.donation);
210
+ const drop = new Set(ns.map((n) => itemIndex(n, donation.tiers.length, 'Tier')));
211
+ const names = [...drop].map((i) => donation.tiers[i].title).join(', ');
212
+ if (!(await confirm(`Remove tier(s) ${names}? Past donations keep their snapshot.`, opts.yes)))
213
+ return info('Aborted');
214
+ donation.tiers = donation.tiers.filter((_, i) => !drop.has(i));
215
+ const updated = await put(client, postId, { donation });
216
+ render(updated.donation, () => {
217
+ success(`Removed ${drop.size} tier(s)`);
218
+ printFund(updated);
219
+ });
220
+ });
221
+ // ── offline supporters ─────────────────────────────────────────────────────
222
+ const sup = fund.command('supporter').aliases(['supporters']).description('Supporters who paid outside Backreel (crowdfunding posts)');
223
+ sup.command('list <postId>', { isDefault: true }).action(async (postId) => {
224
+ const client = backreelClient(globals);
225
+ const post = await loadPost(client, postId);
226
+ requireCampaign(post);
227
+ render(post.campaign?.offlineSupporters || [], () => printSupporters(post));
228
+ });
229
+ sup.command('add <postId>')
230
+ .requiredOption('--name <name>', 'display name')
231
+ .option('--amount <n>', 'amount (shown next to the name)', '0')
232
+ .option('--avatar <assetId>', 'square image asset (see `backreel image --slot photo`)')
233
+ .action(async (postId, opts) => {
234
+ const client = backreelClient(globals);
235
+ const post = await loadPost(client, postId);
236
+ requireCampaign(post);
237
+ const campaign = campaignDto(post.campaign);
238
+ campaign.offlineSupporters.push({ name: opts.name, amount: num(opts.amount, '--amount'), sort: campaign.offlineSupporters.length, avatar: opts.avatar ?? null, avatarUrl: null });
239
+ const updated = await put(client, postId, { campaign });
240
+ render(updated.campaign, () => {
241
+ success(`Added supporter ${opts.name}`);
242
+ info(color.dim('The supporter count rises; offline money only counts once you raise --offline-raised'));
243
+ printSupporters(updated);
244
+ });
245
+ });
246
+ sup.command('rm <postId> <n...>')
247
+ .option('-y, --yes', 'skip confirmation')
248
+ .action(async (postId, ns, opts) => {
249
+ const client = backreelClient(globals);
250
+ const post = await loadPost(client, postId);
251
+ requireCampaign(post);
252
+ const campaign = campaignDto(post.campaign);
253
+ const drop = new Set(ns.map((n) => itemIndex(n, campaign.offlineSupporters.length, 'Supporter')));
254
+ const names = [...drop].map((i) => campaign.offlineSupporters[i].name).join(', ');
255
+ if (!(await confirm(`Remove ${names}?`, opts.yes)))
256
+ return info('Aborted');
257
+ campaign.offlineSupporters = campaign.offlineSupporters.filter((_, i) => !drop.has(i));
258
+ const updated = await put(client, postId, { campaign });
259
+ render(updated.campaign, () => {
260
+ success(`Removed ${drop.size} supporter(s)`);
261
+ printSupporters(updated);
262
+ });
263
+ });
264
+ // ── donation records ───────────────────────────────────────────────────────
265
+ fund.command('donations <postId>')
266
+ .description('Online donations, balance and payouts (anonymous donors stay hidden)')
267
+ .option('--page <n>', 'page number', '1')
268
+ .option('--limit <n>', 'page size (≤200)', '50')
269
+ .action(async (postId, opts) => {
270
+ const client = backreelClient(globals);
271
+ const r = await client.get(`/backreel/donation/post/${postId}/manage`, { page: Number(opts.page), limit: Number(opts.limit) });
272
+ render(r, () => {
273
+ const s = r.summary || {};
274
+ const cur = (s.currency || '').toUpperCase();
275
+ detail([
276
+ ['raised', `${s.raised} ${cur}`],
277
+ ['paid out', `${s.paidOut} ${cur}`],
278
+ ['balance', `${s.balance} ${cur}`],
279
+ ['supporters', r.stats?.supporterCount ?? 0],
280
+ ['donations', r.stats?.donationCount ?? 0],
281
+ ]);
282
+ process.stdout.write('\n');
283
+ table(r.data || [], [
284
+ { header: 'when', value: (d) => relativeTime(d.paidAt || d.createdAt) },
285
+ { header: 'donor', value: (d) => (d.isAnonymous ? color.dim('anonymous') : truncate(d.donor?.name, 20)) },
286
+ { header: 'amount', value: (d) => `${d.amount} ${(d.currency || '').toUpperCase()}` },
287
+ { header: 'tier', value: (d) => truncate(d.tier?.title, 16) },
288
+ { header: 'status', value: (d) => d.status + (d.refundedAmount ? ` (-${d.refundedAmount})` : '') + (d.disputed ? color.red(' disputed') : '') },
289
+ { header: 'message', value: (d) => truncate(d.message, 30) },
290
+ ]);
291
+ const p = r.pagination;
292
+ if (p)
293
+ info(color.dim(`${p.total} donations · page ${p.page}${p.hasNext ? ` · more: --page ${p.page + 1}` : ''}`));
294
+ if ((r.payouts || []).length)
295
+ info(color.dim(`${r.payouts.length} payout(s) — payouts and refunds are handled by Backreel staff`));
296
+ });
297
+ });
298
+ return fund;
299
+ }
@@ -1,12 +1,17 @@
1
1
  import { Command } from 'commander';
2
- import { WjiClient } from '../core/client.js';
2
+ import { POSTS, backreelClient, idOf, oneOf } from '../core/backreel.js';
3
+ import { IMAGE_SLOTS, checkImage } from '../core/image.js';
4
+ import { episodeCommand } from './backreel-episode.js';
5
+ import { enquiryCommand } from './backreel-enquiry.js';
6
+ import { fundCommand } from './backreel-fund.js';
3
7
  import { confirm, readJson, readValue } from '../core/prompt.js';
4
8
  import { color, detail, info, render, relativeTime, success, table, truncate } from '../core/output.js';
5
9
  import { NPC_JOB_TYPES, npcLabel, parseJob, parsePositions, resolveNpc, today, updateJobs } from '../core/npc.js';
6
- const POSTS = '/backreel/post';
7
10
  const KINDS = ['showcase', 'crowdfunding'];
8
11
  const PROJECT_STATUSES = ['planning', 'pre_production', 'in_production', 'post_production', 'completed'];
9
12
  const CREDIT_KINDS = ['creative', 'crew', 'cast'];
13
+ /** The web's LookBook slot limit (compose/store.ts MEDIA_SLOT_LIMIT); the API itself has no cap. */
14
+ const MEDIA_MAX = 9;
10
15
  /** Backend LOGLINE_MAX (src/backreel/post/descriptionHtml.ts). */
11
16
  const LOGLINE_MAX = 200;
12
17
  /** BackreelVerificationKind — what `creators --role` filters on. */
@@ -24,7 +29,7 @@ export function backreelCommand(globals) {
24
29
  .option('--page <n>', 'page number', '1')
25
30
  .option('--limit <n>', 'page size', '20')
26
31
  .action(async (opts) => {
27
- const client = new WjiClient(globals());
32
+ const client = backreelClient(globals);
28
33
  const result = await client.get(`${POSTS}/${opts.all ? 'search' : 'search/my'}`, {
29
34
  keyword: opts.keyword,
30
35
  page: Number(opts.page),
@@ -48,12 +53,12 @@ export function backreelCommand(globals) {
48
53
  br.command('get <postId>')
49
54
  .description('Show a post and its credits')
50
55
  .action(async (id) => {
51
- const client = new WjiClient(globals());
56
+ const client = backreelClient(globals);
52
57
  const post = await client.getFresh(`${POSTS}/list/${id}`);
53
58
  render(post, () => printPost(post));
54
59
  });
55
60
  postOptions(br.command('create <title>').description('Create a post (always a draft until `backreel publish`)')).action(async (title, opts) => {
56
- const client = new WjiClient(globals());
61
+ const client = backreelClient(globals);
57
62
  // The create DTO validates kind and description although the entity has defaults.
58
63
  const body = { kind: 'showcase', description: '', ...(await postBody(opts)), title };
59
64
  const created = await client.post(`${POSTS}/create`, body);
@@ -62,7 +67,7 @@ export function backreelCommand(globals) {
62
67
  postOptions(br.command('set <postId>')
63
68
  .description('Update a post; only the fields you pass change')
64
69
  .option('--title <text>', 'title')).action(async (id, opts) => {
65
- const client = new WjiClient(globals());
70
+ const client = backreelClient(globals);
66
71
  const body = await postBody(opts);
67
72
  if (opts.title)
68
73
  body.title = opts.title;
@@ -75,7 +80,7 @@ export function backreelCommand(globals) {
75
80
  br.command(`${action} <postId>`)
76
81
  .description(action === 'publish' ? 'Publish a post' : 'Take a post offline')
77
82
  .action(async (id) => {
78
- const client = new WjiClient(globals());
83
+ const client = backreelClient(globals);
79
84
  const post = await client.post(`${POSTS}/list/${id}/${action}`);
80
85
  render(post, (p) => success(`"${p.title}" is now ${p.publishStatus}`));
81
86
  });
@@ -84,7 +89,7 @@ export function backreelCommand(globals) {
84
89
  .description('Delete a post')
85
90
  .option('-y, --yes', 'skip confirmation')
86
91
  .action(async (id, opts) => {
87
- const client = new WjiClient(globals());
92
+ const client = backreelClient(globals);
88
93
  const post = await client.getFresh(`${POSTS}/list/${id}`);
89
94
  if (!(await confirm(`Delete post "${post.title}" (${id})?`, opts.yes)))
90
95
  return info('Aborted');
@@ -100,7 +105,7 @@ export function backreelCommand(globals) {
100
105
  .option('--page <n>', 'page number', '1')
101
106
  .option('--limit <n>', 'page size (max 48)', '20')
102
107
  .action(async (opts) => {
103
- const client = new WjiClient(globals());
108
+ const client = backreelClient(globals);
104
109
  if (opts.role)
105
110
  oneOf(opts.role, VERIFICATION_KINDS, '--role');
106
111
  const result = await client.get('/backreel/creator/list', {
@@ -135,13 +140,122 @@ export function backreelCommand(globals) {
135
140
  }
136
141
  });
137
142
  });
143
+ br.command('image <file...>')
144
+ .description('Check image size / ratio for a slot, upload it, and optionally put it on a post')
145
+ .requiredOption('--slot <slot>', `${Object.keys(IMAGE_SLOTS).join(' | ')} (see \`backreel image-specs\`)`)
146
+ .option('--post <postId>', 'cover / portrait: set it on the post; media: append to the LookBook')
147
+ .option('--check', 'only check, do not upload')
148
+ .action(async (files, opts) => {
149
+ const slot = oneOf(opts.slot, Object.keys(IMAGE_SLOTS), '--slot');
150
+ if (files.length > 1 && slot !== 'media')
151
+ throw new Error(`--slot ${slot} takes one image`);
152
+ const checks = await Promise.all(files.map((f) => checkImage(f, slot)));
153
+ for (const c of checks) {
154
+ const dims = c.dims ? `${c.dims.width}×${c.dims.height} ${c.dims.format}` : '?';
155
+ info(`${c.file}: ${dims}, ${(c.bytes / 1048576).toFixed(1)} MB → ${slot} (${c.spec.label}, ideal ${c.spec.ideal})`);
156
+ for (const e of c.errors)
157
+ info(color.red(` ✗ ${e}`));
158
+ for (const w of c.warnings)
159
+ info(color.yellow(` ! ${w}`));
160
+ if (!c.errors.length && !c.warnings.length)
161
+ info(color.green(' ✓ fits'));
162
+ }
163
+ if (checks.some((c) => c.errors.length))
164
+ throw new Error('Fix the errors above first');
165
+ if (opts.check)
166
+ return render(checks, () => { });
167
+ const client = backreelClient(globals);
168
+ const ids = [];
169
+ for (const c of checks) {
170
+ const asset = await client.upload('/assets/v2/upload', c.file, {
171
+ width: c.dims ? String(c.dims.width) : undefined,
172
+ height: c.dims ? String(c.dims.height) : undefined,
173
+ });
174
+ ids.push(asset.id);
175
+ success(`${c.file} → ${asset.id}`);
176
+ }
177
+ if (!opts.post)
178
+ return render({ assets: ids }, () => { });
179
+ const field = { cover: 'cover', portrait: 'coverPortrait', media: 'assets' }[slot];
180
+ if (!field)
181
+ throw new Error('--post works with cover, portrait and media; use the photo id with `credit add --photo` or `fund supporter add --avatar`');
182
+ let value = ids[0];
183
+ if (field === 'assets') {
184
+ const post = await client.getFresh(`${POSTS}/list/${opts.post}`);
185
+ value = [...(post.assets || []).map(idOf), ...ids];
186
+ if (value.length > MEDIA_MAX)
187
+ throw new Error(`The LookBook would have ${value.length} items; the limit is ${MEDIA_MAX}`);
188
+ }
189
+ const updated = await client.put(`${POSTS}/list/${opts.post}`, { [field]: value });
190
+ render(updated, (p) => success(`Set ${field} on "${p.title}"${field === 'assets' ? ` (${value.length} items)` : ''}`));
191
+ });
192
+ br.command('image-specs')
193
+ .description('Recommended image sizes for every Backreel slot')
194
+ .action(() => {
195
+ render(IMAGE_SLOTS, () => {
196
+ table(Object.entries(IMAGE_SLOTS).map(([slot, s]) => ({ slot, ...s })), [
197
+ { header: 'slot', value: (r) => r.slot },
198
+ { header: 'ratio', value: (r) => r.label },
199
+ { header: 'ideal', value: (r) => r.ideal },
200
+ { header: 'min', value: (r) => r.min.join('×') },
201
+ { header: 'note', value: (r) => r.note },
202
+ ]);
203
+ info(color.dim('JPEG / PNG / WebP, ≤25 MB. Long edge over 3840 px is downsized, files over 10 MB are re-encoded as JPEG.'));
204
+ });
205
+ });
206
+ br.command('check <postId>')
207
+ .description('Publish readiness and health: required fields, images, episodes, campaign')
208
+ .action(async (id) => {
209
+ const client = backreelClient(globals);
210
+ const p = await client.getFresh(`${POSTS}/list/${id}`);
211
+ const blockers = [];
212
+ const advice = [];
213
+ const hasText = !!(p.descriptionText || '').trim() || /<img\b/i.test(p.description || '');
214
+ const episodes = p.episodes || [];
215
+ if (!(p.title || '').trim())
216
+ blockers.push('title is empty');
217
+ if (!hasText)
218
+ blockers.push('description is empty (needs text or an image) — `backreel set -d @synopsis.md`');
219
+ if (!p.cover && !(p.assets || []).length && !episodes.length)
220
+ blockers.push('needs a cover, LookBook media or an episode');
221
+ if (p.kind === 'crowdfunding' && !(p.campaign?.goalAmount >= 1))
222
+ blockers.push('crowdfunding goal is 0 — `backreel fund set --goal`');
223
+ if (!p.cover)
224
+ advice.push('no 16:9 cover — cards and shares fall back to a blank frame');
225
+ if (!p.coverPortrait)
226
+ advice.push('no 9:16 portrait cover — mobile crops the 16:9 cover');
227
+ if (!(p.logline || '').trim())
228
+ advice.push('no logline — the page falls back to the first sentence of the description');
229
+ if (!(p.credits || []).length)
230
+ advice.push('no credits');
231
+ const pending = episodes.filter((e) => e.vodVid && e.vodStatus !== 'ready');
232
+ if (pending.length)
233
+ advice.push(`${pending.length} episode(s) not ready on VOD (${pending.map((e) => `#${(e.order ?? 0) + 1} ${e.vodStatus || '?'}`).join(', ')}) — \`backreel episode status\``);
234
+ if (p.trailerDurationSec && p.trailerDurationSec > 31)
235
+ advice.push(`trailer is ${p.trailerDurationSec}s; the limit is 30s`);
236
+ if (p.donation?.enabled && p.kind === 'crowdfunding' && p.campaign?.deadline && new Date(p.campaign.deadline) < new Date()) {
237
+ advice.push('the campaign deadline has passed — donations are closed');
238
+ }
239
+ render({ id: p.id, publishStatus: p.publishStatus, blockers, advice }, () => {
240
+ info(`"${p.title}" — ${p.kind}, ${p.publishStatus}`);
241
+ for (const b of blockers)
242
+ info(color.red(` ✗ ${b}`));
243
+ for (const a of advice)
244
+ info(color.yellow(` ! ${a}`));
245
+ if (!blockers.length)
246
+ info(color.green(p.publishStatus === 'published' ? ' ✓ published' : ' ✓ ready to publish — `wji backreel publish`'));
247
+ });
248
+ });
249
+ br.addCommand(episodeCommand(globals));
250
+ br.addCommand(enquiryCommand(globals));
251
+ br.addCommand(fundCommand(globals));
138
252
  // ── credits ────────────────────────────────────────────────────────────
139
253
  const credit = br.command('credit').aliases(['credits']).description('Cast and crew of a post (max 30)');
140
254
  credit
141
255
  .command('list <postId>', { isDefault: true })
142
256
  .description('Numbered credits')
143
257
  .action(async (id) => {
144
- const client = new WjiClient(globals());
258
+ const client = backreelClient(globals);
145
259
  const post = await client.getFresh(`${POSTS}/list/${id}`);
146
260
  render(post.credits || [], (credits) => printCredits(credits));
147
261
  });
@@ -154,6 +268,7 @@ export function backreelCommand(globals) {
154
268
  .option('--npc <npc>', 'link an NPC City NPC (id, @handle or name)')
155
269
  .option('--user <userId>', 'link a WJI user (their name and avatar win)')
156
270
  .option('--link <url>', 'profile link')
271
+ .option('--photo <assetId>', 'square photo asset (see `backreel image --slot photo`)')
157
272
  .option('--job <type>', `also add a work-history job to the NPC: ${NPC_JOB_TYPES.join(' | ')}`)
158
273
  .action(async (id, opts) => {
159
274
  if (!CREDIT_KINDS.includes(opts.kind))
@@ -163,7 +278,7 @@ export function backreelCommand(globals) {
163
278
  if (opts.job && !NPC_JOB_TYPES.includes(opts.job)) {
164
279
  throw new Error(`--job must be one of ${NPC_JOB_TYPES.join(', ')}`);
165
280
  }
166
- const client = new WjiClient(globals());
281
+ const client = backreelClient(globals);
167
282
  const npc = opts.npc ? await resolveNpc(client, opts.npc) : undefined;
168
283
  // Any public NPC can be credited, but only your own gets a job. The soul
169
284
  // route is owner-only, so it checks that before anything is written.
@@ -180,6 +295,8 @@ export function backreelCommand(globals) {
180
295
  entry.user = opts.user;
181
296
  if (opts.link)
182
297
  entry.link = opts.link;
298
+ if (opts.photo)
299
+ entry.photo = opts.photo;
183
300
  if (npc) {
184
301
  entry.npcId = npc.id;
185
302
  if (npc.image)
@@ -203,7 +320,7 @@ export function backreelCommand(globals) {
203
320
  .description('Remove credits by number')
204
321
  .option('-y, --yes', 'skip confirmation')
205
322
  .action(async (id, ns, opts) => {
206
- const client = new WjiClient(globals());
323
+ const client = backreelClient(globals);
207
324
  const post = await client.getFresh(`${POSTS}/list/${id}`);
208
325
  const credits = post.credits || [];
209
326
  const positions = parsePositions(ns, credits.length);
@@ -231,7 +348,8 @@ function postOptions(cmd) {
231
348
  .option('-d, --description <text>', 'description, plain text or HTML (@file / - supported)')
232
349
  .option('--cover <assetId>', 'landscape cover asset')
233
350
  .option('--cover-portrait <assetId>', 'portrait cover asset')
234
- .option('--trailer <assetId>', 'trailer video asset')
351
+ .option('--trailer <assetId>', 'trailer video asset (≤30 s)')
352
+ .option('--media <assetId...>', `LookBook gallery, images or videos in order (replaces it; ≤${MEDIA_MAX}; "none" clears)`)
235
353
  .option('--body <json>', 'any other post fields as JSON (@file / - supported); flags win');
236
354
  }
237
355
  async function postBody(opts) {
@@ -259,6 +377,12 @@ async function postBody(opts) {
259
377
  body.coverPortrait = opts.coverPortrait;
260
378
  if (opts.trailer)
261
379
  body.trailer = opts.trailer;
380
+ if (opts.media) {
381
+ const media = opts.media.filter((m) => m !== 'none');
382
+ if (media.length > MEDIA_MAX)
383
+ throw new Error(`--media takes at most ${MEDIA_MAX} items (the web's LookBook limit)`);
384
+ body.assets = media;
385
+ }
262
386
  return body;
263
387
  }
264
388
  /** Descriptions are stored as sanitized HTML; plain text becomes paragraphs. */
@@ -282,11 +406,6 @@ async function updateCredits(client, id, fn) {
282
406
  return client.put(`${POSTS}/list/${id}`, { credits: next });
283
407
  }
284
408
  const assetLabel = (a) => (a ? a.url || a.id || a : undefined);
285
- function oneOf(value, allowed, flag) {
286
- if (!allowed.includes(value))
287
- throw new Error(`${flag} must be one of ${allowed.join(', ')}`);
288
- return value;
289
- }
290
409
  function printPost(p) {
291
410
  detail([
292
411
  ['id', p.id],
@@ -300,6 +419,10 @@ function printPost(p) {
300
419
  ['description', truncate(p.descriptionText || p.description, 120)],
301
420
  ['cover', assetLabel(p.cover)],
302
421
  ['coverPortrait', assetLabel(p.coverPortrait)],
422
+ ['media', (p.assets || []).length ? `${p.assets.length} item(s)` : undefined],
423
+ ['episodes', (p.episodes || []).length ? `${p.episodes.length} (\`backreel episode list\`)` : undefined],
424
+ ['campaign', p.campaign ? `${p.campaign.raisedAmount}/${p.campaign.goalAmount} ${(p.campaign.currency || '').toUpperCase()}` : undefined],
425
+ ['donations', p.donation?.enabled ? 'on' : undefined],
303
426
  ['trailer', p.trailer ? `${assetLabel(p.trailer)}${p.trailerDurationSec ? ` (${p.trailerDurationSec}s)` : ''}` : undefined],
304
427
  ['creator', p.creator?.name],
305
428
  ['likes', p.likeCount],
@@ -182,7 +182,10 @@ export function genCommand(globals) {
182
182
  .command('audio')
183
183
  .description('Generate audio')
184
184
  .option('-d, --duration <seconds>', 'duration')
185
- .option('--ref-audio <ref...>', 'audio references as assetId[:label[:weight]]')).action(async (opts) => {
185
+ .option('--ref-audio <ref...>', 'audio references as assetId[:label[:weight]]')
186
+ .option('--ref <ref...>', 'reference image, e.g. a headshot for a Seed Audio voice sample (not with --ref-audio)')).action(async (opts) => {
187
+ if (opts.ref?.length && opts.refAudio?.length)
188
+ throw new Error('Use --ref or --ref-audio, not both');
186
189
  const client = new WjiClient(globals());
187
190
  await submit(client, 'audio', {
188
191
  model: opts.model,
@@ -194,6 +197,7 @@ export function genCommand(globals) {
194
197
  scopeId: opts.scopeId,
195
198
  tags: opts.tags,
196
199
  referenceAudios: refs(opts.refAudio),
200
+ referenceImages: refs(opts.ref),
197
201
  values: {
198
202
  ...((await readJson(opts.values)) || {}),
199
203
  ...parseKeyValues(opts.set),