@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,216 +0,0 @@
1
- import { Command } from 'commander';
2
- import { ApiError, WjiClient } from '../core/client.js';
3
- import { getProfile, updateProfile } from '../core/config.js';
4
- import { askSecret, ask, confirm } from '../core/prompt.js';
5
- import { color, detail, info, isJsonMode, printJson, relativeTime, render, success, table, warn } from '../core/output.js';
6
- export function authCommand(globals) {
7
- const auth = new Command('auth').description('Sign in and manage credentials');
8
- auth
9
- .command('login')
10
- .description('Sign in with email + password and store the tokens')
11
- .option('-e, --email <email>', 'account email')
12
- .option('-p, --password <password>', 'account password (prompted when omitted)')
13
- .action(async (opts) => {
14
- const g = globals();
15
- const client = new WjiClient(g);
16
- const email = opts.email || (await ask('Email: '));
17
- const password = opts.password || (await askSecret('Password: '));
18
- const result = await client.request('POST', '/auth/signin', {
19
- body: { email, password },
20
- anonymous: true,
21
- });
22
- updateProfile(g.profile, {
23
- endpoint: client.endpoint,
24
- accessToken: result.tokens.accessToken,
25
- refreshToken: result.tokens.refreshToken,
26
- userId: result.user.id,
27
- email: result.user.email,
28
- name: result.user.name,
29
- });
30
- if (isJsonMode())
31
- return printJson(result);
32
- success(`Signed in as ${result.user.name || result.user.email} (profile: ${client.profileName})`);
33
- info(color.dim(`Endpoint ${client.endpoint} · balance ${result.user.balance ?? '-'}`));
34
- });
35
- auth
36
- .command('signup')
37
- .description('Register a new account (needs an email verification code)')
38
- .requiredOption('-e, --email <email>', 'account email')
39
- .requiredOption('-n, --name <name>', 'display name')
40
- .requiredOption('-c, --code <code>', 'email verification code')
41
- .option('-p, --password <password>', 'password (prompted when omitted)')
42
- .action(async (opts) => {
43
- const g = globals();
44
- const client = new WjiClient(g);
45
- const password = opts.password || (await askSecret('Password: '));
46
- const result = await client.request('POST', '/auth/signup', {
47
- body: { email: opts.email, name: opts.name, code: opts.code, password },
48
- anonymous: true,
49
- });
50
- updateProfile(g.profile, {
51
- endpoint: client.endpoint,
52
- accessToken: result.tokens.accessToken,
53
- refreshToken: result.tokens.refreshToken,
54
- userId: result.user.id,
55
- email: result.user.email,
56
- name: result.user.name,
57
- });
58
- render(result, () => success(`Registered and signed in as ${result.user.email}`));
59
- });
60
- auth
61
- .command('token')
62
- .description('Print the active bearer token (use for scripting)')
63
- .action(() => {
64
- const client = new WjiClient(globals());
65
- process.stdout.write(client.requireToken() + '\n');
66
- });
67
- auth
68
- .command('whoami')
69
- .description('Show the signed-in account')
70
- .action(async () => {
71
- const client = new WjiClient(globals());
72
- const me = await client.get('/user/me');
73
- render(me, (u) => detail([
74
- ['id', u.id],
75
- ['name', u.name],
76
- ['email', u.email],
77
- ['balance', u.balance],
78
- ['vip', u.vip?.level],
79
- ['endpoint', client.endpoint],
80
- ['org', client.orgId || 'personal'],
81
- ['profile', client.profileName],
82
- ['credential', client.usingApiKey ? 'api key' : 'jwt'],
83
- ]));
84
- });
85
- auth
86
- .command('refresh')
87
- .description('Exchange the stored refresh token for a fresh access token')
88
- .action(async () => {
89
- const client = new WjiClient(globals());
90
- // Goes through the same locked path as the automatic refresh, so a manual
91
- // refresh can't race a parallel command into replaying the old token.
92
- const profile = await client.refreshSession();
93
- render({ accessToken: profile.accessToken, refreshToken: profile.refreshToken }, () => success('Access token refreshed'));
94
- });
95
- auth
96
- .command('logout')
97
- .description('Clear stored credentials for the current profile')
98
- .action(async () => {
99
- const g = globals();
100
- const { profile } = getProfile(g.profile);
101
- if (profile.refreshToken && profile.accessToken) {
102
- // POST with the token in the body — `GET /auth/signout?rt=` is deprecated
103
- // and put the refresh token into access logs.
104
- const client = new WjiClient(g);
105
- const signout = (refreshToken) => client.request('POST', '/auth/signout', { body: { refreshToken }, noRetry: true });
106
- try {
107
- try {
108
- await signout(profile.refreshToken);
109
- }
110
- catch (err) {
111
- // An expired access token: refreshing rotates the refresh token, so the
112
- // one to revoke is the new one — revoking the old one would be a no-op.
113
- if (!(err instanceof ApiError && err.status === 401))
114
- throw err;
115
- const rotated = await client.refreshSession();
116
- if (rotated.refreshToken)
117
- await signout(rotated.refreshToken);
118
- }
119
- }
120
- catch {
121
- // Server-side signout is best effort; local credentials still go.
122
- }
123
- }
124
- updateProfile(g.profile, {
125
- accessToken: undefined,
126
- refreshToken: undefined,
127
- apiKey: undefined,
128
- userId: undefined,
129
- email: undefined,
130
- name: undefined,
131
- });
132
- success('Signed out');
133
- });
134
- // ── API keys ───────────────────────────────────────────────────────────────
135
- const key = auth.command('key').description('Manage long-lived sk_ API keys');
136
- key
137
- .command('list')
138
- .description('List your API keys')
139
- .option('--limit <n>', 'page size', '20')
140
- .action(async (opts) => {
141
- const client = new WjiClient(globals());
142
- const result = await client.get('/api-key/search', { limit: Number(opts.limit) });
143
- render(result, (r) => table(r.data || [], [
144
- { header: 'id', value: (k) => k.id },
145
- { header: 'name', value: (k) => k.name },
146
- { header: 'prefix', value: (k) => k.keyPrefix },
147
- { header: 'revoked', value: (k) => (k.revokedAt ? 'yes' : '') },
148
- { header: 'last used', value: (k) => relativeTime(k.lastUsedAt) },
149
- { header: 'created', value: (k) => relativeTime(k.createdAt) },
150
- ]));
151
- });
152
- key
153
- .command('create <name>')
154
- .description('Create an API key; the secret is shown once')
155
- .option('--expires <iso>', 'expiry date, e.g. 2027-01-01')
156
- .option('--save', 'store the new key in this profile and use it for future calls')
157
- .action(async (name, opts) => {
158
- const g = globals();
159
- const client = new WjiClient(g);
160
- // The endpoint answers { apiKey, secret }; the plaintext secret is only ever
161
- // returned here.
162
- const created = await client.post('/api-key/create', {
163
- name,
164
- expiresAt: opts.expires ? new Date(opts.expires).toISOString() : undefined,
165
- });
166
- const secret = created.secret;
167
- if (opts.save && secret)
168
- updateProfile(g.profile, { apiKey: secret });
169
- if (isJsonMode())
170
- return printJson(created);
171
- success(`Created API key "${name}" (${created.apiKey?.id ?? ''})`);
172
- if (secret) {
173
- process.stdout.write(secret + '\n');
174
- info(color.dim('Store this now — it is not retrievable later.'));
175
- }
176
- else {
177
- warn('The server did not return a secret.');
178
- }
179
- if (opts.save)
180
- info(color.dim(`Saved to profile ${client.profileName}`));
181
- });
182
- key
183
- .command('revoke <id>')
184
- .description('Revoke an API key')
185
- .option('-y, --yes', 'skip confirmation')
186
- .action(async (id, opts) => {
187
- const prompt = `Revoke API key ${id}? Anything still using it stops working immediately.`;
188
- if (!(await confirm(prompt, opts.yes)))
189
- return info('Aborted');
190
- const client = new WjiClient(globals());
191
- const result = await client.post(`/api-key/list/${id}/revoke`);
192
- render(result, () => success(`Revoked ${id}`));
193
- });
194
- key
195
- .command('rm <id>')
196
- .description('Delete an API key')
197
- .option('-y, --yes', 'skip confirmation')
198
- .action(async (id, opts) => {
199
- if (!(await confirm(`Delete API key ${id}?`, opts.yes)))
200
- return info('Aborted');
201
- const client = new WjiClient(globals());
202
- const result = await client.delete(`/api-key/list/${id}`);
203
- render(result, () => success(`Deleted ${id}`));
204
- });
205
- key
206
- .command('use <secret>')
207
- .description('Store an existing sk_ key in this profile')
208
- .action((secret) => {
209
- const g = globals();
210
- if (!secret.startsWith('sk_'))
211
- throw new Error('An API key must start with sk_');
212
- updateProfile(g.profile, { apiKey: secret });
213
- success('API key saved');
214
- });
215
- return auth;
216
- }
@@ -1,171 +0,0 @@
1
- import { writeFile } from 'node:fs/promises';
2
- import { basename } from 'node:path';
3
- import { Command } from 'commander';
4
- import { backreelClient, oneOf } from '../core/backreel.js';
5
- import { color, detail, info, render, relativeTime, success, table, truncate } from '../core/output.js';
6
- import { readValue } from '../core/prompt.js';
7
- const ENQ = '/backreel/enquiry';
8
- const TYPES = ['collaboration', 'new_project', 'investment', 'licensing', 'access_request', 'general'];
9
- const STATUSES = ['new', 'in_discussion', 'accepted', 'declined', 'archived', 'withdrawn'];
10
- /** What the recipient (post owner) may set; the sender may only withdraw. */
11
- const SETTABLE = ['in_discussion', 'accepted', 'declined', 'archived', 'withdrawn'];
12
- const MAX_ATTACHMENTS = 5;
13
- const MESSAGE_MAX = 4000;
14
- const person = (p) => (p ? `${p.name || p.id}${p.verifiedKinds?.length ? color.dim(` ✓${p.verifiedKinds.join(',')}`) : ''}` : color.dim('system'));
15
- /** Enquiry attachments are private Backreel files (purpose=enquiry: documents and images ≤20 MB). */
16
- async function uploadAttachments(client, files = []) {
17
- if (files.length > MAX_ATTACHMENTS)
18
- throw new Error(`At most ${MAX_ATTACHMENTS} attachments per message`);
19
- const ids = [];
20
- for (const file of files) {
21
- info(color.dim(`Uploading ${basename(file)}…`));
22
- const uploaded = await client.upload('/backreel/file/upload?purpose=enquiry', file, {});
23
- ids.push(uploaded.id);
24
- }
25
- return ids;
26
- }
27
- function printSummaryTable(rows) {
28
- table(rows, [
29
- { header: 'id', value: (e) => e.id },
30
- { header: '', value: (e) => (e.unread ? color.yellow('●') : '') },
31
- { header: 'type', value: (e) => e.type },
32
- { header: 'status', value: (e) => e.status + (e.archived && e.status !== 'archived' ? color.dim(' (archived)') : '') },
33
- { header: 'from', value: (e) => truncate(e.side === 'sender' ? `→ ${e.recipient?.name}` : e.sender?.name, 20) },
34
- { header: 'post', value: (e) => truncate(e.post?.title, 20) },
35
- { header: 'last', value: (e) => truncate(e.lastMessagePreview || e.subject, 36) },
36
- { header: 'when', value: (e) => relativeTime(e.lastMessageAt) },
37
- ]);
38
- }
39
- function printEnquiry(e, messages) {
40
- const extra = [];
41
- if (e.projectInvite) {
42
- const p = e.projectInvite;
43
- const budget = p.budgetMin || p.budgetMax ? `${p.budgetMin ?? '?'}–${p.budgetMax ?? '?'} ${p.currency || ''}` : '';
44
- extra.push(['project', [p.projectType, p.title, budget, p.location, p.roleNeeded].filter(Boolean).join(' · ')]);
45
- if (p.timelineStart || p.timelineEnd)
46
- extra.push(['timeline', `${p.timelineStart ?? '?'} → ${p.timelineEnd ?? '?'}`]);
47
- }
48
- if (e.licensing)
49
- extra.push(['licensing', [e.licensing.territory, e.licensing.media, e.licensing.term].filter(Boolean).join(' · ')]);
50
- if (e.investment)
51
- extra.push(['investment', `${e.investment.ticketSize ?? '?'} ${e.investment.currency || ''}`]);
52
- if (e.accessPurpose)
53
- extra.push(['access purpose', e.accessPurpose]);
54
- const c = e.contact || {};
55
- detail([
56
- ['id', e.id],
57
- ['type', e.type],
58
- ['status', e.status + (e.archived && e.status !== 'archived' ? ' (archived)' : '')],
59
- ['subject', e.subject],
60
- ['post', `${e.post?.title} (${e.post?.id})`],
61
- ['from', person(e.sender)],
62
- ['to', `${person(e.recipient)} (${e.recipientRole})`],
63
- ['contact', [c.name, c.email, c.phone, c.company].filter(Boolean).join(' · ')],
64
- ...extra,
65
- ['sender access', e.senderAccess ? `${e.senderAccess.partnerState}${e.senderAccess.supporter ? ', supporter' : ''}` : undefined],
66
- ['can', Object.entries(e.permissions || {}).filter(([, v]) => v).map(([k]) => k).join(', ')],
67
- ['attachments', (e.attachments || []).map((f) => `${f.name} (${f.id})`).join(', ')],
68
- ['created', e.createdAt],
69
- ]);
70
- process.stdout.write('\n');
71
- for (const m of messages) {
72
- const who = m.kind === 'system' ? color.dim('— system') : color.bold(person(m.sender));
73
- process.stdout.write(`${who} ${color.dim(relativeTime(m.createdAt) ?? '')}\n`);
74
- if (m.body)
75
- process.stdout.write(`${m.body}\n`);
76
- for (const f of m.attachments || [])
77
- process.stdout.write(color.dim(` 📎 ${f.name} (${f.id})\n`));
78
- process.stdout.write('\n');
79
- }
80
- }
81
- export function enquiryCommand(globals) {
82
- const enq = new Command('enquiry')
83
- .aliases(['enquiries', 'enq'])
84
- .description('Enquiries about your posts: read, reply, accept / decline, grant partner access');
85
- enq.command('list', { isDefault: true })
86
- .description('Your inbox (received) or what you sent, newest activity first')
87
- .option('--box <box>', 'received | sent', 'received')
88
- .option('--post <postId>', 'only this post')
89
- .option('--status <status>', STATUSES.join(' | '))
90
- .option('--type <type>', TYPES.join(' | '))
91
- .option('--unread', 'only unread')
92
- .option('--archived', 'show the archive instead of the inbox')
93
- .option('--page <n>', 'page number', '1')
94
- .option('--limit <n>', 'page size', '20')
95
- .action(async (opts) => {
96
- const client = backreelClient(globals);
97
- const result = await client.get(`${ENQ}/search`, {
98
- box: oneOf(opts.box, ['received', 'sent'], '--box'),
99
- post: opts.post,
100
- status: opts.status ? oneOf(opts.status, STATUSES, '--status') : undefined,
101
- type: opts.type ? oneOf(opts.type, TYPES, '--type') : undefined,
102
- unread: opts.unread ? 'true' : undefined,
103
- archived: opts.archived ? 'true' : undefined,
104
- page: Number(opts.page),
105
- limit: Number(opts.limit),
106
- });
107
- render(result, (r) => {
108
- printSummaryTable(r?.data || []);
109
- const p = r?.pagination;
110
- if (p)
111
- info(color.dim(`${p.total} total · page ${p.page}${p.hasNext ? ` · more: --page ${p.page + 1}` : ''}`));
112
- });
113
- });
114
- enq.command('unread')
115
- .description('Unread counts')
116
- .action(async () => {
117
- const client = backreelClient(globals);
118
- const counts = await client.get(`${ENQ}/unread-count`);
119
- render(counts, (c) => detail([['received', c.received], ['sent', c.sent], ['total', c.total]]));
120
- });
121
- enq.command('get <id>')
122
- .description('Show an enquiry and its whole thread (marks it read)')
123
- .action(async (id) => {
124
- const client = backreelClient(globals);
125
- const e = await client.getFresh(`${ENQ}/list/${id}`);
126
- const messages = await client.getFresh(`${ENQ}/list/${id}/messages`);
127
- render({ ...e, messages }, () => printEnquiry(e, messages || []));
128
- });
129
- enq.command('reply <id> <message>')
130
- .description(`Reply in the thread (≤${MESSAGE_MAX} chars; @file / - supported). The other side gets an in-app notice and, at most every 30 min, an email`)
131
- .option('-a, --attach <file...>', `attach files (≤${MAX_ATTACHMENTS}; documents / images ≤20 MB)`)
132
- .action(async (id, message, opts) => {
133
- const client = backreelClient(globals);
134
- const body = (await readValue(message)).trim();
135
- if (!body && !opts.attach?.length)
136
- throw new Error('Empty reply');
137
- if (body.length > MESSAGE_MAX)
138
- throw new Error(`Reply is ${body.length} chars; the limit is ${MESSAGE_MAX}`);
139
- const attachments = await uploadAttachments(client, opts.attach);
140
- const sent = await client.post(`${ENQ}/list/${id}/messages`, { body, attachments: attachments.length ? attachments : undefined });
141
- render(sent, () => success('Reply sent'));
142
- });
143
- enq.command('status <id> <status>')
144
- .description(`Move an enquiry: ${SETTABLE.join(' | ')} (accepting an access_request grants partner access)`)
145
- .action(async (id, status) => {
146
- const client = backreelClient(globals);
147
- const updated = await client.post(`${ENQ}/list/${id}/status`, { status: oneOf(status, SETTABLE, 'status') });
148
- render(updated, (e) => success(`Enquiry ${id} is now ${e.status}`));
149
- });
150
- enq.command('grant <id>')
151
- .description('Give the sender partner access to the post (creator only; also accepts an open enquiry)')
152
- .action(async (id) => {
153
- const client = backreelClient(globals);
154
- const updated = await client.post(`${ENQ}/list/${id}/grant`);
155
- render(updated, (e) => success(`Granted partner access to ${e.sender?.name || 'the sender'} — status ${e.status}`));
156
- });
157
- enq.command('download <fileId>')
158
- .description('Download an attachment (private file; the signed link lasts 60 s)')
159
- .option('-o, --out <path>', 'output path (default: the original file name)')
160
- .action(async (fileId, opts) => {
161
- const client = backreelClient(globals);
162
- const link = await client.get(`/backreel/file/${fileId}/download`);
163
- const res = await fetch(link.url);
164
- if (!res.ok)
165
- throw new Error(`Download failed: HTTP ${res.status}`);
166
- const out = opts.out || basename(link.name || fileId);
167
- await writeFile(out, Buffer.from(await res.arrayBuffer()));
168
- render({ ...link, path: out }, () => success(`Saved ${out}`));
169
- });
170
- return enq;
171
- }
@@ -1,243 +0,0 @@
1
- import { stat } from 'node:fs/promises';
2
- import { basename, extname } from 'node:path';
3
- import { Command } from 'commander';
4
- import { ApiError } from '../core/client.js';
5
- import { POSTS, TIERS, backreelClient, episodeIndex, idOf, loadPost, oneOf } from '../core/backreel.js';
6
- import { color, info, render, success, table, truncate, warn } from '../core/output.js';
7
- import { confirm } from '../core/prompt.js';
8
- import { uploadToVod } from '../core/vod.js';
9
- const MAX_EPISODES = 30;
10
- const VIDEO_EXTS = ['.mp4', '.mov', '.m4v', '.webm'];
11
- /** /assets/v2/upload cap for video — only the --legacy path goes through it. */
12
- const ASSET_VIDEO_MAX_BYTES = 200 * 1024 * 1024;
13
- /** The fields the update DTO takes back; vodStatus / poster / duration are server-owned. */
14
- function toDto(e, order) {
15
- const base = { title: e.title, order, tier: e.tier || 'public' };
16
- return e.vodVid ? { ...base, vodVid: e.vodVid } : { ...base, video: idOf(e.video) };
17
- }
18
- async function loadEpisodes(client, postId) {
19
- const post = await loadPost(client, postId);
20
- if (post.kind !== 'showcase') {
21
- throw new Error(`"${post.title}" is a ${post.kind} post — only showcase posts have episodes`);
22
- }
23
- const episodes = [...(post.episodes || [])]
24
- .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
25
- .map((e, i) => toDto(e, i));
26
- return { post, episodes };
27
- }
28
- /** The server re-sorts by `order`, re-indexes, and re-checks every vodVid with VOD. */
29
- function saveEpisodes(client, postId, episodes) {
30
- if (episodes.length > MAX_EPISODES)
31
- throw new Error(`A post can have at most ${MAX_EPISODES} episodes`);
32
- return client.put(`${POSTS}/list/${postId}`, { episodes: episodes.map((e, i) => ({ ...e, order: i })) });
33
- }
34
- /**
35
- * Turn `[file] / --vid / --asset` into an episode source. A local file goes to
36
- * Backreel's VOD space by default (signed, expiring play URLs); `--legacy`
37
- * uploads it as a plain WJI asset with a permanent public URL instead.
38
- */
39
- async function resolveSource(client, opts, postId, file) {
40
- const given = [file, opts.vid, opts.asset].filter(Boolean).length;
41
- if (given > 1)
42
- throw new Error('Give one video source: a file, --vid or --asset');
43
- if (opts.vid)
44
- return { vodVid: opts.vid };
45
- if (opts.asset)
46
- return { video: opts.asset };
47
- if (!file)
48
- return null;
49
- const ext = extname(file).toLowerCase();
50
- if (!VIDEO_EXTS.includes(ext))
51
- throw new Error(`${basename(file)}: episodes must be ${VIDEO_EXTS.join(' / ')}`);
52
- const size = (await stat(file)).size;
53
- if (opts.legacy) {
54
- if (size > ASSET_VIDEO_MAX_BYTES)
55
- throw new Error(`${basename(file)} is over 200 MB — drop --legacy to upload it to VOD`);
56
- info(color.dim(`Uploading ${basename(file)} as a WJI asset…`));
57
- const asset = await client.upload('/assets/v2/upload', file, {});
58
- return { video: asset.id };
59
- }
60
- const token = await client.post('/backreel/vod/upload-token');
61
- const vid = await uploadToVod(token, file, (step) => info(color.dim(`VOD: ${step}…`)));
62
- // claim checks the space, tags it with the post, publishes the media and starts transcoding.
63
- const claimed = await client.post('/backreel/vod/claim', { vid, postId, title: opts.title?.slice(0, 128) });
64
- info(color.dim(`VOD ${vid}: ${claimed?.status ?? 'claimed'}`));
65
- return { vodVid: vid };
66
- }
67
- function printEpisodes(post) {
68
- const rows = [...(post.episodes || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
69
- table(rows, [
70
- { header: '#', value: (e) => (e.order ?? 0) + 1 },
71
- { header: 'title', value: (e) => truncate(e.title, 32) },
72
- { header: 'tier', value: (e) => e.tier },
73
- { header: 'source', value: (e) => (e.vodVid ? `vod ${e.vodVid}` : e.video ? `asset ${idOf(e.video)}` : '-') },
74
- { header: 'status', value: (e) => (e.vodVid ? e.vodStatus || '?' : e.video ? 'ready' : '') },
75
- { header: 'duration', value: (e) => (e.durationSec ? `${Math.round(e.durationSec)}s` : '') },
76
- ]);
77
- }
78
- const sourceOptions = (cmd) => cmd
79
- .option('--vid <vid>', 'a Vid already in the Backreel VOD space')
80
- .option('--asset <assetId>', 'an uploaded WJI video asset (legacy path, public URL)')
81
- .option('--legacy', 'upload the file as a WJI asset (≤200 MB) instead of VOD');
82
- export function episodeCommand(globals) {
83
- const ep = new Command('episode')
84
- .aliases(['episodes', 'ep'])
85
- .description('Episodes of a showcase post: upload, reorder, gate by tier, check transcoding');
86
- ep.command('list <postId>', { isDefault: true })
87
- .description('List the episodes with their tier, source and VOD status')
88
- .action(async (postId) => {
89
- const client = backreelClient(globals);
90
- const post = await loadPost(client, postId);
91
- render(post.episodes || [], () => {
92
- if (!(post.episodes || []).length)
93
- return info(`"${post.title}" has no episodes yet`);
94
- printEpisodes(post);
95
- });
96
- });
97
- sourceOptions(ep.command('add <postId> [file]')
98
- .description('Add an episode: upload a local video to VOD, or use --vid / --asset')
99
- .requiredOption('-t, --title <title>', 'episode title')
100
- .option('--tier <tier>', `who can watch: ${TIERS.join(' | ')}`, 'public')
101
- .option('--at <n>', 'insert as episode n (default: last)')).action(async (postId, file, opts) => {
102
- const client = backreelClient(globals);
103
- const tier = oneOf(opts.tier, TIERS, '--tier');
104
- const { post, episodes } = await loadEpisodes(client, postId);
105
- if (episodes.length >= MAX_EPISODES)
106
- throw new Error(`"${post.title}" already has ${MAX_EPISODES} episodes`);
107
- const at = opts.at === undefined ? episodes.length : episodeIndex(opts.at, episodes.length + 1);
108
- const source = await resolveSource(client, opts, postId, file);
109
- if (!source)
110
- throw new Error('Give the video: a file, --vid <vid> or --asset <assetId>');
111
- episodes.splice(at, 0, { title: opts.title, order: at, tier, ...source });
112
- const updated = await saveEpisodes(client, postId, episodes);
113
- render(updated.episodes, () => {
114
- success(`Added episode ${at + 1} "${opts.title}" to "${updated.title}"`);
115
- printEpisodes(updated);
116
- if ('vodVid' in source)
117
- info(color.dim('VOD transcoding takes a few minutes — `wji backreel episode status` to check'));
118
- });
119
- });
120
- sourceOptions(ep.command('set <postId> <n> [file]')
121
- .description('Change episode n: title, tier, or replace its video')
122
- .option('-t, --title <title>', 'new title')
123
- .option('--tier <tier>', TIERS.join(' | '))).action(async (postId, n, file, opts) => {
124
- const client = backreelClient(globals);
125
- const { episodes } = await loadEpisodes(client, postId);
126
- const i = episodeIndex(n, episodes.length);
127
- const source = await resolveSource(client, { ...opts, title: opts.title || episodes[i].title }, postId, file);
128
- if (!opts.title && !opts.tier && !source)
129
- throw new Error('Nothing to change — pass --title, --tier or a new video');
130
- const next = { title: opts.title || episodes[i].title, order: i, tier: opts.tier ? oneOf(opts.tier, TIERS, '--tier') : episodes[i].tier };
131
- episodes[i] = source ? { ...next, ...source } : { ...episodes[i], ...next };
132
- const updated = await saveEpisodes(client, postId, episodes);
133
- render(updated.episodes, () => {
134
- success(`Updated episode ${i + 1}`);
135
- printEpisodes(updated);
136
- });
137
- });
138
- ep.command('rm <postId> <n...>')
139
- .description('Remove episodes (later ones move up)')
140
- .option('-y, --yes', 'skip confirmation')
141
- .action(async (postId, ns, opts) => {
142
- const client = backreelClient(globals);
143
- const { episodes } = await loadEpisodes(client, postId);
144
- const drop = new Set(ns.map((n) => episodeIndex(n, episodes.length)));
145
- const names = [...drop].sort((a, b) => a - b).map((i) => `${i + 1}. ${episodes[i].title}`).join(', ');
146
- if (!(await confirm(`Remove ${names}?`, opts.yes)))
147
- return info('Aborted');
148
- const updated = await saveEpisodes(client, postId, episodes.filter((_, i) => !drop.has(i)));
149
- render(updated.episodes, () => {
150
- success(`Removed ${drop.size} episode(s)`);
151
- printEpisodes(updated);
152
- });
153
- });
154
- ep.command('move <postId> <from> <to>')
155
- .description('Move episode <from> to position <to>')
156
- .action(async (postId, from, to) => {
157
- const client = backreelClient(globals);
158
- const { episodes } = await loadEpisodes(client, postId);
159
- const a = episodeIndex(from, episodes.length);
160
- const b = episodeIndex(to, episodes.length);
161
- const [moved] = episodes.splice(a, 1);
162
- episodes.splice(b, 0, moved);
163
- const updated = await saveEpisodes(client, postId, episodes);
164
- render(updated.episodes, () => {
165
- success(`Moved "${moved.title}" to episode ${b + 1}`);
166
- printEpisodes(updated);
167
- });
168
- });
169
- ep.command('tiers <postId>')
170
- .description('Tier ladder: public up to episode N, members up to M, the rest at --rest')
171
- .option('--public-to <n>', 'episodes 1..n are public', '0')
172
- .option('--member-to <n>', 'then up to episode n for signed-in members')
173
- .option('--rest <tier>', `everything after: ${TIERS.join(' | ')}`, 'supporter')
174
- .action(async (postId, opts) => {
175
- const client = backreelClient(globals);
176
- const { episodes } = await loadEpisodes(client, postId);
177
- const pub = Number(opts.publicTo);
178
- const mem = opts.memberTo === undefined ? pub : Number(opts.memberTo);
179
- if (!Number.isInteger(pub) || pub < 0 || !Number.isInteger(mem) || mem < pub) {
180
- throw new Error('--public-to and --member-to are episode numbers, with --member-to ≥ --public-to');
181
- }
182
- const rest = oneOf(opts.rest, TIERS, '--rest');
183
- episodes.forEach((e, i) => {
184
- e.tier = i < pub ? 'public' : i < mem ? 'member' : rest;
185
- });
186
- const updated = await saveEpisodes(client, postId, episodes);
187
- render(updated.episodes, () => {
188
- success('Episode tiers updated');
189
- printEpisodes(updated);
190
- });
191
- });
192
- ep.command('status <postId>')
193
- .description('Refresh VOD transcoding state (it only updates when someone asks to play)')
194
- .action(async (postId) => {
195
- const client = backreelClient(globals);
196
- const post = await loadPost(client, postId);
197
- const results = [];
198
- for (const e of [...(post.episodes || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))) {
199
- const row = { n: (e.order ?? 0) + 1, title: e.title, source: e.vodVid ? 'vod' : 'asset', status: 'ready', duration: e.durationSec };
200
- if (e.vodVid) {
201
- try {
202
- const play = await client.get(`${POSTS}/list/${postId}/episodes/${e.order}/play`);
203
- row.duration = play.durationSec ?? row.duration;
204
- }
205
- catch (err) {
206
- if (err instanceof ApiError && err.status === 409)
207
- row.status = err.body?.status || 'processing';
208
- else
209
- row.status = `error: ${err.message}`;
210
- }
211
- }
212
- results.push(row);
213
- }
214
- render(results, (rows) => {
215
- if (!rows.length)
216
- return info(`"${post.title}" has no episodes yet`);
217
- table(rows, [
218
- { header: '#', value: (r) => r.n },
219
- { header: 'title', value: (r) => truncate(r.title, 32) },
220
- { header: 'source', value: (r) => r.source },
221
- { header: 'status', value: (r) => (r.status === 'ready' ? color.green('ready') : color.yellow(r.status)) },
222
- { header: 'duration', value: (r) => (r.duration ? `${Math.round(r.duration)}s` : '') },
223
- ]);
224
- if (rows.some((r) => r.status === 'failed'))
225
- warn('A failed transcode needs a new upload: `episode set <post> <n> <file>`');
226
- });
227
- });
228
- ep.command('play <postId> <n>')
229
- .description('Print a short-lived play URL for episode n (as the owner)')
230
- .action(async (postId, n) => {
231
- const client = backreelClient(globals);
232
- const post = await loadPost(client, postId);
233
- const i = episodeIndex(n, (post.episodes || []).length);
234
- const order = [...post.episodes].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))[i].order ?? i;
235
- const play = await client.get(`${POSTS}/list/${postId}/episodes/${order}/play`);
236
- render(play, (p) => {
237
- process.stdout.write(`${p.url}\n`);
238
- if (p.expiresAt)
239
- info(color.dim(`expires ${p.expiresAt}`));
240
- });
241
- });
242
- return ep;
243
- }