@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.
- package/README.md +19 -2
- package/dist/commands/agent.js +28 -8
- package/dist/commands/backreel-enquiry.js +171 -0
- package/dist/commands/backreel-episode.js +243 -0
- package/dist/commands/backreel-fund.js +299 -0
- package/dist/commands/backreel.js +201 -18
- package/dist/commands/gen.js +5 -1
- package/dist/commands/npc-album.js +201 -0
- package/dist/commands/npc-live.js +316 -0
- package/dist/commands/npc.js +26 -2
- package/dist/commands/workspace.js +74 -1
- package/dist/core/backreel.js +23 -0
- package/dist/core/client.js +18 -1
- package/dist/core/image.js +106 -0
- package/dist/core/npc.js +1 -1
- package/dist/core/vod.js +149 -0
- package/dist/index.js +6 -0
- package/package.json +1 -1
- package/skill/backreel/SKILL.md +171 -0
- package/skill/backreel/reference/crowdfunding.md +82 -0
- package/skill/backreel/reference/enquiries.md +80 -0
- package/skill/backreel/reference/episodes.md +104 -0
- package/skill/backreel/reference/images.md +76 -0
- package/skill/npc-character/SKILL.md +111 -0
- package/skill/npc-character/examples/nagi.character.md +117 -0
- package/skill/npc-character/examples/nagi.stage.md +34 -0
- package/skill/npc-character/examples/yuzu.character.md +110 -0
- package/skill/npc-character/examples/yuzu.stage.md +30 -0
- package/skill/npc-character/reference/character-craft.md +153 -0
- package/skill/npc-character/reference/document-format.md +151 -0
- package/skill/npc-character/reference/interview.md +44 -0
- package/skill/npc-character/reference/life.md +92 -0
- package/skill/npc-character/reference/runtime.md +95 -0
- package/skill/npc-character/reference/stage-craft.md +109 -0
- package/skill/npc-character/reference/visual-kit.md +81 -0
- package/skill/npc-character/reference/voice.md +101 -0
- package/skill/wji/SKILL.md +10 -1
- 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
|
+
}
|
package/dist/core/client.js
CHANGED
|
@@ -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;
|
package/dist/core/vod.js
ADDED
|
@@ -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
|
@@ -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.
|