@wji.ai/cli 0.9.0 → 0.9.1

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 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
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",
@@ -6348,8 +6350,9 @@ async function uploadToVod(token, file, onProgress) {
6348
6350
  var MAX_EPISODES = 30;
6349
6351
  var VIDEO_EXTS = [".mp4", ".mov", ".m4v", ".webm"];
6350
6352
  var ASSET_VIDEO_MAX_BYTES = 200 * 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) {
@@ -6393,9 +6396,62 @@ 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
  }
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
+ }
6399
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 (\u2264200 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");
@@ -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
 
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.1",
4
4
  "description": "Command line interface for the WJI AI platform",
5
5
  "license": "UNLICENSED",
6
6
  "author": "WJI AI",
@@ -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
 
@@ -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 ·