@wji.ai/cli 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -2
- 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 +141 -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/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/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 +1 -1
- package/skill/wji/reference/commands.md +25 -5
|
@@ -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/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.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Crowdfunding, donations and supporters
|
|
2
|
+
|
|
3
|
+
## Two pieces that work together
|
|
4
|
+
|
|
5
|
+
| | Campaign | Donations |
|
|
6
|
+
| --- | --- | --- |
|
|
7
|
+
| on | crowdfunding posts only | any published post |
|
|
8
|
+
| holds | goal, offline total, deadline, offline supporter list | on/off, currency, minimum, tiers |
|
|
9
|
+
| set with | `br fund set --goal --offline-raised --deadline`, `br fund supporter …` | `br fund set --donations --currency --min`, `br fund tier …` |
|
|
10
|
+
|
|
11
|
+
The public progress bar = `--offline-raised` + online donations; the
|
|
12
|
+
supporter count = offline supporters + online donors.
|
|
13
|
+
|
|
14
|
+
## Setting up a campaign
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
wji br create "下一部長片" --kind crowdfunding -d @pitch.md --logline "…"
|
|
18
|
+
wji br fund set <postId> --goal 500000 --deadline 2026-12-31
|
|
19
|
+
wji br fund set <postId> --donations on --currency hkd --min 50
|
|
20
|
+
wji br fund tier add <postId> --title "早鳥支持" --amount 200 --perks "片尾鳴謝 + 數碼海報" --limit 300
|
|
21
|
+
wji br fund tier add <postId> --title "製片人" --amount 20000 --perks "聯合製片人名銜、探班一次" --limit 5
|
|
22
|
+
wji br check <postId>
|
|
23
|
+
wji br publish <postId> # ask first
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
- A crowdfunding post cannot publish with a goal below 1.
|
|
27
|
+
- Currencies: hkd, usd, twd, sgd, eur, gbp. **Locked after the first
|
|
28
|
+
donation** — choose before publishing.
|
|
29
|
+
- Amounts are whole numbers. Every tier must be ≥ the minimum (and ≥ 1);
|
|
30
|
+
≤10 tiers; `--limit` caps the slots (`--limit 0` on `tier set` = unlimited).
|
|
31
|
+
- The deadline stops donations at that moment; `--deadline none` removes it.
|
|
32
|
+
- A single donation is capped at 100,000.
|
|
33
|
+
|
|
34
|
+
## Designing tiers
|
|
35
|
+
|
|
36
|
+
- 3–5 tiers read best: an entry tier most people can afford, a "real
|
|
37
|
+
supporter" tier, one or two premium tiers with scarce perks (`--limit`).
|
|
38
|
+
- Perks should be concrete and deliverable: credit wording, a screening,
|
|
39
|
+
a set visit, early access. Supporter **episodes** are a perk too — tiers
|
|
40
|
+
set to `supporter` unlock for anyone who has donated (see
|
|
41
|
+
[episodes.md](episodes.md)).
|
|
42
|
+
- Changing a tier's amount does not affect past donations; they keep a
|
|
43
|
+
snapshot of the tier they bought.
|
|
44
|
+
|
|
45
|
+
## Offline supporters
|
|
46
|
+
|
|
47
|
+
People who paid by bank transfer, sponsorship etc. Add them so the wall and
|
|
48
|
+
count reflect reality, and raise the offline total separately:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
wji br fund supporter add <postId> --name "ABC Films" --amount 50000 --avatar <assetId>
|
|
52
|
+
wji br fund set <postId> --offline-raised 50000
|
|
53
|
+
wji br fund supporter list <postId>
|
|
54
|
+
wji br fund supporter rm <postId> 2 -y
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Adding a supporter does **not** change the raised amount — update
|
|
58
|
+
`--offline-raised` yourself (keep the two consistent). Avatars are square
|
|
59
|
+
images (`br image logo.png --slot photo`).
|
|
60
|
+
|
|
61
|
+
## Donation records
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
wji br fund donations <postId>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Shows raised / paid out / balance, then each donation: time, donor (anonymous
|
|
68
|
+
donors stay hidden even from the creator), amount, tier, status (`paid`,
|
|
69
|
+
`partially_refunded`, `refunded`, `failed`, `cancelled`; a red *disputed*
|
|
70
|
+
marks a card chargeback) and the donor's message. Payouts and
|
|
71
|
+
refunds are handled by Backreel staff — the creator asks them, the CLI cannot
|
|
72
|
+
do it.
|
|
73
|
+
|
|
74
|
+
## Showcase posts can take donations too
|
|
75
|
+
|
|
76
|
+
A showcase post (episodes) can turn donations on to sell `supporter` access
|
|
77
|
+
to later episodes — there is just no goal or progress bar:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
wji br fund set <postId> --donations on --currency hkd --min 30
|
|
81
|
+
wji br episode tiers <postId> --public-to 2 --rest supporter
|
|
82
|
+
```
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Enquiries
|
|
2
|
+
|
|
3
|
+
Anyone signed in can send an enquiry about a **published** post — to its
|
|
4
|
+
creator, or (for collaboration / new_project / general) to a credited person
|
|
5
|
+
with a linked account. The creator reads and answers them here.
|
|
6
|
+
|
|
7
|
+
## Types and what they carry
|
|
8
|
+
|
|
9
|
+
| type | meaning | extra fields shown by `enquiry get` |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| `collaboration` | work together on something | — |
|
|
12
|
+
| `new_project` | an offer to hire for a project | project type, title, budget range + currency, timeline, location, role needed |
|
|
13
|
+
| `investment` | wants to invest | ticket size + currency |
|
|
14
|
+
| `licensing` | wants to license the work | territory, media, term |
|
|
15
|
+
| `access_request` | wants partner access to gated material | access purpose; `sender access` shows their current tier |
|
|
16
|
+
| `general` | anything else | — |
|
|
17
|
+
|
|
18
|
+
Every enquiry has the sender's contact block (name, email, phone, company),
|
|
19
|
+
their verification badges (✓investor, ✓production_company …) and up to 5
|
|
20
|
+
private attachments (`enquiry download <fileId>` — the link lasts 60 s).
|
|
21
|
+
|
|
22
|
+
## Status flow
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
new ──reply──▶ in_discussion ──▶ accepted | declined | archived
|
|
26
|
+
(sender may withdraw while new / in_discussion; nothing moves after withdrawn)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- The recipient (you) sets `in_discussion`, `accepted`, `declined`,
|
|
30
|
+
`archived`. Replying to a `new` enquiry moves it to `in_discussion`.
|
|
31
|
+
- `archived` hides it from the inbox (`enquiry list --archived` shows it); a
|
|
32
|
+
reply from the sender brings it back.
|
|
33
|
+
- `accepted` on an **access_request** grants partner access immediately.
|
|
34
|
+
- `enquiry grant <id>` grants partner access from any enquiry you received as
|
|
35
|
+
the creator, and marks an open one accepted. It is offered only while the
|
|
36
|
+
sender has no partner access (`can` line of `enquiry get`).
|
|
37
|
+
- Accept / decline notify the sender in-app and by email.
|
|
38
|
+
|
|
39
|
+
## Triage routine
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
wji br enquiry unread
|
|
43
|
+
wji br enquiry list --unread
|
|
44
|
+
wji br enquiry get <id> # read everything, including attachments
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Summarise each for the user: who (name, company, verification), what they
|
|
48
|
+
want, the money / dates involved, and what the creator would need to decide.
|
|
49
|
+
Do **not** decide for them — accepting, declining and granting access are the
|
|
50
|
+
creator's calls.
|
|
51
|
+
|
|
52
|
+
## Replying
|
|
53
|
+
|
|
54
|
+
Replies are ≤4000 characters, go to the other side in-app immediately and by
|
|
55
|
+
email at most every 30 minutes. Draft the reply, show it, send only after the
|
|
56
|
+
user agrees:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
wji br enquiry reply <id> @reply.md -a pitch-deck.pdf
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Attachments: documents (PDF, DOCX, XLSX, PPTX, CSV) and images, ≤20 MB each,
|
|
63
|
+
≤5 per message.
|
|
64
|
+
|
|
65
|
+
Patterns that work:
|
|
66
|
+
|
|
67
|
+
- **Acknowledge + next step**: thank them, restate what they asked in one
|
|
68
|
+
line, say what happens next and when ("I'll send the budget breakdown by
|
|
69
|
+
Friday").
|
|
70
|
+
- **Need more**: ask the 2–3 specific questions you need (budget, schedule,
|
|
71
|
+
rights territory) instead of a vague "tell me more".
|
|
72
|
+
- **Decline kindly**: one line of thanks, one line of reason if the user wants
|
|
73
|
+
to give one, leave the door open — then `enquiry status <id> declined`.
|
|
74
|
+
- Match the sender's language (Cantonese / Mandarin / English).
|
|
75
|
+
|
|
76
|
+
## Limits worth knowing
|
|
77
|
+
|
|
78
|
+
Senders can have one open enquiry of the same type per post, 3 per post per
|
|
79
|
+
day and 20 per day overall — repeated spam beyond that is blocked by the
|
|
80
|
+
server. Blocking and reporting a sender are done on the web.
|