@wji.ai/cli 0.6.0 → 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 (38) hide show
  1. package/README.md +19 -2
  2. package/dist/commands/agent.js +28 -8
  3. package/dist/commands/backreel-enquiry.js +171 -0
  4. package/dist/commands/backreel-episode.js +243 -0
  5. package/dist/commands/backreel-fund.js +299 -0
  6. package/dist/commands/backreel.js +201 -18
  7. package/dist/commands/gen.js +5 -1
  8. package/dist/commands/npc-album.js +201 -0
  9. package/dist/commands/npc-live.js +316 -0
  10. package/dist/commands/npc.js +26 -2
  11. package/dist/commands/workspace.js +74 -1
  12. package/dist/core/backreel.js +23 -0
  13. package/dist/core/client.js +18 -1
  14. package/dist/core/image.js +106 -0
  15. package/dist/core/npc.js +1 -1
  16. package/dist/core/vod.js +149 -0
  17. package/dist/index.js +6 -0
  18. package/package.json +1 -1
  19. package/skill/backreel/SKILL.md +171 -0
  20. package/skill/backreel/reference/crowdfunding.md +82 -0
  21. package/skill/backreel/reference/enquiries.md +80 -0
  22. package/skill/backreel/reference/episodes.md +104 -0
  23. package/skill/backreel/reference/images.md +76 -0
  24. package/skill/npc-character/SKILL.md +111 -0
  25. package/skill/npc-character/examples/nagi.character.md +117 -0
  26. package/skill/npc-character/examples/nagi.stage.md +34 -0
  27. package/skill/npc-character/examples/yuzu.character.md +110 -0
  28. package/skill/npc-character/examples/yuzu.stage.md +30 -0
  29. package/skill/npc-character/reference/character-craft.md +153 -0
  30. package/skill/npc-character/reference/document-format.md +151 -0
  31. package/skill/npc-character/reference/interview.md +44 -0
  32. package/skill/npc-character/reference/life.md +92 -0
  33. package/skill/npc-character/reference/runtime.md +95 -0
  34. package/skill/npc-character/reference/stage-craft.md +109 -0
  35. package/skill/npc-character/reference/visual-kit.md +81 -0
  36. package/skill/npc-character/reference/voice.md +101 -0
  37. package/skill/wji/SKILL.md +10 -1
  38. package/skill/wji/reference/commands.md +43 -6
@@ -1,9 +1,10 @@
1
1
  import { Command } from 'commander';
2
2
  import { WjiClient } from '../core/client.js';
3
- import { updateProfile } from '../core/config.js';
3
+ import { getProfile, updateProfile } from '../core/config.js';
4
4
  import { confirm, parseKeyValues } from '../core/prompt.js';
5
5
  import { color, detail, detailLevel, info, relativeTime, render, success, table, truncate } from '../core/output.js';
6
6
  import { loadMembers, memberLabel, resolveMember } from '../core/members.js';
7
+ import { fetchModels } from './model.js';
7
8
  /** App tokens accepted by `--app`; mirrors WorkspaceApplication on the backend. */
8
9
  const APPS = {
9
10
  dataset: 'Dataset',
@@ -342,6 +343,56 @@ export function workspaceCommand(globals) {
342
343
  await client.delete(`/workspace/list/${wsId}/member/${m.memberId}`);
343
344
  render({ removed: targets.map((m) => m.userId) }, () => success(`Removed ${names}`));
344
345
  });
346
+ ws.command('leave <id>')
347
+ .description('Leave a workspace you were invited to (owners cannot leave)')
348
+ .option('-y, --yes', 'skip confirmation')
349
+ .action(async (id, opts) => {
350
+ const g = globals();
351
+ const client = new WjiClient(g);
352
+ const w = await client.get(`/workspace/list/${id}/basic`).catch(() => ({ name: id }));
353
+ if (!(await confirm(`Leave workspace "${w.name}" (${id})? You need a new invite to come back.`, opts.yes))) {
354
+ return info('Aborted');
355
+ }
356
+ const result = await client.post(`/workspace/list/${id}/leave`);
357
+ // A left workspace 403s from now on, so stop pointing the profile at it.
358
+ if (getProfile(g.profile).profile.workspace === id)
359
+ updateProfile(g.profile, { workspace: undefined });
360
+ render(result, () => success(`Left ${w.name} (${id})`));
361
+ });
362
+ // ── default models ─────────────────────────────────────────────────────────
363
+ ws.command('defaults [id]')
364
+ .description('Show or set the workspace default models (pass "none" to clear one)')
365
+ .option('--chat <model>', 'default chat model for new conversations')
366
+ .option('--image <model>', 'default image model')
367
+ .option('--video <model>', 'default video model')
368
+ .option('--storyboard <model>', 'default storyboard model')
369
+ .option('--audio <model>', 'default audio model')
370
+ .option('--image-resolution <value>', 'default image resolution, e.g. 2K')
371
+ .option('--video-resolution <value>', 'default video resolution, e.g. 720p')
372
+ .action(async (id, opts) => {
373
+ const client = new WjiClient(globals());
374
+ const wsId = client.requireWorkspace(id);
375
+ // settings only comes back at full detail, and PUT replaces settings whole —
376
+ // read it fresh so prompts / defaultApp / improvePromptDefault survive.
377
+ const w = await client.getFresh(`/workspace/list/${wsId}`, { detail: 'full' });
378
+ const current = { ...(w.settings?.defaultModels || {}) };
379
+ const changes = DEFAULT_MODEL_KEYS.filter((key) => opts[key] !== undefined);
380
+ if (!changes.length) {
381
+ return render(current, () => detail(DEFAULT_MODEL_KEYS.map((key) => [key, current[key] ?? color.dim('—')])));
382
+ }
383
+ const next = { ...current };
384
+ const needsModels = changes.some((key) => !key.endsWith('Resolution') && !isNone(opts[key]));
385
+ const models = needsModels ? await fetchModels(client, { workspace: wsId }) : [];
386
+ for (const key of changes) {
387
+ const raw = String(opts[key]).trim();
388
+ next[key] = isNone(raw) ? null : key.endsWith('Resolution') ? raw : resolveDefaultModel(models, key, raw);
389
+ }
390
+ await client.put(`/workspace/list/${wsId}`, { settings: { ...(w.settings || {}), defaultModels: next } });
391
+ render(next, () => {
392
+ success('Default models updated');
393
+ detail(DEFAULT_MODEL_KEYS.map((key) => [key, next[key] ?? color.dim('—')]));
394
+ });
395
+ });
345
396
  // ── quota / stats ──────────────────────────────────────────────────────────
346
397
  ws.command('quota [id]')
347
398
  .description('Show credit quota usage')
@@ -384,6 +435,28 @@ export function workspaceCommand(globals) {
384
435
  });
385
436
  return ws;
386
437
  }
438
+ const DEFAULT_MODEL_KEYS = [
439
+ 'chat',
440
+ 'image',
441
+ 'video',
442
+ 'storyboard',
443
+ 'audio',
444
+ 'imageResolution',
445
+ 'videoResolution',
446
+ ];
447
+ const isNone = (value) => ['none', 'null', ''].includes(String(value ?? '').trim().toLowerCase());
448
+ /**
449
+ * The web stores the chat default as the model `name` (what conversations carry)
450
+ * and the media defaults as the `nickname` its pickers emit — the value `-m` takes.
451
+ * Accept either spelling and store what the web would.
452
+ */
453
+ function resolveDefaultModel(models, key, value) {
454
+ const model = models.find((m) => m.nickname === value || m.name === value);
455
+ if (!model) {
456
+ throw new Error(`Unknown model "${value}" for --${key}. See \`wji models list\`.`);
457
+ }
458
+ return key === 'chat' ? model.name : model.nickname || model.name;
459
+ }
387
460
  export function printTree(node, prefix = '', connector = '') {
388
461
  if (!node)
389
462
  return;
@@ -0,0 +1,23 @@
1
+ import { WjiClient } from './client.js';
2
+ export const POSTS = '/backreel/post';
3
+ /** BackreelAccessTier, lowest to highest. `owner` is internal and never set. */
4
+ export const TIERS = ['public', 'member', 'supporter', 'partner'];
5
+ /** Every Backreel call says `X-App: backreel`, so notifications and mail link to Backreel, not WJI. */
6
+ export const backreelClient = (globals) => new WjiClient({ ...globals(), app: 'backreel' });
7
+ /** The owner's view of a post (episodes with vodVid / video ids, campaign internals, donation config). */
8
+ export const loadPost = (client, id) => client.getFresh(`${POSTS}/list/${id}`);
9
+ export function oneOf(value, allowed, flag) {
10
+ if (!allowed.includes(value))
11
+ throw new Error(`${flag} must be one of ${allowed.join(', ')}`);
12
+ return value;
13
+ }
14
+ /** Ref fields come back as an id or a populated object; the API takes the id. */
15
+ export const idOf = (ref) => (ref && typeof ref === 'object' ? ref.id : ref) || undefined;
16
+ /** `n` on the command line is 1-based; returns the 0-based index or throws. */
17
+ export function episodeIndex(n, count) {
18
+ const i = Number(n);
19
+ if (!Number.isInteger(i) || i < 1 || i > count) {
20
+ throw new Error(`Episode ${n} does not exist — this post has ${count} episode(s)`);
21
+ }
22
+ return i - 1;
23
+ }
@@ -3,6 +3,21 @@ import { basename } from 'node:path';
3
3
  import { DEFAULT_ENDPOINT, acquireLock, getProfile, refreshLockPath, releaseLock, updateProfile, } from './config.js';
4
4
  /** Server cap on `ids` for `/assets/check-status`, `/assets/summary/batch` and friends. */
5
5
  export const ASSET_BATCH_MAX_IDS = 100;
6
+ /**
7
+ * Content types for multipart uploads. Some endpoints (Backreel private files)
8
+ * accept a file by its declared type alone, and a bare Blob is sent as
9
+ * application/octet-stream — which they reject as an unsupported type.
10
+ */
11
+ const MIME_TYPES = {
12
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', avif: 'image/avif',
13
+ mp4: 'video/mp4', mov: 'video/quicktime', m4v: 'video/x-m4v', webm: 'video/webm',
14
+ mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4',
15
+ pdf: 'application/pdf', csv: 'text/csv', txt: 'text/plain', md: 'text/markdown', json: 'application/json', zip: 'application/zip',
16
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
17
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
18
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
19
+ };
20
+ export const mimeTypeOf = (filename) => MIME_TYPES[filename.split('.').pop()?.toLowerCase() || ''] || 'application/octet-stream';
6
21
  export const isObjectId = (value) => /^[a-f0-9]{24}$/i.test(value);
7
22
  export class ApiError extends Error {
8
23
  status;
@@ -92,6 +107,8 @@ export class WjiClient {
92
107
  const org = this.orgId;
93
108
  if (org)
94
109
  headers['X-Org-Id'] = org;
110
+ if (this.globals.app)
111
+ headers['X-App'] = this.globals.app;
95
112
  return headers;
96
113
  }
97
114
  async request(method, path, opts = {}) {
@@ -175,7 +192,7 @@ export class WjiClient {
175
192
  /** Multipart upload of an in-memory buffer (e.g. a zip built on the fly). */
176
193
  async uploadBuffer(path, buffer, filename, fields = {}, field = 'file') {
177
194
  const form = new FormData();
178
- form.append(field, new Blob([new Uint8Array(buffer)]), filename);
195
+ form.append(field, new Blob([new Uint8Array(buffer)], { type: mimeTypeOf(filename) }), filename);
179
196
  for (const [key, value] of Object.entries(fields)) {
180
197
  if (value !== undefined && value !== null && value !== '')
181
198
  form.append(key, value);
@@ -0,0 +1,106 @@
1
+ import { open, stat } from 'node:fs/promises';
2
+ /** Read width × height from a PNG, JPEG, WebP or GIF header — no image library. */
3
+ export async function imageSize(file) {
4
+ const fh = await open(file, 'r');
5
+ try {
6
+ const size = (await fh.stat()).size;
7
+ const head = Buffer.alloc(Math.min(size, 64 * 1024));
8
+ await fh.read(head, 0, head.length, 0);
9
+ return parseImageSize(head);
10
+ }
11
+ finally {
12
+ await fh.close();
13
+ }
14
+ }
15
+ export function parseImageSize(b) {
16
+ // PNG: signature + IHDR
17
+ if (b.length >= 24 && b.readUInt32BE(0) === 0x89504e47) {
18
+ return { width: b.readUInt32BE(16), height: b.readUInt32BE(20), format: 'png' };
19
+ }
20
+ // GIF
21
+ if (b.length >= 10 && b.toString('ascii', 0, 3) === 'GIF') {
22
+ return { width: b.readUInt16LE(6), height: b.readUInt16LE(8), format: 'gif' };
23
+ }
24
+ // WebP
25
+ if (b.length >= 30 && b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP') {
26
+ const chunk = b.toString('ascii', 12, 16);
27
+ if (chunk === 'VP8X')
28
+ return { width: 1 + b.readUIntLE(24, 3), height: 1 + b.readUIntLE(27, 3), format: 'webp' };
29
+ if (chunk === 'VP8 ')
30
+ return { width: b.readUInt16LE(26) & 0x3fff, height: b.readUInt16LE(28) & 0x3fff, format: 'webp' };
31
+ if (chunk === 'VP8L') {
32
+ const bits = b.readUInt32LE(21);
33
+ return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1, format: 'webp' };
34
+ }
35
+ }
36
+ // JPEG: walk segments to the first SOFn
37
+ if (b.length >= 4 && b[0] === 0xff && b[1] === 0xd8) {
38
+ let i = 2;
39
+ while (i + 9 < b.length) {
40
+ if (b[i] !== 0xff) {
41
+ i++;
42
+ continue;
43
+ }
44
+ const marker = b[i + 1];
45
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
46
+ i += 2;
47
+ continue;
48
+ }
49
+ const len = b.readUInt16BE(i + 2);
50
+ const isSof = marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker);
51
+ if (isSof) {
52
+ const height = b.readUInt16BE(i + 5);
53
+ const width = b.readUInt16BE(i + 7);
54
+ // EXIF orientation is not applied — the server does not rotate either.
55
+ return { width, height, format: 'jpeg' };
56
+ }
57
+ i += 2 + len;
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+ /**
63
+ * Backreel image slots. The backend enforces no ratio or resolution — every
64
+ * slot is shown with `object-cover`, so a wrong ratio is silently cropped.
65
+ * These are the sizes that fill each frame sharply; `min` is the floor below
66
+ * which the image looks soft on a desktop screen.
67
+ */
68
+ export const IMAGE_SLOTS = {
69
+ cover: { ratio: 16 / 9, label: '16:9', ideal: '1920×1080', min: [1280, 720], note: 'also cropped to 4:3 on cards and ~16:7 in reel strips — keep the subject centred' },
70
+ portrait: { ratio: 9 / 16, label: '9:16', ideal: '1080×1920', min: [720, 1280], note: 'mobile hero / poster; falls back to a crop of the cover when missing' },
71
+ media: { ratio: 3 / 4, label: '3:4', ideal: '1536×2048', min: [900, 1200], note: 'LookBook tiles are cropped to 3:4' },
72
+ photo: { ratio: 1, label: '1:1', ideal: '800×800', min: [400, 400], note: 'credit photo / supporter avatar, shown in a circle' },
73
+ };
74
+ /** Server limits from /assets/v2/upload and normalizeImageForStorage. */
75
+ export const IMAGE_MAX_BYTES = 25 * 1024 * 1024;
76
+ export const IMAGE_MAX_LONG_EDGE = 3840;
77
+ const RATIO_TOLERANCE = 0.03;
78
+ export async function checkImage(file, slot) {
79
+ const spec = IMAGE_SLOTS[slot];
80
+ const bytes = (await stat(file)).size;
81
+ const dims = await imageSize(file);
82
+ const errors = [];
83
+ const warnings = [];
84
+ if (bytes > IMAGE_MAX_BYTES)
85
+ errors.push(`file is ${(bytes / 1048576).toFixed(1)} MB; the upload limit is 25 MB`);
86
+ if (!dims) {
87
+ errors.push('not a PNG, JPEG, WebP or GIF image (or the header could not be read)');
88
+ return { file, slot, bytes, dims, errors, warnings, spec };
89
+ }
90
+ const ratio = dims.width / dims.height;
91
+ if (Math.abs(ratio - spec.ratio) / spec.ratio > RATIO_TOLERANCE) {
92
+ warnings.push(`ratio ${dims.width}:${dims.height} ≈ ${ratio.toFixed(2)} is not ${spec.label} — the edges will be cropped`);
93
+ }
94
+ const [minW, minH] = spec.min;
95
+ const shortSide = spec.ratio >= 1 ? dims.height : dims.width;
96
+ const minShort = spec.ratio >= 1 ? minH : minW;
97
+ if (shortSide < minShort) {
98
+ warnings.push(`${dims.width}×${dims.height} is below ${minW}×${minH}; it will look soft (ideal ${spec.ideal})`);
99
+ }
100
+ if (Math.max(dims.width, dims.height) > IMAGE_MAX_LONG_EDGE) {
101
+ warnings.push(`long edge over ${IMAGE_MAX_LONG_EDGE}px — the server downsizes it on upload`);
102
+ }
103
+ if (bytes > 10 * 1024 * 1024)
104
+ warnings.push('over 10 MB — the server re-encodes it as JPEG');
105
+ return { file, slot, bytes, dims, errors, warnings, spec };
106
+ }
package/dist/core/npc.js CHANGED
@@ -70,7 +70,7 @@ export function parseJob(opts, base) {
70
70
  const job = {
71
71
  type: opts.type ?? base?.type ?? '',
72
72
  title: opts.title ?? base?.title ?? '',
73
- detail: opts.detail ?? base?.detail ?? '',
73
+ detail: opts.about ?? opts.detail ?? base?.detail ?? '',
74
74
  date: opts.date ?? base?.date ?? '',
75
75
  };
76
76
  const location = opts.location ?? base?.location;
@@ -0,0 +1,149 @@
1
+ import { createHash, createHmac } from 'node:crypto';
2
+ import { readFile, stat } from 'node:fs/promises';
3
+ import { extname } from 'node:path';
4
+ const VERSION = '2023-01-01';
5
+ const SERVICE = 'vod';
6
+ const DEFAULT_HOST = 'vod.byteplusapi.com';
7
+ const DEFAULT_REGION = 'ap-southeast-1';
8
+ /** Same escaping as the volcengine signer (RFC 3986, `*` escaped too). */
9
+ function uriEscape(value) {
10
+ return encodeURIComponent(value)
11
+ .replace(/[^A-Za-z0-9_.~\-%]+/g, escape)
12
+ .replace(/[*]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`);
13
+ }
14
+ export function canonicalQuery(params) {
15
+ return Object.keys(params)
16
+ .filter((key) => params[key] !== undefined && params[key] !== null)
17
+ .sort()
18
+ .map((key) => `${uriEscape(key)}=${uriEscape(String(params[key]))}`)
19
+ .join('&');
20
+ }
21
+ const sha256Hex = (data) => createHash('sha256').update(data).digest('hex');
22
+ const hmac = (key, data) => createHmac('sha256', key).update(data).digest();
23
+ /**
24
+ * BytePlus / volcengine request signature ("HMAC-SHA256", scope
25
+ * `<date>/<region>/<service>/request`). Unsigned headers: authorization,
26
+ * content-type, content-length, user-agent — so only X-Date,
27
+ * X-Security-Token and X-Content-Sha256 are signed. Exported so the test can
28
+ * compare it with the official signer byte for byte.
29
+ */
30
+ export function signVodRequest(opts) {
31
+ const datetime = (opts.date ?? new Date()).toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[:-]/g, '');
32
+ const day = datetime.slice(0, 8);
33
+ const headers = { 'X-Date': datetime };
34
+ if (opts.credentials.sessionToken)
35
+ headers['X-Security-Token'] = opts.credentials.sessionToken;
36
+ if (opts.body !== undefined)
37
+ headers['X-Content-Sha256'] = sha256Hex(opts.body);
38
+ const names = Object.keys(headers).sort((a, b) => (a.toLowerCase() < b.toLowerCase() ? -1 : 1));
39
+ const canonicalHeaders = names.map((k) => `${k.toLowerCase()}:${headers[k].replace(/\s+/g, ' ').trim()}`).join('\n');
40
+ const signedHeaders = names.map((k) => k.toLowerCase()).sort().join(';');
41
+ const bodyHash = headers['X-Content-Sha256'] ?? sha256Hex('');
42
+ const canonical = [
43
+ opts.method.toUpperCase(),
44
+ '/',
45
+ canonicalQuery(opts.query),
46
+ `${canonicalHeaders}\n`,
47
+ signedHeaders,
48
+ bodyHash,
49
+ ].join('\n');
50
+ const scope = `${day}/${opts.region}/${SERVICE}/request`;
51
+ const stringToSign = ['HMAC-SHA256', datetime, scope, sha256Hex(canonical)].join('\n');
52
+ const key = hmac(hmac(hmac(hmac(opts.credentials.secretKey, day), opts.region), SERVICE), 'request');
53
+ const signature = createHmac('sha256', key).update(stringToSign).digest('hex');
54
+ headers.Authorization = `HMAC-SHA256 Credential=${opts.credentials.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
55
+ return headers;
56
+ }
57
+ /** CRC-32/IEEE, which the store host checks via `Content-CRC32`. */
58
+ const CRC_TABLE = (() => {
59
+ const t = new Uint32Array(256);
60
+ for (let i = 0; i < 256; i++) {
61
+ let c = i;
62
+ for (let k = 0; k < 8; k++)
63
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
64
+ t[i] = c >>> 0;
65
+ }
66
+ return t;
67
+ })();
68
+ export function crc32(buf) {
69
+ let crc = 0xffffffff;
70
+ for (let i = 0; i < buf.length; i++)
71
+ crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
72
+ return (crc ^ 0xffffffff) >>> 0;
73
+ }
74
+ async function callVod(token, action, opts) {
75
+ const host = process.env.WJI_VOD_HOST || DEFAULT_HOST;
76
+ const region = token.region || DEFAULT_REGION;
77
+ const query = { ...(opts.query || {}), Action: action, Version: VERSION };
78
+ const body = opts.json === undefined ? undefined : JSON.stringify(opts.json);
79
+ const headers = signVodRequest({
80
+ method: opts.method,
81
+ query,
82
+ body,
83
+ region,
84
+ credentials: {
85
+ accessKeyId: token.stsToken.AccessKeyId,
86
+ secretKey: token.stsToken.SecretAccessKey,
87
+ sessionToken: token.stsToken.SessionToken,
88
+ },
89
+ });
90
+ if (body !== undefined)
91
+ headers['Content-Type'] = 'application/json; charset=utf-8';
92
+ const res = await fetch(`https://${host}/?${canonicalQuery(query)}`, { method: opts.method, headers, body });
93
+ const text = await res.text();
94
+ let json;
95
+ try {
96
+ json = JSON.parse(text);
97
+ }
98
+ catch {
99
+ throw new Error(`VOD ${action}: HTTP ${res.status} ${text.slice(0, 200)}`);
100
+ }
101
+ const err = json?.ResponseMetadata?.Error;
102
+ if (err || !res.ok) {
103
+ throw new Error(`VOD ${action}: ${err?.Code || res.status} ${err?.Message || text.slice(0, 200)}`);
104
+ }
105
+ return json?.Result;
106
+ }
107
+ /**
108
+ * Upload one local video and return its Vid. One PUT of the whole file —
109
+ * fine for episode-sized files; the browser SDK's chunked mode is not
110
+ * reproduced, so very large masters (multi-GB) are better uploaded on the web.
111
+ */
112
+ export async function uploadToVod(token, file, onProgress) {
113
+ const size = (await stat(file)).size;
114
+ const ext = extname(file).toLowerCase() || '.mp4';
115
+ onProgress?.('requesting an upload slot');
116
+ const applied = await callVod(token, 'ApplyUploadInfo', {
117
+ method: 'GET',
118
+ // No FileName: BytePlus uses it as the storage key, so a repeat name fails with AlreadyExisted.
119
+ query: { SpaceName: token.spaceName, FileType: 'media', FileSize: size, FileExtension: ext },
120
+ });
121
+ const addr = applied?.Data?.UploadAddress ?? applied?.UploadAddress;
122
+ const host = addr?.UploadHosts?.[0];
123
+ const store = addr?.StoreInfos?.[0];
124
+ if (!host || !store?.StoreUri || !addr?.SessionKey) {
125
+ throw new Error(`VOD ApplyUploadInfo returned no upload address: ${JSON.stringify(applied).slice(0, 300)}`);
126
+ }
127
+ const buffer = await readFile(file);
128
+ onProgress?.(`uploading ${(size / 1e6).toFixed(1)} MB`);
129
+ const put = await fetch(`https://${host}/${String(store.StoreUri).split('/').map(encodeURIComponent).join('/')}`, {
130
+ method: 'PUT',
131
+ headers: {
132
+ Authorization: store.Auth,
133
+ 'Content-CRC32': crc32(buffer).toString(16).padStart(8, '0'),
134
+ },
135
+ body: new Uint8Array(buffer),
136
+ });
137
+ if (put.status !== 200) {
138
+ throw new Error(`VOD upload failed: HTTP ${put.status} ${(await put.text()).slice(0, 200)}`);
139
+ }
140
+ onProgress?.('committing');
141
+ const committed = await callVod(token, 'CommitUploadInfo', {
142
+ method: 'POST',
143
+ json: { SpaceName: token.spaceName, SessionKey: addr.SessionKey },
144
+ });
145
+ const vid = String(committed?.Data?.Vid || committed?.Vid || '').trim();
146
+ if (!vid)
147
+ throw new Error(`VOD CommitUploadInfo returned no Vid: ${JSON.stringify(committed).slice(0, 300)}`);
148
+ return vid;
149
+ }
package/dist/index.js CHANGED
@@ -23,6 +23,12 @@ import { backreelCommand } from './commands/backreel.js';
23
23
  // Read from package.json so `wji --version` cannot drift from the published
24
24
  // version — the skill compares the two to detect a stale install.
25
25
  const VERSION = createRequire(import.meta.url)('../package.json').version;
26
+ // `wji … | head` closes the pipe early; that is the reader being done, not a crash.
27
+ process.stdout.on('error', (err) => {
28
+ if (err.code === 'EPIPE')
29
+ process.exit(0);
30
+ throw err;
31
+ });
26
32
  const program = new Command();
27
33
  program
28
34
  .name('wji')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wji.ai/cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Command line interface for the WJI AI platform",
5
5
  "license": "UNLICENSED",
6
6
  "author": "WJI AI",
@@ -0,0 +1,171 @@
1
+ ---
2
+ name: backreel
3
+ description: Run a creator's works on Backreel (the film / short-drama showcase and crowdfunding site that shares accounts with WJI) with the `wji` CLI — create and publish a post (作品), prepare and upload cover / portrait / LookBook images at the right size and ratio, add cast and crew credits, upload and maintain episode videos (分集 via BytePlus VOD), set who can watch each episode, run a crowdfunding campaign with donation tiers and offline supporters, read donation records, and answer enquiries (查詢 / 合作邀請 / 投資 / 授權) — reply, accept or decline, grant partner access. Use whenever a task mentions Backreel, a Backreel post / work / reel, its episodes, trailer, cover or poster sizes, crowdfunding / 眾籌 / 捐款, supporters, or enquiries / 查詢 on a creator's work.
4
+ ---
5
+
6
+ # Backreel
7
+
8
+ Backreel shares accounts and the API with WJI, so this skill sits on the `wji`
9
+ skill — read that one for sign-in (`wji auth whoami`), profiles and asset
10
+ basics. Everything here is `wji backreel …` (alias `wji br …`). Every command
11
+ takes `--json`; use it when you need ids.
12
+
13
+ Only the **post's creator** can edit, publish, upload episodes or see
14
+ enquiries and donations for it — admins included, the post routes refuse
15
+ anyone else. Check who you are before touching someone's work:
16
+
17
+ ```bash
18
+ wji auth whoami
19
+ wji br list # your posts, drafts included
20
+ ```
21
+
22
+ ## How a Backreel post is built
23
+
24
+ | Part | What it is | Command |
25
+ | --- | --- | --- |
26
+ | kind | `showcase` (episodes, trailer) or `crowdfunding` (goal, supporters) — switching wipes the other side's data | `br create --kind`, `br set --kind` |
27
+ | title / logline / description | logline = one public line ≤200 chars; description = HTML or plain text | `br set --logline`, `-d @file.md` |
28
+ | images | 16:9 cover, 9:16 portrait, LookBook gallery (≤9), credit photos | `br image <file> --slot … --post` |
29
+ | trailer | a video asset ≤30 s | `wji asset upload` → `br set --trailer` |
30
+ | episodes | ≤30, each with its own viewer tier, hosted on VOD | `br episode …` |
31
+ | credits | cast / crew / creative, ≤30, may link a WJI user or an NPC | `br credit …` |
32
+ | campaign + donations | goal, offline total, deadline, donation tiers, offline supporters | `br fund …` |
33
+ | enquiries | messages from people who want to work with / invest in / license the work | `br enquiry …` |
34
+
35
+ Viewer tiers, lowest to highest: `public` (anyone) < `member` (signed in) <
36
+ `supporter` (has donated) < `partner` (granted by the creator or a verified
37
+ investor / company). The logline and the cards are always public.
38
+
39
+ ## 1. Create a post and get it publishable
40
+
41
+ ```bash
42
+ wji br create "港口" --kind showcase --logline "一個漁村少年的最後一夏" \
43
+ -d @synopsis.md --category short_drama --project-status in_production --tags drama,hk
44
+ wji br check <postId> # blockers (✗) and advice (!)
45
+ ```
46
+
47
+ `create` always makes a **draft**. Publishing needs a title, a description
48
+ with text or an image, and at least one of cover / LookBook media / an
49
+ episode; a crowdfunding post also needs a goal ≥ 1. `br check` lists exactly
50
+ what is missing — run it before `br publish <postId>`. `br unpublish` takes a
51
+ post offline (it can be republished); `br rm` deletes it.
52
+
53
+ Categories the web offers: `narrative_short`, `movie`, `short_drama`,
54
+ `web_movie`, `tv_commercial`, `mv`, `animation`, `creative`, `tutorial`,
55
+ `other`.
56
+
57
+ ## 2. Images — size and ratio
58
+
59
+ The server accepts any image and **crops to fill** every frame, so a wrong
60
+ ratio silently loses the edges. Prepare each slot at its own ratio:
61
+
62
+ | Slot | Ratio | Ideal | Minimum | Where |
63
+ | --- | --- | --- | --- | --- |
64
+ | `cover` | 16:9 | 1920×1080 | 1280×720 | page header, shares; also cropped to 4:3 on cards and ~16:7 in reel strips |
65
+ | `portrait` | 9:16 | 1080×1920 | 720×1280 | mobile hero / poster (falls back to a crop of the cover) |
66
+ | `media` | 3:4 | 1536×2048 | 900×1200 | LookBook tiles (≤9 items, images or videos) |
67
+ | `photo` | 1:1 | 800×800 | 400×400 | credit photo, supporter avatar (shown round) |
68
+
69
+ JPEG / PNG / WebP, ≤25 MB. Long edges over 3840 px are downsized and files
70
+ over 10 MB are re-encoded as JPEG on upload.
71
+
72
+ ```bash
73
+ wji br image-specs # the table above
74
+ wji br image cover.jpg --slot cover --check # measure only
75
+ wji br image cover.jpg --slot cover --post <postId> # check, upload, set
76
+ wji br image poster.png --slot portrait --post <postId>
77
+ wji br image s1.jpg s2.jpg s3.jpg --slot media --post <postId> # appends to the LookBook
78
+ wji br set <postId> --media <assetId…> # replace / reorder the LookBook ("none" clears)
79
+ ```
80
+
81
+ `br image` reads the size from the file, warns about a wrong ratio or a soft
82
+ (too small) image, and refuses files it cannot upload. Treat a warning as a
83
+ reason to re-crop, not to ignore. Read [reference/images.md](reference/images.md)
84
+ for composition rules (safe area for the 4:3 and 16:7 crops), making the
85
+ 9:16 poster from the cover with AI, and credit photos.
86
+
87
+ ## 3. Episodes and videos
88
+
89
+ ```bash
90
+ wji br episode add <postId> E01.mp4 -t "第一集 重逢" # uploads to VOD, claims it, appends
91
+ wji br episode add <postId> E00.mp4 -t "前導" --at 1 --tier public
92
+ wji br episode list <postId>
93
+ wji br episode status <postId> # transcoding: processing → ready (refreshes from VOD)
94
+ wji br episode tiers <postId> --public-to 2 --member-to 5 --rest supporter
95
+ wji br episode set <postId> 3 -t "第三集(導演版)" # rename
96
+ wji br episode set <postId> 3 E03_v2.mp4 # replace the video
97
+ wji br episode move <postId> 4 2
98
+ wji br episode rm <postId> 6 -y
99
+ wji br episode play <postId> 1 # short-lived URL to watch it yourself
100
+ ```
101
+
102
+ Episodes exist on **showcase** posts only (≤30). A local file goes straight
103
+ to Backreel's BytePlus VOD space, which serves short-lived signed URLs —
104
+ nothing on the page can be copied and shared. Transcoding takes a few minutes;
105
+ an episode stays `processing` until then and viewers get "still
106
+ transcoding". Numbers on the command line are 1-based positions. Read
107
+ [reference/episodes.md](reference/episodes.md) for the trailer, the legacy
108
+ asset path, tier strategy and failure handling.
109
+
110
+ ## 4. Crowdfunding and donations
111
+
112
+ ```bash
113
+ wji br create "下一部長片" --kind crowdfunding -d @pitch.md
114
+ wji br fund set <postId> --goal 500000 --deadline 2026-12-31 \
115
+ --donations on --currency hkd --min 50
116
+ wji br fund tier add <postId> --title "早鳥" --amount 200 --perks "片尾鳴謝" --limit 300
117
+ wji br fund supporter add <postId> --name "ABC Films" --amount 50000
118
+ wji br fund set <postId> --offline-raised 50000 # money raised outside Backreel
119
+ wji br fund show <postId> # progress, settings, tiers
120
+ wji br fund donations <postId> # online donations, balance, payouts
121
+ ```
122
+
123
+ Progress = offline total + online donations. Donations (Stripe) work on any
124
+ published post, not just crowdfunding, and stop at the deadline; the currency
125
+ locks after the first donation. Payouts and refunds are done by Backreel
126
+ staff, not the CLI. Read [reference/crowdfunding.md](reference/crowdfunding.md)
127
+ for tier design and what supporters unlock.
128
+
129
+ ## 5. Enquiries
130
+
131
+ ```bash
132
+ wji br enquiry unread
133
+ wji br enquiry list # inbox, newest activity first (● = unread)
134
+ wji br enquiry list --type investment --status new
135
+ wji br enquiry get <enquiryId> # full request + thread; marks it read
136
+ wji br enquiry download <fileId> # an attachment (private file)
137
+ wji br enquiry reply <enquiryId> "多謝來信…" -a deck.pdf
138
+ wji br enquiry status <enquiryId> in_discussion # | accepted | declined | archived
139
+ wji br enquiry grant <enquiryId> # give the sender partner access
140
+ ```
141
+
142
+ Types: collaboration, new_project, investment, licensing, access_request,
143
+ general. Accepting an **access_request** grants partner access automatically;
144
+ `grant` does it from any enquiry (creator only) and marks it accepted. Every
145
+ reply notifies the sender in-app and by email (at most every 30 min), so
146
+ **show the user the reply text and get their OK before sending**, and never
147
+ accept, decline or grant on your own judgement. Read
148
+ [reference/enquiries.md](reference/enquiries.md) for triage and reply
149
+ patterns.
150
+
151
+ ## Credits
152
+
153
+ ```bash
154
+ wji br credit add <postId> --kind creative --name "陳導" --role 導演 --photo <assetId>
155
+ wji br credit add <postId> --kind cast --npc @mika --role "飾 阿明"
156
+ wji br credit add <postId> --kind crew --user <userId> --role 攝影指導 # their name and avatar win
157
+ wji br credit list <postId>
158
+ wji br credit rm <postId> 3 -y
159
+ ```
160
+
161
+ ## Rules of thumb
162
+
163
+ - **Ask before anything outward-facing**: publishing, unpublishing, deleting,
164
+ replying to or deciding an enquiry, granting access, turning donations on.
165
+ Draft edits are safe to make and show.
166
+ - Generating images (e.g. the AI poster) spends WJI credits — confirm first.
167
+ - Episode uploads go to the live VOD space even from a test post; don't
168
+ upload throwaway clips.
169
+ - Re-run `br check` after big edits; run `br episode status` after uploads.
170
+ - Errors come back in Chinese from the server (e.g. `分集最多 30 集`); relay
171
+ them as they are and say what to change.