@wji.ai/cli 0.9.0 → 0.9.2

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 (70) hide show
  1. package/README.md +7 -1
  2. package/dist/index.js +156 -11
  3. package/package.json +1 -1
  4. package/skill/backreel/SKILL.md +6 -2
  5. package/skill/backreel/reference/episodes.md +45 -7
  6. package/skill/short-drama/SKILL.md +27 -4
  7. package/skill/short-drama/examples/night-shift/episodes/E01.json +173 -1
  8. package/skill/short-drama/examples/night-shift/project.json +6 -0
  9. package/skill/short-drama/reference/dialogue-craft.md +16 -11
  10. package/skill/short-drama/reference/prompt-audit.mjs +308 -0
  11. package/skill/short-drama/reference/reference-binding.md +84 -0
  12. package/skill/short-drama/reference/schema.md +14 -5
  13. package/skill/short-drama/reference/stage-characters.md +1 -1
  14. package/skill/short-drama/reference/stage-script.md +54 -7
  15. package/skill/short-drama/reference/stage-storyboard.md +29 -24
  16. package/skill/short-drama/scripts/selftest.mjs +154 -9
  17. package/skill/short-drama/scripts/short-drama.mjs +467 -23
  18. package/skill/w-film-dialogue-editor/CHANGELOG.md +44 -0
  19. package/skill/w-film-dialogue-editor/SKILL.md +124 -0
  20. package/skill/w-film-dialogue-editor/references/data/README.md +222 -0
  21. package/skill/w-film-dialogue-editor/references/data/en/core.json +1666 -0
  22. package/skill/w-film-dialogue-editor/references/data/en/slang.json +1602 -0
  23. package/skill/w-film-dialogue-editor/references/data/en/work.json +1722 -0
  24. package/skill/w-film-dialogue-editor/references/data/en-sentences.csv +134 -0
  25. package/skill/w-film-dialogue-editor/references/data/en-sentences.md +146 -0
  26. package/skill/w-film-dialogue-editor/references/data/ja/core.json +1978 -0
  27. package/skill/w-film-dialogue-editor/references/data/ja/slang.json +1927 -0
  28. package/skill/w-film-dialogue-editor/references/data/ja/work.json +1972 -0
  29. package/skill/w-film-dialogue-editor/references/data/ja-sentences.csv +134 -0
  30. package/skill/w-film-dialogue-editor/references/data/ja-sentences.md +146 -0
  31. package/skill/w-film-dialogue-editor/references/data/ko/core.json +1839 -0
  32. package/skill/w-film-dialogue-editor/references/data/ko/slang.json +1897 -0
  33. package/skill/w-film-dialogue-editor/references/data/ko/work.json +1829 -0
  34. package/skill/w-film-dialogue-editor/references/data/ko-sentences.csv +134 -0
  35. package/skill/w-film-dialogue-editor/references/data/ko-sentences.md +146 -0
  36. package/skill/w-film-dialogue-editor/references/data/zh-cn/core.json +1794 -0
  37. package/skill/w-film-dialogue-editor/references/data/zh-cn/slang.json +1609 -0
  38. package/skill/w-film-dialogue-editor/references/data/zh-cn/work.json +1648 -0
  39. package/skill/w-film-dialogue-editor/references/data/zh-cn-sentences.csv +134 -0
  40. package/skill/w-film-dialogue-editor/references/data/zh-cn-sentences.md +146 -0
  41. package/skill/w-film-dialogue-editor/references/data/zh-hk/core.json +2476 -0
  42. package/skill/w-film-dialogue-editor/references/data/zh-hk/gen-z.json +3263 -0
  43. package/skill/w-film-dialogue-editor/references/data/zh-hk/older.json +3632 -0
  44. package/skill/w-film-dialogue-editor/references/data/zh-hk/rough.json +1668 -0
  45. package/skill/w-film-dialogue-editor/references/data/zh-hk/workplace.json +3697 -0
  46. package/skill/w-film-dialogue-editor/references/data/zh-hk-sentences.csv +373 -0
  47. package/skill/w-film-dialogue-editor/references/data/zh-hk-sentences.md +391 -0
  48. package/skill/w-film-dialogue-editor/references/data/zh-tw/core.json +1640 -0
  49. package/skill/w-film-dialogue-editor/references/data/zh-tw/slang.json +1537 -0
  50. package/skill/w-film-dialogue-editor/references/data/zh-tw/work.json +1699 -0
  51. package/skill/w-film-dialogue-editor/references/data/zh-tw-sentences.csv +132 -0
  52. package/skill/w-film-dialogue-editor/references/data/zh-tw-sentences.md +144 -0
  53. package/skill/w-film-dialogue-editor/references/dialogue_revision_framework.md +84 -0
  54. package/skill/w-film-dialogue-editor/references/en-index.md +265 -0
  55. package/skill/w-film-dialogue-editor/references/ja-index.md +633 -0
  56. package/skill/w-film-dialogue-editor/references/ko-index.md +498 -0
  57. package/skill/w-film-dialogue-editor/references/lang-en.md +242 -0
  58. package/skill/w-film-dialogue-editor/references/lang-ja.md +312 -0
  59. package/skill/w-film-dialogue-editor/references/lang-ko.md +290 -0
  60. package/skill/w-film-dialogue-editor/references/lang-zh-cn.md +60 -0
  61. package/skill/w-film-dialogue-editor/references/lang-zh-hk-mandarin.md +34 -0
  62. package/skill/w-film-dialogue-editor/references/lang-zh-hk.md +306 -0
  63. package/skill/w-film-dialogue-editor/references/lang-zh-period.md +38 -0
  64. package/skill/w-film-dialogue-editor/references/lang-zh-tw.md +57 -0
  65. package/skill/w-film-dialogue-editor/references/zh-cn-index.md +636 -0
  66. package/skill/w-film-dialogue-editor/references/zh-hk-index.md +1450 -0
  67. package/skill/w-film-dialogue-editor/references/zh-tw-index.md +457 -0
  68. package/skill/w-film-dialogue-editor/scripts/rebuild.sh +13 -0
  69. package/skill/w-film-dialogue-editor/scripts/ref.mjs +539 -0
  70. package/skill/w-film-dialogue-editor/templates/dialogue_revision_report.md +43 -0
package/README.md CHANGED
@@ -215,6 +215,7 @@ wji backreel create "Harbour" -d @synopsis.txt --cover <assetId>
215
215
  wji backreel credit add <postId> --npc @mika --role "飾 阿明" --job appearance
216
216
  wji backreel image poster.png --slot portrait --post <postId> # size check + upload + set
217
217
  wji backreel episode add <postId> E01.mp4 -t "第一集" # VOD upload
218
+ wji backreel episode subs add <postId> 1 E01.zh-Hant.srt # subtitles, one per language
218
219
  wji backreel check <postId>
219
220
  wji backreel publish <postId>
220
221
  wji backreel enquiry list --unread
@@ -310,10 +311,15 @@ generations, banner sizes, AI-disclosure compliance); `social-media` holds
310
311
  every platform's image / video specs (Instagram, TikTok, YouTube, Facebook, X,
311
312
  LinkedIn, Pinterest, Threads, Snapchat, 小红书, 抖音, 快手, 视频号, B站, 微博,
312
313
  公众号) and the repurposing recipes — an episode into 9:16 teasers and posters,
313
- an ad master into every placement. Install them the same way:
314
+ an ad master into every placement. `skill/w-film-dialogue-editor/` is the
315
+ dialogue skill short-drama reviews every script with: a guide per 语体 (粤语,
316
+ 大陆普通话, 台湾国语, 港式普通话, 古装/年代, English, 韩文, 日文) and a corpus of
317
+ real usage queried with its `scripts/ref.mjs`; short-drama finds it when it is
318
+ installed beside it. Install them the same way:
314
319
 
315
320
  ```bash
316
321
  cp -r "$(npm root -g)/@wji.ai/cli/skill/short-drama" ~/.claude/skills/short-drama
322
+ cp -r "$(npm root -g)/@wji.ai/cli/skill/w-film-dialogue-editor" ~/.claude/skills/w-film-dialogue-editor
317
323
  cp -r "$(npm root -g)/@wji.ai/cli/skill/ads" ~/.claude/skills/ads
318
324
  cp -r "$(npm root -g)/@wji.ai/cli/skill/social-media" ~/.claude/skills/social-media
319
325
  ```
package/dist/index.js CHANGED
@@ -281,6 +281,8 @@ var MIME_TYPES = {
281
281
  mp3: "audio/mpeg",
282
282
  wav: "audio/wav",
283
283
  m4a: "audio/mp4",
284
+ srt: "application/x-subrip",
285
+ vtt: "text/vtt",
284
286
  pdf: "application/pdf",
285
287
  csv: "text/csv",
286
288
  txt: "text/plain",
@@ -6347,9 +6349,10 @@ async function uploadToVod(token, file, onProgress) {
6347
6349
  // src/commands/backreel-episode.ts
6348
6350
  var MAX_EPISODES = 30;
6349
6351
  var VIDEO_EXTS = [".mp4", ".mov", ".m4v", ".webm"];
6350
- var ASSET_VIDEO_MAX_BYTES = 200 * 1024 * 1024;
6352
+ var VIDEO_MAX_BYTES = 500 * 1024 * 1024;
6353
+ var subtitlesOf = (e) => (e.subtitles || []).map((s) => ({ id: s.id, lang: s.lang, ...s.label ? { label: s.label } : {} }));
6351
6354
  function toDto(e, order) {
6352
- const base = { title: e.title, order, tier: e.tier || "public" };
6355
+ const base = { title: e.title, order, tier: e.tier || "public", subtitles: subtitlesOf(e) };
6353
6356
  return e.vodVid ? { ...base, vodVid: e.vodVid } : { ...base, video: idOf2(e.video) };
6354
6357
  }
6355
6358
  async function loadEpisodes(client, postId) {
@@ -6373,8 +6376,8 @@ async function resolveSource(client, opts, postId, file) {
6373
6376
  const ext = extname2(file).toLowerCase();
6374
6377
  if (!VIDEO_EXTS.includes(ext)) throw new Error(`${basename6(file)}: episodes must be ${VIDEO_EXTS.join(" / ")}`);
6375
6378
  const size = (await stat5(file)).size;
6379
+ if (size > VIDEO_MAX_BYTES) throw new Error(`${basename6(file)} is over 500 MB`);
6376
6380
  if (opts.legacy) {
6377
- if (size > ASSET_VIDEO_MAX_BYTES) throw new Error(`${basename6(file)} is over 200 MB \u2014 drop --legacy to upload it to VOD`);
6378
6381
  info(color.dim(`Uploading ${basename6(file)} as a WJI asset\u2026`));
6379
6382
  const asset = await client.upload("/assets/v2/upload", file, {});
6380
6383
  return { video: asset.id };
@@ -6393,10 +6396,63 @@ function printEpisodes(post) {
6393
6396
  { header: "tier", value: (e) => e.tier },
6394
6397
  { header: "source", value: (e) => e.vodVid ? `vod ${e.vodVid}` : e.video ? `asset ${idOf2(e.video)}` : "-" },
6395
6398
  { header: "status", value: (e) => e.vodVid ? e.vodStatus || "?" : e.video ? "ready" : "" },
6396
- { header: "duration", value: (e) => e.durationSec ? `${Math.round(e.durationSec)}s` : "" }
6399
+ { header: "duration", value: (e) => e.durationSec ? `${Math.round(e.durationSec)}s` : "" },
6400
+ { header: "subs", value: (e) => (e.subtitles || []).map((s) => s.lang).join(" ") }
6397
6401
  ]);
6398
6402
  }
6399
- var sourceOptions = (cmd) => cmd.option("--vid <vid>", "a Vid already in the Backreel VOD space").option("--asset <assetId>", "an uploaded WJI video asset (legacy path, public URL)").option("--legacy", "upload the file as a WJI asset (\u2264200 MB) instead of VOD");
6403
+ var MAX_SUBTITLES = 8;
6404
+ var SUBTITLE_EXTS = [".srt", ".vtt"];
6405
+ var LANG_RE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8}){0,3}$/;
6406
+ function guessSubtitleLang(fileName) {
6407
+ const padded = `.${basename6(fileName).toLowerCase().replace(/\.(srt|vtt)$/, "")}.`;
6408
+ const hints = [
6409
+ [/(zh[-_]?(hant|tw|hk)|繁|cht)/, "zh-Hant"],
6410
+ [/(zh[-_]?(hans|cn)|简|chs)/, "zh-Hans"],
6411
+ [/(yue|粵|粤)/, "yue"],
6412
+ [/[._ -](en|eng|english)[._ -]/, "en"],
6413
+ [/[._ -](ja|jp|jpn)[._ -]/, "ja"],
6414
+ [/[._ -](ko|kr|kor)[._ -]/, "ko"],
6415
+ [/[._ -](th|tha)[._ -]/, "th"],
6416
+ [/[._ -](vi|vie)[._ -]/, "vi"]
6417
+ ];
6418
+ return hints.find(([re]) => re.test(padded))?.[1];
6419
+ }
6420
+ function printSubtitles(episode) {
6421
+ const subs = episode.subtitles || [];
6422
+ if (!subs.length) return info(`Episode ${(episode.order ?? 0) + 1} has no subtitles`);
6423
+ table(subs, [
6424
+ { header: "lang", value: (s) => s.lang },
6425
+ { header: "label", value: (s) => s.label || "" },
6426
+ { header: "default", value: (s) => s === subs[0] ? color.green("\u2605") : "" },
6427
+ { header: "id", value: (s) => color.dim(s.id) }
6428
+ ]);
6429
+ }
6430
+ async function saveSubtitles(client, postId, episodes, i, message) {
6431
+ const updated = await saveEpisodes(client, postId, episodes);
6432
+ const episode = [...updated.episodes || []].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))[i];
6433
+ render(episode?.subtitles || [], () => {
6434
+ success(message);
6435
+ printSubtitles(episode);
6436
+ });
6437
+ }
6438
+ function findTrack(subs, lang, i) {
6439
+ const at = subs.findIndex((s) => s.lang.toLowerCase() === lang.toLowerCase());
6440
+ if (at < 0) {
6441
+ const have = subs.map((s) => s.lang).join(", ") || "none";
6442
+ throw new Error(`Episode ${i + 1} has no ${lang} subtitles (has: ${have})`);
6443
+ }
6444
+ return at;
6445
+ }
6446
+ function pickRendition(renditions, want) {
6447
+ const w = want.toLowerCase();
6448
+ const hit = renditions.find((r) => String(r.definition || "").toLowerCase() === w) || renditions.find((r) => r.height && String(r.height) === w.replace(/p$/, ""));
6449
+ if (!hit) {
6450
+ const have = renditions.map((r) => r.definition || `${r.height}p`).join(", ") || "only the default";
6451
+ throw new Error(`No ${want} rendition (available: ${have})`);
6452
+ }
6453
+ return hit;
6454
+ }
6455
+ var sourceOptions = (cmd) => cmd.option("--vid <vid>", "a Vid already in the Backreel VOD space").option("--asset <assetId>", "an uploaded WJI video asset (legacy path, public URL)").option("--legacy", "upload the file as a WJI asset (\u2264500 MB) instead of VOD");
6400
6456
  function episodeCommand(globals2) {
6401
6457
  const ep = new Command19("episode").aliases(["episodes", "ep"]).description("Episodes of a showcase post: upload, reorder, gate by tier, check transcoding");
6402
6458
  ep.command("list <postId>", { isDefault: true }).description("List the episodes with their tier, source and VOD status").action(async (postId) => {
@@ -6417,7 +6473,7 @@ function episodeCommand(globals2) {
6417
6473
  const at = opts.at === void 0 ? episodes.length : episodeIndex(opts.at, episodes.length + 1);
6418
6474
  const source = await resolveSource(client, opts, postId, file);
6419
6475
  if (!source) throw new Error("Give the video: a file, --vid <vid> or --asset <assetId>");
6420
- episodes.splice(at, 0, { title: opts.title, order: at, tier, ...source });
6476
+ episodes.splice(at, 0, { title: opts.title, order: at, tier, subtitles: [], ...source });
6421
6477
  const updated = await saveEpisodes(client, postId, episodes);
6422
6478
  render(updated.episodes, () => {
6423
6479
  success(`Added episode ${at + 1} "${opts.title}" to "${updated.title}"`);
@@ -6433,12 +6489,21 @@ function episodeCommand(globals2) {
6433
6489
  const i = episodeIndex(n, episodes.length);
6434
6490
  const source = await resolveSource(client, { ...opts, title: opts.title || episodes[i].title }, postId, file);
6435
6491
  if (!opts.title && !opts.tier && !source) throw new Error("Nothing to change \u2014 pass --title, --tier or a new video");
6436
- const next = { title: opts.title || episodes[i].title, order: i, tier: opts.tier ? oneOf2(opts.tier, TIERS, "--tier") : episodes[i].tier };
6492
+ const next = {
6493
+ title: opts.title || episodes[i].title,
6494
+ order: i,
6495
+ tier: opts.tier ? oneOf2(opts.tier, TIERS, "--tier") : episodes[i].tier,
6496
+ // a new cut of the same episode usually keeps its timing, so the tracks stay
6497
+ subtitles: episodes[i].subtitles
6498
+ };
6437
6499
  episodes[i] = source ? { ...next, ...source } : { ...episodes[i], ...next };
6438
6500
  const updated = await saveEpisodes(client, postId, episodes);
6439
6501
  render(updated.episodes, () => {
6440
6502
  success(`Updated episode ${i + 1}`);
6441
6503
  printEpisodes(updated);
6504
+ if (source && next.subtitles.length) {
6505
+ info(color.dim(`Kept ${next.subtitles.length} subtitle track(s) \u2014 \`wji br episode subs rm\` any that no longer line up`));
6506
+ }
6442
6507
  });
6443
6508
  });
6444
6509
  ep.command("rm <postId> <n...>").description("Remove episodes (later ones move up)").option("-y, --yes", "skip confirmation").action(async (postId, ns, opts) => {
@@ -6513,18 +6578,99 @@ function episodeCommand(globals2) {
6513
6578
  if (rows.some((r) => r.status === "failed")) warn("A failed transcode needs a new upload: `episode set <post> <n> <file>`");
6514
6579
  });
6515
6580
  });
6516
- ep.command("play <postId> <n>").description("Print a short-lived play URL for episode n (as the owner)").action(async (postId, n) => {
6581
+ ep.command("play <postId> <n>").description("Print a short-lived play URL for episode n (as the owner) \u2014 the best quality unless --quality").option("-q, --quality <q>", "a rendition such as 720p or 480p").option("--qualities", "list every rendition instead of printing one URL").action(async (postId, n, opts) => {
6517
6582
  const client = backreelClient(globals2);
6518
6583
  const post = await loadPost(client, postId);
6519
6584
  const i = episodeIndex(n, (post.episodes || []).length);
6520
6585
  const order = [...post.episodes].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))[i].order ?? i;
6521
6586
  const play = await client.get(`${POSTS}/list/${postId}/episodes/${order}/play`);
6522
- render(play, (p) => {
6587
+ const renditions = play.renditions || [];
6588
+ if (opts.qualities) {
6589
+ return render(renditions, (rows) => {
6590
+ if (!rows.length) return info("Only one version (legacy asset, or VOD returned no renditions)");
6591
+ table(rows, [
6592
+ { header: "quality", value: (r) => r.definition || "" },
6593
+ { header: "size", value: (r) => r.width && r.height ? `${r.width}\xD7${r.height}` : "" },
6594
+ { header: "bitrate", value: (r) => r.bitrate ? `${Math.round(r.bitrate / 1e3)} kbps` : "" }
6595
+ ]);
6596
+ if (play.expiresAt) info(color.dim(`URLs expire ${play.expiresAt} \u2014 --json to get them`));
6597
+ });
6598
+ }
6599
+ const url = opts.quality ? pickRendition(renditions, opts.quality).url : play.url;
6600
+ render({ ...play, url }, (p) => {
6523
6601
  process.stdout.write(`${p.url}
6524
6602
  `);
6525
6603
  if (p.expiresAt) info(color.dim(`expires ${p.expiresAt}`));
6526
6604
  });
6527
6605
  });
6606
+ const subs = ep.command("subs").aliases(["subtitles", "sub"]).description("Subtitle tracks of an episode: .srt / .vtt, one per language, \u22648, the first is the default");
6607
+ subs.command("list <postId> <n>", { isDefault: true }).description("List episode n's subtitle tracks").action(async (postId, n) => {
6608
+ const client = backreelClient(globals2);
6609
+ const post = await loadPost(client, postId);
6610
+ const i = episodeIndex(n, (post.episodes || []).length);
6611
+ const episode = [...post.episodes].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))[i];
6612
+ render(episode.subtitles || [], () => printSubtitles(episode));
6613
+ });
6614
+ subs.command("add <postId> <n> <file>").description("Upload a .srt / .vtt and attach it to episode n").option("-l, --lang <lang>", "BCP 47 language: en, ja, ko, zh-Hant, zh-Hans, yue\u2026 (guessed from the file name if omitted)").option("--label <label>", "name shown in the player (default: the language's own name)").option("--default", "make it the default track (first in the list)").option("--replace", "replace the episode's existing track in that language").action(async (postId, n, file, opts) => {
6615
+ const client = backreelClient(globals2);
6616
+ const ext = extname2(file).toLowerCase();
6617
+ if (!SUBTITLE_EXTS.includes(ext)) throw new Error(`${basename6(file)}: subtitles must be .srt or .vtt`);
6618
+ if ((await stat5(file)).size > 1024 * 1024) throw new Error(`${basename6(file)} is over 1 MB`);
6619
+ const lang = opts.lang || guessSubtitleLang(file);
6620
+ if (!lang) throw new Error(`Can't tell the language from "${basename6(file)}" \u2014 pass --lang (e.g. en, zh-Hant)`);
6621
+ if (!LANG_RE.test(lang)) throw new Error(`--lang ${lang} isn't a language code like en, ja or zh-Hant`);
6622
+ const label = opts.label ? String(opts.label).trim() : void 0;
6623
+ if (label && label.length > 40) throw new Error("--label is at most 40 characters");
6624
+ const { post, episodes } = await loadEpisodes(client, postId);
6625
+ const i = episodeIndex(n, episodes.length);
6626
+ const duration = [...post.episodes].sort((x, y) => (x.order ?? 0) - (y.order ?? 0))[i]?.durationSec;
6627
+ const list = [...episodes[i].subtitles];
6628
+ const existing = list.findIndex((s) => s.lang.toLowerCase() === lang.toLowerCase());
6629
+ if (existing >= 0 && !opts.replace) {
6630
+ throw new Error(`Episode ${i + 1} already has ${lang} subtitles \u2014 add --replace to swap them`);
6631
+ }
6632
+ if (existing < 0 && list.length >= MAX_SUBTITLES) {
6633
+ throw new Error(`Episode ${i + 1} already has ${MAX_SUBTITLES} subtitle tracks`);
6634
+ }
6635
+ const uploaded = await client.upload("/backreel/subtitle/upload", file, {});
6636
+ const keptLabel = label ?? (existing >= 0 ? list[existing].label : void 0);
6637
+ const track = { id: uploaded.id, lang, ...keptLabel ? { label: keptLabel } : {} };
6638
+ if (existing >= 0) list.splice(existing, 1, track);
6639
+ else list.push(track);
6640
+ if (opts.default) list.unshift(...list.splice(list.indexOf(track), 1));
6641
+ episodes[i].subtitles = list;
6642
+ await saveSubtitles(client, postId, episodes, i, `${existing >= 0 ? "Replaced" : "Added"} ${lang} subtitles on episode ${i + 1} (${uploaded.cueCount} lines)`);
6643
+ if (duration && uploaded.lastCueSec > duration + 2) {
6644
+ warn(`The last line is at ${Math.round(uploaded.lastCueSec)}s but the episode is ${Math.round(duration)}s \u2014 is this the right file?`);
6645
+ }
6646
+ });
6647
+ subs.command("rm <postId> <n> <lang...>").description("Remove episode n's tracks in these languages").action(async (postId, n, langs) => {
6648
+ const client = backreelClient(globals2);
6649
+ const { episodes } = await loadEpisodes(client, postId);
6650
+ const i = episodeIndex(n, episodes.length);
6651
+ const drop = new Set(langs.map((l) => findTrack(episodes[i].subtitles, l, i)));
6652
+ episodes[i].subtitles = episodes[i].subtitles.filter((_, at) => !drop.has(at));
6653
+ await saveSubtitles(client, postId, episodes, i, `Removed ${langs.join(", ")} from episode ${i + 1}`);
6654
+ });
6655
+ subs.command("default <postId> <n> <lang>").description("Make a track the default (moves it first)").action(async (postId, n, lang) => {
6656
+ const client = backreelClient(globals2);
6657
+ const { episodes } = await loadEpisodes(client, postId);
6658
+ const i = episodeIndex(n, episodes.length);
6659
+ const list = [...episodes[i].subtitles];
6660
+ list.unshift(...list.splice(findTrack(list, lang, i), 1));
6661
+ episodes[i].subtitles = list;
6662
+ await saveSubtitles(client, postId, episodes, i, `${list[0].lang} is now episode ${i + 1}'s default`);
6663
+ });
6664
+ subs.command("label <postId> <n> <lang> [label]").description("Set the name the player shows for a track (omit it to go back to the language name)").action(async (postId, n, lang, label) => {
6665
+ const client = backreelClient(globals2);
6666
+ if (label && label.trim().length > 40) throw new Error("A label is at most 40 characters");
6667
+ const { episodes } = await loadEpisodes(client, postId);
6668
+ const i = episodeIndex(n, episodes.length);
6669
+ const at = findTrack(episodes[i].subtitles, lang, i);
6670
+ const { label: _old, ...rest } = episodes[i].subtitles[at];
6671
+ episodes[i].subtitles[at] = label?.trim() ? { ...rest, label: label.trim() } : rest;
6672
+ await saveSubtitles(client, postId, episodes, i, `Updated the ${rest.lang} label on episode ${i + 1}`);
6673
+ });
6528
6674
  return ep;
6529
6675
  }
6530
6676
 
@@ -7846,7 +7992,6 @@ function backreelCommand(globals2) {
7846
7992
  if (!(p.credits || []).length) advice.push("no credits");
7847
7993
  const pending = episodes.filter((e) => e.vodVid && e.vodStatus !== "ready");
7848
7994
  if (pending.length) advice.push(`${pending.length} episode(s) not ready on VOD (${pending.map((e) => `#${(e.order ?? 0) + 1} ${e.vodStatus || "?"}`).join(", ")}) \u2014 \`backreel episode status\``);
7849
- if (p.trailerDurationSec && p.trailerDurationSec > 31) advice.push(`trailer is ${p.trailerDurationSec}s; the limit is 30s`);
7850
7995
  if (p.donation?.enabled && p.kind === "crowdfunding" && p.campaign?.deadline && new Date(p.campaign.deadline) < /* @__PURE__ */ new Date()) {
7851
7996
  advice.push("the campaign deadline has passed \u2014 donations are closed");
7852
7997
  }
@@ -7924,7 +8069,7 @@ function backreelCommand(globals2) {
7924
8069
  return br;
7925
8070
  }
7926
8071
  function postOptions(cmd) {
7927
- return cmd.option("--kind <kind>", KINDS.join(" | ")).option("--logline <text>", `one-line hook, max ${LOGLINE_MAX} chars; public even when the description is locked ("" clears it)`).option("--category <category>", "category").option("--project-status <status>", PROJECT_STATUSES.join(" | ")).option("--tags <tags>", "comma-separated tags (replaces the list)").option("-d, --description <text>", "description, plain text or HTML (@file / - supported)").option("--cover <assetId>", "landscape cover asset").option("--cover-portrait <assetId>", "portrait cover asset").option("--trailer <assetId>", "trailer video asset (\u226430 s)").option("--media <assetId...>", `LookBook gallery, images or videos in order (replaces it; \u2264${MEDIA_MAX}; "none" clears)`).option("--org <orgId|none>", "publish as one of your organizations; none = yourself (only the author can change it later)").option("--body <json>", "any other post fields as JSON (@file / - supported); flags win");
8072
+ return cmd.option("--kind <kind>", KINDS.join(" | ")).option("--logline <text>", `one-line hook, max ${LOGLINE_MAX} chars; public even when the description is locked ("" clears it)`).option("--category <category>", "category").option("--project-status <status>", PROJECT_STATUSES.join(" | ")).option("--tags <tags>", "comma-separated tags (replaces the list)").option("-d, --description <text>", "description, plain text or HTML (@file / - supported)").option("--cover <assetId>", "landscape cover asset").option("--cover-portrait <assetId>", "portrait cover asset").option("--trailer <assetId>", "trailer video asset (\u2264500 MB, any length)").option("--media <assetId...>", `LookBook gallery, images or videos in order (replaces it; \u2264${MEDIA_MAX}; "none" clears)`).option("--org <orgId|none>", "publish as one of your organizations; none = yourself (only the author can change it later)").option("--body <json>", "any other post fields as JSON (@file / - supported); flags win");
7928
8073
  }
7929
8074
  async function postBody(opts) {
7930
8075
  const body = { ...await readJson(opts.body) || {} };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wji.ai/cli",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Command line interface for the WJI AI platform",
5
5
  "license": "UNLICENSED",
6
6
  "author": "WJI AI",
@@ -26,7 +26,7 @@ wji br list # your posts, drafts included
26
26
  | kind | `showcase` (episodes, trailer) or `crowdfunding` (goal, supporters) — switching wipes the other side's data | `br create --kind`, `br set --kind` |
27
27
  | title / logline / description | logline = one public line ≤200 chars; description = HTML or plain text | `br set --logline`, `-d @file.md` |
28
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` |
29
+ | trailer | a video asset, ≤500 MB, any length | `wji asset upload` → `br set --trailer` |
30
30
  | episodes | ≤30, each with its own viewer tier, hosted on VOD | `br episode …` |
31
31
  | credits | cast / crew / creative, ≤30, may link a WJI user or an NPC | `br credit …` |
32
32
  | campaign + donations | goal, offline total, deadline, donation tiers, offline supporters | `br fund …` |
@@ -97,6 +97,10 @@ wji br episode set <postId> 3 E03_v2.mp4 # replace the video
97
97
  wji br episode move <postId> 4 2
98
98
  wji br episode rm <postId> 6 -y
99
99
  wji br episode play <postId> 1 # short-lived URL to watch it yourself
100
+ wji br episode play <postId> 1 --qualities # renditions VOD made (1080p / 720p / …)
101
+ wji br episode subs add <postId> 1 E01.zh-Hant.srt # language guessed from the name
102
+ wji br episode subs add <postId> 1 E01_en.vtt --lang en --default
103
+ wji br episode subs <postId> 1 # list tracks; the first is the default
100
104
  ```
101
105
 
102
106
  Episodes exist on **showcase** posts only (≤30). A local file goes straight
@@ -105,7 +109,7 @@ nothing on the page can be copied and shared. Transcoding takes a few minutes;
105
109
  an episode stays `processing` until then and viewers get "still
106
110
  transcoding". Numbers on the command line are 1-based positions. Read
107
111
  [reference/episodes.md](reference/episodes.md) for the trailer, the legacy
108
- asset path, tier strategy and failure handling.
112
+ asset path, tier strategy, subtitles and failure handling.
109
113
 
110
114
  ## 4. Crowdfunding and donations
111
115
 
@@ -6,19 +6,20 @@
6
6
  | --- | --- | --- |
7
7
  | posts | any | **showcase only** (switching to crowdfunding drops them) |
8
8
  | count | one | ≤30 |
9
- | length | ≤30 s (server allows +1 s) | no limit |
9
+ | length | no limit | no limit |
10
+ | size | ≤500 MB | ≤500 MB |
10
11
  | storage | WJI asset (public URL) | BytePlus VOD (signed URL per play, ~5 min) |
11
12
  | gated | no — always public | per episode: public / member / supporter / partner |
12
13
 
13
14
  ### Trailer
14
15
 
15
16
  ```bash
16
- wji asset upload trailer.mp4 --json # ≤200 MB; duration is probed
17
+ wji asset upload trailer.mp4 --json # ≤500 MB, any length
17
18
  wji br set <postId> --trailer <assetId>
18
19
  ```
19
20
 
20
- The server rejects a trailer over 31 s when it knows the duration. Cut it to
21
- ≤30 s first.
21
+ There is no duration cap. The upload is rejected only when the file is over
22
+ 500 MB.
22
23
 
23
24
  ## Uploading episodes
24
25
 
@@ -33,8 +34,7 @@ transcoding), then saves the episode list with the new `vodVid`.
33
34
 
34
35
  - Formats: `.mp4`, `.mov`, `.m4v`, `.webm`. Upload the finished master;
35
36
  VOD transcodes to MP4 for playback.
36
- - The whole file goes up in one request — fine for episodes (hundreds of MB).
37
- Multi-GB masters are better uploaded from the web, which chunks them.
37
+ - The whole file goes up in one request. The cap is 500 MB, with no duration limit.
38
38
  - A credential lasts 15 minutes and the server allows 30 per 10 minutes —
39
39
  upload a season one episode at a time, not in parallel.
40
40
  - `--vid <vid>` attaches a video already in the space (e.g. uploaded on the
@@ -43,7 +43,7 @@ transcoding), then saves the episode list with the new `vodVid`.
43
43
  ### Legacy path
44
44
 
45
45
  `--legacy` (or `--asset <assetId>`) stores the episode as a plain WJI asset
46
- (≤200 MB) with a permanent public URL. It plays immediately with no
46
+ (≤500 MB) with a permanent public URL. It plays immediately with no
47
47
  transcoding, but anyone who sees the URL can share it, and tier locks only hide
48
48
  it on the page. Use VOD for anything gated; legacy only for short public
49
49
  extras.
@@ -78,6 +78,44 @@ wji br episode rm <postId> 7 8 -y
78
78
  Removing an episode does not delete the video from VOD; re-adding it later
79
79
  with `--vid` works.
80
80
 
81
+ ## Subtitles
82
+
83
+ Each episode takes up to 8 subtitle tracks, one per language, from `.srt` or
84
+ `.vtt` files (≤1 MB). The server cleans them into plain WebVTT — italics,
85
+ colours and `{\an8}` positioning are dropped. The **first** track is the
86
+ default; viewers who never chose one get the track matching the site's
87
+ language, else the default.
88
+
89
+ ```bash
90
+ wji br episode subs add <postId> 2 E02.zh-Hant.srt # zh-Hant guessed from the name
91
+ wji br episode subs add <postId> 2 E02.srt --lang en --label "English (CC)"
92
+ wji br episode subs add <postId> 2 E02_en_v2.srt --lang en --replace
93
+ wji br episode subs default <postId> 2 en
94
+ wji br episode subs label <postId> 2 en # back to the language name
95
+ wji br episode subs rm <postId> 2 ja ko
96
+ wji br episode subs <postId> 2
97
+ ```
98
+
99
+ Languages are BCP 47 tags: `zh-Hant`, `zh-Hans`, `yue`, `en`, `ja`, `ko`,
100
+ `th`, `vi`, `id`, `ms`, `es`, `pt`, `fr`, `de` (others are accepted, but the
101
+ player shows the raw code). A file the server can't read fails at upload,
102
+ before the episode changes. `add` warns when the last line runs past the
103
+ episode's length — usually the wrong episode's file.
104
+
105
+ Subtitles follow the episode's tier: a locked episode never serves its text.
106
+ Replacing the video (`episode set <post> <n> new.mp4`) keeps the tracks; remove
107
+ them if the new cut changed the timing.
108
+
109
+ ## Quality
110
+
111
+ VOD transcodes each upload into the renditions its template defines. The
112
+ player offers Auto plus each one; with a single rendition the menu hides.
113
+
114
+ ```bash
115
+ wji br episode play <postId> 1 --qualities # what VOD made
116
+ wji br episode play <postId> 1 -q 720p # URL of one rendition
117
+ ```
118
+
81
119
  ## Who can watch: tiers
82
120
 
83
121
  `public` anyone · `member` signed in · `supporter` has donated to this post ·
@@ -36,8 +36,11 @@ Either way, this skill only says **what to build**; the operations doc says how
36
36
 
37
37
  **How the drama is written** comes from `{baseDir}/reference/craft-story.md` (the 短剧 method:
38
38
  brief, three phases, 分集目录, 爽点 / 钩子 libraries, the episode formula, the character bible) and
39
- `{baseDir}/reference/dialogue-craft.md` (lines as action, 语体 rules, the polish pass). Read the
39
+ `{baseDir}/reference/dialogue-craft.md` (lines as action, 语体 rules, the dialogue review). Read the
40
40
  first before stage 1 and the second before stage 4. The gates encode what those two can check.
41
+ The full dialogue method is the user's **`w-film-dialogue-editor`** skill, installed beside this
42
+ one: its language guides and corpus are read while the lines are written and again in the required
43
+ dialogue review after the script.
41
44
 
42
45
  **Re-read the guideline.** The 创作指引 (`render guideline`) is the drama's top rule document.
43
46
  Print it before writing any character, any episode and any storyboard, and print the speakers'
@@ -277,6 +280,7 @@ Read `{baseDir}/reference/stage-script.md`.
277
280
  writes `episodes/E0N.json` with target seconds, hook/cliff, claimed beats, the episode design
278
281
  (`function` / `task` / `change` / `hookType`, `satisfactionBeat` to set) and a `seedNote`
279
282
  (synopsis, the 爽点 to realise, register, guideline priorities, every speaker's `bible.speech`,
283
+ the dialogue skill's guides and per-speaker corpus queries in `dialogue`,
280
284
  ids, previous cliff, budget). Fill `scenes[].flow` — action beats and line beats alternating,
281
285
  **common actions only**, hook image inside the first 3 beats and by 15 s, a `turn: true` beat
282
286
  at least every 30 s, the 爽点 on an action (`satisfactionBeat`), cliff as the last beat; lines
@@ -286,9 +290,13 @@ Read `{baseDir}/reference/stage-script.md`.
286
290
  Delete `seedNote` when done.
287
291
  2. `validate --stage script --ep N` — duration math, line length, speakers, hook window, the
288
292
  rhythm, the design, filmability, upstream ids; the register gate only advises. Over budget: cut
289
- actions first; under: add conflict, not small talk. When dialogue is the weakness, run the
290
- polish pass: `render dialogue --ep N` → fill the 逐句校对表 → apply → re-validate.
291
- 3. Sync: `render script --ep N` (the 短剧 episode format with the 创作自检 table) → `story/` dataset
293
+ actions first; under: add conflict, not small talk.
294
+ 3. **Dialogue review** (required, gate `dialogue-review`): `seed dialogue --ep N` → read the
295
+ `w-film-dialogue-editor` framework and the guide of each 语体 it prints, query the corpus per
296
+ speaker → fill the diagnosis, level, one row per line (issue / revised / reason / playable
297
+ note) and the guide's 自检 → `apply dialogue --ep N` → re-validate. A line edited later is
298
+ reviewed again. Details in `stage-script.md`.
299
+ 4. Sync: `render script --ep N` (the 短剧 episode format with the 创作自检 table) → `story/` dataset
292
300
  `E0N 剧本` (type `screenplay`, `viewMode=content`); bind `wji.screenplayDatasetId` with `--ep N`.
293
301
 
294
302
  ## Stage 5 — Storyboard (per episode)
@@ -307,6 +315,21 @@ prompt, alongside the camera package and each speaker's delivery.
307
315
 
308
316
  **Two people talking** (gate `two-person-framing`, sheets mode): a two-shot once, then over-the-shoulder (`ots`) for the exchange, a clean single for the punchline. A single names only its person; every look states a screen direction (`eyeline`); the sides stay locked. See `reference/craft-shots.md`.
309
317
 
318
+ **Every picture says what it is, and what NOT to copy from it** (gate `ref-binding`, sheets mode).
319
+ The model reads one string: a legend line that only says "a character sheet" for two different
320
+ people is how faces get swapped, and a shot that never names its `@imageK` is a shot where the
321
+ model invents the face or the prop. So — one legend line per picture, naming a person by age /
322
+ hair / clothes from the character bible, the room by its name, a prop by its name; every picture
323
+ that is **not** a person also says what may not be copied from it (*take only its light ratios,
324
+ contrast and colour temperature — never its composition or its people*); the room is named once
325
+ for the whole segment (`Every shot stays inside @image1 …`); every person and every prop is bound
326
+ by its label again **inside its own shot**; no connected picture may go unused; and a segment may
327
+ not carry more pictures than the model takes (`refs` cap). Wording patterns and the six roles:
328
+ `reference/reference-binding.md`. A prompt that was written or edited by hand (or copied from a
329
+ production run) can be proofread before any image is spent:
330
+ `node {baseDir}/reference/prompt-audit.mjs --project <project> --ep 1 --segment E0N-k --prompt <file>`
331
+ — it reports per-shot unbound people and props, indistinguishable legend lines and unused pictures.
332
+
310
333
  **Every cut is lit and composed in the preset's look.** Read the preset's guide
311
334
  (`{baseDir}/reference/cinematography-<preset>.md`) before the first storyboard. Name the source
312
335
  in `light.source`, stage the person against it, and choose the camera side that gives the
@@ -1879,5 +1879,177 @@
1879
1879
  "canvasId": "",
1880
1880
  "shotFolderId": "",
1881
1881
  "displayNodes": {}
1882
+ },
1883
+ "dialogueReview": {
1884
+ "level": "轻修",
1885
+ "guides": [
1886
+ "lang-zh-cn.md"
1887
+ ],
1888
+ "diagnosis": {
1889
+ "goal": "小夏想守住别人托付的袋子;老陈想弄清袋子从哪来",
1890
+ "conflict": "老陈步步试探,小夏用「别人的东西」挡住",
1891
+ "subtext": "老陈认得穿雨衣的人,却不说破;小夏开始怀疑他",
1892
+ "voice": "小夏短句、收得急;老陈慢、一字一顿,遮住名字也分得出",
1893
+ "rhythm": "问答都短,每两三句就被动作打断,节奏成立"
1894
+ },
1895
+ "rows": [
1896
+ {
1897
+ "scene": 1,
1898
+ "beat": 2,
1899
+ "speaker": "C03",
1900
+ "original": "帮我收着。明天有人来拿。",
1901
+ "issue": "无:两个短句,交代托付又不解释",
1902
+ "revised": "帮我收着。明天有人来拿。",
1903
+ "reason": "",
1904
+ "note": "压着嗓子,你不是请求,是把麻烦塞给她"
1905
+ },
1906
+ {
1907
+ "scene": 1,
1908
+ "beat": 3,
1909
+ "speaker": "C01",
1910
+ "original": "你是谁?我们不代收——",
1911
+ "issue": "无:被打断的半句,正好是人已走远",
1912
+ "revised": "你是谁?我们不代收——",
1913
+ "reason": "",
1914
+ "note": "追问,话到一半发现对方根本不听"
1915
+ },
1916
+ {
1917
+ "scene": 2,
1918
+ "beat": 3,
1919
+ "speaker": "C02",
1920
+ "original": "又是你值夜班。",
1921
+ "issue": "无:熟人寒暄,带着她天天值夜的信息",
1922
+ "revised": "又是你值夜班。",
1923
+ "reason": "",
1924
+ "note": "先吃一口再说,你在装作若无其事"
1925
+ },
1926
+ {
1927
+ "scene": 2,
1928
+ "beat": 4,
1929
+ "speaker": "C01",
1930
+ "original": "陈叔,你怎么天天半夜来。",
1931
+ "issue": "无:顺口一问,眼睛在袋子上,潜台词在动作里",
1932
+ "revised": "陈叔,你怎么天天半夜来。",
1933
+ "reason": "",
1934
+ "note": "嘴上问他,心思全在袋子上,别看他"
1935
+ },
1936
+ {
1937
+ "scene": 2,
1938
+ "beat": 5,
1939
+ "speaker": "C02",
1940
+ "original": "老了,睡不着。",
1941
+ "issue": "无:一句带过,把真正的原因藏住",
1942
+ "revised": "老了,睡不着。",
1943
+ "reason": "",
1944
+ "note": "笑着挡回去,不让她再问"
1945
+ },
1946
+ {
1947
+ "scene": 2,
1948
+ "beat": 7,
1949
+ "speaker": "C02",
1950
+ "original": "这是什么?",
1951
+ "issue": "无:最短的问题,压力在筷子停住",
1952
+ "revised": "这是什么?",
1953
+ "reason": "",
1954
+ "note": "声音不变,你在试探,不是好奇"
1955
+ },
1956
+ {
1957
+ "scene": 2,
1958
+ "beat": 8,
1959
+ "speaker": "C01",
1960
+ "original": "刚才一个人放这儿的,说明天有人来拿。",
1961
+ "issue": "无:耸肩一句,转述托付人的话,不加解释",
1962
+ "revised": "刚才一个人放这儿的,说明天有人来拿。",
1963
+ "reason": "",
1964
+ "note": "耸肩,你在敷衍,想把话题带过"
1965
+ },
1966
+ {
1967
+ "scene": 2,
1968
+ "beat": 10,
1969
+ "speaker": "C01",
1970
+ "original": "别人的东西。",
1971
+ "issue": "无:三个字划界线",
1972
+ "revised": "别人的东西。",
1973
+ "reason": "",
1974
+ "note": "手按住抽屉,你在拒绝,不用抬高声音"
1975
+ },
1976
+ {
1977
+ "scene": 2,
1978
+ "beat": 11,
1979
+ "speaker": "C02",
1980
+ "original": "小夏,那个人穿雨衣?",
1981
+ "issue": "无:叫名字一次,用在试探的关键处",
1982
+ "revised": "小夏,那个人穿雨衣?",
1983
+ "reason": "",
1984
+ "note": "很轻,你在确认一件你早就知道的事"
1985
+ },
1986
+ {
1987
+ "scene": 2,
1988
+ "beat": 13,
1989
+ "speaker": "C02",
1990
+ "original": "三年前,这店里丢过一样东西。",
1991
+ "issue": "无:只给半句往事,留钩子",
1992
+ "revised": "三年前,这店里丢过一样东西。",
1993
+ "reason": "",
1994
+ "note": "背对她说,你在决定要说多少"
1995
+ },
1996
+ {
1997
+ "scene": 2,
1998
+ "beat": 15,
1999
+ "speaker": "C02",
2000
+ "original": "别把它交给任何人。",
2001
+ "issue": "无:命令句,第一次正眼看她",
2002
+ "revised": "别把它交给任何人。",
2003
+ "reason": "",
2004
+ "note": "一字一顿,你在把责任压到她身上"
2005
+ },
2006
+ {
2007
+ "scene": 2,
2008
+ "beat": 17,
2009
+ "speaker": "VO",
2010
+ "original": "他怎么知道那人穿雨衣。",
2011
+ "issue": "无:心声一句,把怀疑交给观众",
2012
+ "revised": "他怎么知道那人穿雨衣。",
2013
+ "reason": "",
2014
+ "note": "压得很低,像自己不敢承认这个念头"
2015
+ },
2016
+ {
2017
+ "scene": 2,
2018
+ "beat": 19,
2019
+ "speaker": "C02",
2020
+ "original": "锁上门。",
2021
+ "issue": "无:三个字收尾,命令里有恐惧",
2022
+ "revised": "锁上门。",
2023
+ "reason": "",
2024
+ "note": "不回头,你在逼她立刻照做"
2025
+ }
2026
+ ],
2027
+ "selfCheck": [
2028
+ {
2029
+ "item": "读出声:有没有播音腔的句子?",
2030
+ "ok": true,
2031
+ "note": "全部短句口语,无书面长句"
2032
+ },
2033
+ {
2034
+ "item": "语气词密度:一句最多一个,没有粤语残留(啦/喇/啫)",
2035
+ "ok": true,
2036
+ "note": "几乎不用语气词"
2037
+ },
2038
+ {
2039
+ "item": "网络用语符合年龄:长辈角色不用",
2040
+ "ok": true,
2041
+ "note": "老陈六十多岁,无网络用语"
2042
+ },
2043
+ {
2044
+ "item": "地域口音只给少数角色",
2045
+ "ok": true,
2046
+ "note": "全员标准普通话"
2047
+ },
2048
+ {
2049
+ "item": "英语夹杂符合行业身份",
2050
+ "ok": true,
2051
+ "note": "无英语夹杂,便利店夜班不需要"
2052
+ }
2053
+ ]
1882
2054
  }
1883
- }
2055
+ }