@wji.ai/cli 0.7.0 → 0.8.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 (49) hide show
  1. package/README.md +4 -1
  2. package/dist/index.js +7162 -76
  3. package/package.json +6 -9
  4. package/skill/short-drama/SKILL.md +22 -10
  5. package/skill/short-drama/reference/bgm-sfx.md +124 -0
  6. package/skill/short-drama/reference/canvas-recipes.md +19 -2
  7. package/skill/short-drama/reference/film-mixing-guideline.md +447 -0
  8. package/skill/short-drama/reference/mix-over-video.md +89 -0
  9. package/skill/short-drama/reference/operations-cli.md +1 -1
  10. package/skill/short-drama/reference/schema.md +4 -2
  11. package/skill/short-drama/reference/stage-characters.md +2 -2
  12. package/skill/short-drama/reference/stage-storyboard.md +29 -6
  13. package/skill/short-drama/reference/workspace-layout.md +2 -2
  14. package/skill/short-drama/scripts/selftest.mjs +54 -9
  15. package/skill/short-drama/scripts/short-drama.mjs +150 -12
  16. package/dist/commands/agent.js +0 -294
  17. package/dist/commands/asset.js +0 -241
  18. package/dist/commands/auth.js +0 -216
  19. package/dist/commands/backreel-enquiry.js +0 -171
  20. package/dist/commands/backreel-episode.js +0 -243
  21. package/dist/commands/backreel-fund.js +0 -299
  22. package/dist/commands/backreel.js +0 -443
  23. package/dist/commands/canvas.js +0 -1706
  24. package/dist/commands/class.js +0 -395
  25. package/dist/commands/config.js +0 -113
  26. package/dist/commands/crew.js +0 -205
  27. package/dist/commands/fsnode.js +0 -288
  28. package/dist/commands/gen.js +0 -356
  29. package/dist/commands/model.js +0 -136
  30. package/dist/commands/npc-album.js +0 -201
  31. package/dist/commands/npc-live.js +0 -316
  32. package/dist/commands/npc.js +0 -437
  33. package/dist/commands/org.js +0 -85
  34. package/dist/commands/preset.js +0 -176
  35. package/dist/commands/profile.js +0 -129
  36. package/dist/commands/skill.js +0 -272
  37. package/dist/commands/workspace.js +0 -470
  38. package/dist/core/archive.js +0 -95
  39. package/dist/core/backreel.js +0 -23
  40. package/dist/core/client.js +0 -269
  41. package/dist/core/config.js +0 -95
  42. package/dist/core/crew.js +0 -546
  43. package/dist/core/image.js +0 -106
  44. package/dist/core/members.js +0 -54
  45. package/dist/core/npc.js +0 -108
  46. package/dist/core/output.js +0 -174
  47. package/dist/core/prompt.js +0 -118
  48. package/dist/core/vod.js +0 -149
  49. package/dist/core/zip.js +0 -87
@@ -1,54 +0,0 @@
1
- export async function loadMembers(client, workspaceId) {
2
- const ws = await client.getFresh(`/workspace/list/${workspaceId}`);
3
- const members = (ws.members || []).map((m) => ({
4
- memberId: String(m.id),
5
- userId: String(m.user?.id ?? m.user ?? ''),
6
- name: String(m.user?.name || ''),
7
- email: String(m.user?.email || ''),
8
- role: m.role,
9
- }));
10
- return { workspace: ws, members };
11
- }
12
- /** What people call a member in a list: the name, else the email, else the id. */
13
- export const memberLabel = (m) => m.name || m.email || m.userId;
14
- /**
15
- * One member from what a person would type: user id, membership id, email or
16
- * name (case-insensitive), else a unique name prefix. Ambiguity is an error,
17
- * never a guess.
18
- */
19
- export function resolveMember(members, token) {
20
- const t = token.trim();
21
- const lower = t.toLowerCase();
22
- const exact = members.find((m) => m.userId === t || m.memberId === t);
23
- if (exact)
24
- return exact;
25
- const byEmail = members.filter((m) => m.email.toLowerCase() === lower);
26
- if (byEmail.length === 1)
27
- return byEmail[0];
28
- const byName = members.filter((m) => m.name.toLowerCase() === lower);
29
- if (byName.length === 1)
30
- return byName[0];
31
- if (byName.length > 1) {
32
- throw new Error(`Several members are called "${t}": ${byName.map((m) => `${m.email || m.userId}`).join(', ')} — use the email or user id`);
33
- }
34
- const prefix = members.filter((m) => m.name.toLowerCase().startsWith(lower) || m.email.toLowerCase().startsWith(lower));
35
- if (prefix.length === 1)
36
- return prefix[0];
37
- if (prefix.length > 1) {
38
- throw new Error(`"${t}" matches several members: ${prefix.map(memberLabel).join(', ')}`);
39
- }
40
- throw new Error(`No member "${t}" in this workspace. See \`wji ws member list\`.`);
41
- }
42
- /** Run `fn` over `items` with at most `limit` in flight, keeping input order. */
43
- export async function mapLimit(items, limit, fn) {
44
- const out = new Array(items.length);
45
- let next = 0;
46
- const worker = async () => {
47
- while (next < items.length) {
48
- const i = next++;
49
- out[i] = await fn(items[i], i);
50
- }
51
- };
52
- await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
53
- return out;
54
- }
package/dist/core/npc.js DELETED
@@ -1,108 +0,0 @@
1
- import { isObjectId } from './client.js';
2
- /**
3
- * NPC City residents (NPCs). Search endpoints return raw entities (`owner` is
4
- * an id); get / create / patch return the presented shape (`owner` is
5
- * `{id,name,avatarUrl}`). Everything the CLI prints reads fields both share.
6
- */
7
- export const RESIDENTS = '/npc-city/residents';
8
- export const NPC_STATUSES = ['active', 'debut', 'hiatus'];
9
- export const NPC_VISIBILITIES = ['public', 'private'];
10
- /** Work-history entry types; the server turns anything else into `event`. */
11
- export const NPC_JOB_TYPES = ['concert', 'release', 'collaboration', 'brand', 'event', 'ad', 'appearance'];
12
- /** How people refer to a resident: `NAME (@handle)`. */
13
- export const npcLabel = (r) => (r?.handle ? `${r.name} (${r.handle})` : String(r?.name ?? r?.id ?? ''));
14
- /** Every resident the caller manages in the current org context (`--org`). */
15
- export async function listMyNpcs(client) {
16
- const out = [];
17
- for (let page = 1; page <= 50; page++) {
18
- const res = await client.getFresh(`${RESIDENTS}/mine`, { page, limit: 100 });
19
- out.push(...(res?.data || []));
20
- if (!res?.pagination?.hasNext)
21
- break;
22
- }
23
- return out;
24
- }
25
- /**
26
- * A resident from what a person would type: id, `@handle` (or the handle
27
- * without `@`), exact name, else a unique name/handle prefix among yours; an
28
- * `@handle` that isn't yours falls back to the public directory. Ambiguity is
29
- * an error, never a guess. Returns the presented resident.
30
- */
31
- export async function resolveNpc(client, token) {
32
- const t = token.trim();
33
- if (isObjectId(t))
34
- return client.getFresh(`${RESIDENTS}/${t}`);
35
- const mine = await listMyNpcs(client);
36
- const lower = t.toLowerCase();
37
- const handle = lower.startsWith('@') ? lower : `@${lower}`;
38
- const pick = (r) => client.getFresh(`${RESIDENTS}/${r.id}`);
39
- const byHandle = mine.filter((r) => String(r.handle || '').toLowerCase() === handle);
40
- if (byHandle.length === 1)
41
- return pick(byHandle[0]);
42
- const byName = mine.filter((r) => String(r.name || '').toLowerCase() === lower);
43
- if (byName.length === 1)
44
- return pick(byName[0]);
45
- if (byName.length > 1) {
46
- throw new Error(`Several NPCs are called "${t}": ${byName.map(npcLabel).join(', ')} — use the @handle or id`);
47
- }
48
- const bare = lower.replace(/^@/, '');
49
- const prefix = mine.filter((r) => String(r.name || '').toLowerCase().startsWith(bare) ||
50
- String(r.handle || '').toLowerCase().replace(/^@/, '').startsWith(bare));
51
- if (prefix.length === 1)
52
- return pick(prefix[0]);
53
- if (prefix.length > 1)
54
- throw new Error(`"${t}" matches several NPCs: ${prefix.map(npcLabel).join(', ')}`);
55
- // Someone else's public NPC (e.g. to credit it on a Backreel post).
56
- if (t.startsWith('@')) {
57
- const pub = await client.getFresh(`${RESIDENTS}/by-handle/${encodeURIComponent(t)}`).catch(() => undefined);
58
- if (pub?.id)
59
- return pub;
60
- }
61
- throw new Error(`No NPC "${t}" among yours. See \`wji npc list\`; org-owned NPCs need \`--org <orgId>\`.`);
62
- }
63
- const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
64
- /**
65
- * A job from CLI flags, validated here because the server silently drops
66
- * entries without a title or date and rewrites unknown types to `event`.
67
- * With `base` the flags are merged onto an existing job (`job set`).
68
- */
69
- export function parseJob(opts, base) {
70
- const job = {
71
- type: opts.type ?? base?.type ?? '',
72
- title: opts.title ?? base?.title ?? '',
73
- detail: opts.about ?? opts.detail ?? base?.detail ?? '',
74
- date: opts.date ?? base?.date ?? '',
75
- };
76
- const location = opts.location ?? base?.location;
77
- const url = opts.url ?? base?.url;
78
- if (location)
79
- job.location = location;
80
- if (url)
81
- job.url = url;
82
- if (!NPC_JOB_TYPES.includes(job.type)) {
83
- throw new Error(`Job type must be one of ${NPC_JOB_TYPES.join(', ')} (got "${job.type}")`);
84
- }
85
- if (!job.title.trim())
86
- throw new Error('A job needs --title');
87
- if (!DATE_RE.test(job.date))
88
- throw new Error(`A job needs --date as YYYY-MM-DD (got "${job.date}")`);
89
- return job;
90
- }
91
- /** Jobs have no ids: read the array fresh, change it, write it back whole. */
92
- export async function updateJobs(client, id, fn) {
93
- const current = await client.getFresh(`${RESIDENTS}/${id}`);
94
- const jobs = fn([...(current.jobs || [])]);
95
- return client.patch(`${RESIDENTS}/${id}`, { jobs });
96
- }
97
- /** 1-based positions from the command line, checked against the list length. */
98
- export function parsePositions(values, length) {
99
- const out = values.map((v) => {
100
- const n = Number(v);
101
- if (!Number.isInteger(n) || n < 1 || n > length) {
102
- throw new Error(`No #${v} — there ${length === 1 ? 'is 1 entry' : `are ${length} entries`}`);
103
- }
104
- return n - 1;
105
- });
106
- return [...new Set(out)].sort((a, b) => a - b);
107
- }
108
- export const today = () => new Date().toISOString().slice(0, 10);
@@ -1,174 +0,0 @@
1
- const useColor = process.env.NO_COLOR == null && process.env.WJI_NO_COLOR == null && process.stdout.isTTY;
2
- const wrap = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
3
- export const color = {
4
- dim: wrap('2'),
5
- bold: wrap('1'),
6
- red: wrap('31'),
7
- green: wrap('32'),
8
- yellow: wrap('33'),
9
- blue: wrap('34'),
10
- cyan: wrap('36'),
11
- };
12
- /** Global --json: when on, every command prints raw JSON and nothing else. */
13
- let jsonMode = false;
14
- export function setJsonMode(on) {
15
- jsonMode = on;
16
- }
17
- export function isJsonMode() {
18
- return jsonMode;
19
- }
20
- const DETAIL_LEVELS = ['brief', 'standard', 'full'];
21
- /**
22
- * Global --detail (or WJI_DETAIL). Unset means "standard when printing JSON,
23
- * nothing (server default = full) for human output", so tables are unchanged.
24
- */
25
- let detailMode;
26
- export function setDetailMode(raw) {
27
- if (raw === undefined || raw === '') {
28
- detailMode = undefined;
29
- return;
30
- }
31
- const level = String(raw).toLowerCase();
32
- if (!DETAIL_LEVELS.includes(level)) {
33
- throw new Error(`--detail must be one of ${DETAIL_LEVELS.join(', ')} (got "${raw}")`);
34
- }
35
- detailMode = level;
36
- }
37
- export function detailLevel() {
38
- if (detailMode)
39
- return detailMode;
40
- return jsonMode ? 'standard' : undefined;
41
- }
42
- /** Clip long text the way the server does at `standard` (200 chars + …). */
43
- export function clipText(value, max = 200) {
44
- if (value === undefined || value === null || value === '')
45
- return undefined;
46
- const s = String(value);
47
- return s.length <= max ? s : `${s.slice(0, max)}…`;
48
- }
49
- export function printJson(value) {
50
- process.stdout.write(JSON.stringify(value, null, 2) + '\n');
51
- }
52
- /** Print `value` as JSON in --json mode, otherwise run `human`. */
53
- export function render(value, human) {
54
- if (jsonMode)
55
- return printJson(value);
56
- human(value);
57
- }
58
- export function info(message) {
59
- if (!jsonMode)
60
- process.stderr.write(message + '\n');
61
- }
62
- export function success(message) {
63
- if (!jsonMode)
64
- process.stderr.write(color.green('✓ ') + message + '\n');
65
- }
66
- export function warn(message) {
67
- process.stderr.write(color.yellow('! ') + message + '\n');
68
- }
69
- const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
70
- /**
71
- * Terminal cell width. CJK and emoji occupy two columns, so counting code
72
- * points misaligns every table that holds Chinese text.
73
- */
74
- function charWidth(code) {
75
- if (code === 0x200d)
76
- return 0;
77
- if (code >= 0x0300 && code <= 0x036f)
78
- return 0; // combining marks
79
- if (code >= 0xfe00 && code <= 0xfe0f)
80
- return 0; // variation selectors
81
- return (code >= 0x1100 && code <= 0x115f) ||
82
- (code >= 0x2e80 && code <= 0x303e) ||
83
- (code >= 0x3041 && code <= 0x33ff) ||
84
- (code >= 0x3400 && code <= 0x4dbf) ||
85
- (code >= 0x4e00 && code <= 0x9fff) ||
86
- (code >= 0xa000 && code <= 0xa4cf) ||
87
- (code >= 0xa960 && code <= 0xa97f) ||
88
- (code >= 0xac00 && code <= 0xd7a3) ||
89
- (code >= 0xf900 && code <= 0xfaff) ||
90
- (code >= 0xfe10 && code <= 0xfe19) ||
91
- (code >= 0xfe30 && code <= 0xfe6f) ||
92
- (code >= 0xff00 && code <= 0xff60) ||
93
- (code >= 0xffe0 && code <= 0xffe6) ||
94
- (code >= 0x1f300 && code <= 0x1f64f) ||
95
- (code >= 0x1f900 && code <= 0x1f9ff) ||
96
- (code >= 0x20000 && code <= 0x3fffd)
97
- ? 2
98
- : 1;
99
- }
100
- export function displayWidth(text) {
101
- let width = 0;
102
- for (const char of stripAnsi(text))
103
- width += charWidth(char.codePointAt(0));
104
- return width;
105
- }
106
- const visibleLength = displayWidth;
107
- export function table(rows, columns) {
108
- if (!rows.length) {
109
- info(color.dim('(empty)'));
110
- return;
111
- }
112
- const cells = rows.map((row) => columns.map((c) => {
113
- const v = c.value(row);
114
- return v == null ? color.dim('-') : String(v);
115
- }));
116
- const widths = columns.map((c, i) => Math.max(visibleLength(c.header), ...cells.map((r) => visibleLength(r[i]))));
117
- const pad = (s, w) => s + ' '.repeat(Math.max(0, w - displayWidth(s)));
118
- process.stdout.write(columns.map((c, i) => color.dim(pad(c.header.toUpperCase(), widths[i]))).join(' ').trimEnd() +
119
- '\n');
120
- for (const row of cells) {
121
- process.stdout.write(row.map((v, i) => pad(v, widths[i])).join(' ').trimEnd() + '\n');
122
- }
123
- }
124
- /** Key/value block for detail views. */
125
- export function detail(pairs) {
126
- const width = Math.max(...pairs.map(([k]) => displayWidth(k)));
127
- for (const [k, v] of pairs) {
128
- if (v == null || v === '')
129
- continue;
130
- const text = typeof v === 'object' ? JSON.stringify(v) : String(v);
131
- const padded = k + ' '.repeat(Math.max(0, width - displayWidth(k)));
132
- process.stdout.write(`${color.dim(padded)} ${text}\n`);
133
- }
134
- }
135
- /** Truncate to `max` terminal columns, not `max` code points. */
136
- export function truncate(value, max = 48) {
137
- if (value == null)
138
- return null;
139
- const s = String(value).replace(/\s+/g, ' ').trim();
140
- if (displayWidth(s) <= max)
141
- return s;
142
- let out = '';
143
- let width = 0;
144
- for (const char of s) {
145
- const next = width + charWidth(char.codePointAt(0));
146
- if (next > max - 1)
147
- break;
148
- out += char;
149
- width = next;
150
- }
151
- return out + '…';
152
- }
153
- export function relativeTime(value) {
154
- if (!value)
155
- return null;
156
- const then = new Date(value).getTime();
157
- if (Number.isNaN(then))
158
- return null;
159
- const diff = Date.now() - then;
160
- const units = [
161
- [86400000 * 365, 'y'],
162
- [86400000 * 30, 'mo'],
163
- [86400000, 'd'],
164
- [3600000, 'h'],
165
- [60000, 'm'],
166
- ];
167
- for (const [ms, label] of units) {
168
- if (Math.abs(diff) >= ms) {
169
- const n = Math.round(Math.abs(diff) / ms);
170
- return diff >= 0 ? `${n}${label} ago` : `in ${n}${label}`;
171
- }
172
- }
173
- return 'just now';
174
- }
@@ -1,118 +0,0 @@
1
- import { createInterface } from 'node:readline/promises';
2
- import { stdin, stdout } from 'node:process';
3
- const ETX = '\u0003'; // Ctrl-C
4
- const BACKSPACE = '\u007f';
5
- export async function ask(question) {
6
- const rl = createInterface({ input: stdin, output: stdout });
7
- try {
8
- return (await rl.question(question)).trim();
9
- }
10
- finally {
11
- rl.close();
12
- }
13
- }
14
- /** Read a secret without echoing it. Falls back to a normal read on a non-TTY stdin. */
15
- export async function askSecret(question) {
16
- if (!stdin.isTTY)
17
- return ask(question);
18
- stdout.write(question);
19
- stdin.setRawMode(true);
20
- stdin.resume();
21
- stdin.setEncoding('utf8');
22
- return new Promise((resolve, reject) => {
23
- let value = '';
24
- const finish = (fn) => {
25
- stdin.setRawMode(false);
26
- stdin.pause();
27
- stdin.removeListener('data', onData);
28
- stdout.write('\n');
29
- fn();
30
- };
31
- const onData = (chunk) => {
32
- for (const char of chunk) {
33
- if (char === '\r' || char === '\n')
34
- return finish(() => resolve(value));
35
- if (char === ETX)
36
- return finish(() => reject(new Error('Cancelled')));
37
- if (char === BACKSPACE || char === '\b') {
38
- value = value.slice(0, -1);
39
- continue;
40
- }
41
- value += char;
42
- }
43
- };
44
- stdin.on('data', onData);
45
- });
46
- }
47
- export async function confirm(question, force) {
48
- if (force)
49
- return true;
50
- if (!stdin.isTTY) {
51
- throw new Error(`Refusing to continue without confirmation on a non-TTY. Pass --yes. (${question})`);
52
- }
53
- const answer = (await ask(`${question} [y/N] `)).toLowerCase();
54
- return answer === 'y' || answer === 'yes';
55
- }
56
- /** Read a value given inline, as `@file`, or as `-` for stdin. */
57
- export async function readValue(value) {
58
- const { readFile } = await import('node:fs/promises');
59
- if (value === '-') {
60
- const chunks = [];
61
- for await (const chunk of stdin)
62
- chunks.push(chunk);
63
- return Buffer.concat(chunks).toString('utf8');
64
- }
65
- if (value.startsWith('@'))
66
- return readFile(value.slice(1), 'utf8');
67
- return value;
68
- }
69
- /** Parse a JSON option that may be inline JSON, `@file` or `-`. */
70
- export async function readJson(value) {
71
- if (value === undefined)
72
- return undefined;
73
- const text = await readValue(value);
74
- try {
75
- return JSON.parse(text);
76
- }
77
- catch (err) {
78
- throw new Error(`Invalid JSON: ${err.message}`);
79
- }
80
- }
81
- /** Parse repeated `--set key=value` pairs into a nested object. */
82
- export function parseKeyValues(pairs = []) {
83
- const out = {};
84
- for (const pair of pairs) {
85
- const index = pair.indexOf('=');
86
- if (index === -1)
87
- throw new Error(`Expected key=value, got "${pair}"`);
88
- const key = pair.slice(0, index);
89
- const raw = pair.slice(index + 1);
90
- let value = raw;
91
- if (raw === 'true')
92
- value = true;
93
- else if (raw === 'false')
94
- value = false;
95
- else if (raw === 'null')
96
- value = null;
97
- else if (raw !== '' && !Number.isNaN(Number(raw)))
98
- value = Number(raw);
99
- else if (raw.startsWith('{') || raw.startsWith('[')) {
100
- try {
101
- value = JSON.parse(raw);
102
- }
103
- catch {
104
- /* leave it as a string */
105
- }
106
- }
107
- // Dotted keys build nested objects, matching the API's qs allowDots parsing.
108
- const path = key.split('.');
109
- let cursor = out;
110
- for (const segment of path.slice(0, -1)) {
111
- if (typeof cursor[segment] !== 'object' || cursor[segment] === null)
112
- cursor[segment] = {};
113
- cursor = cursor[segment];
114
- }
115
- cursor[path[path.length - 1]] = value;
116
- }
117
- return out;
118
- }
package/dist/core/vod.js DELETED
@@ -1,149 +0,0 @@
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
- }