@mediaruntime/cli 1.3.0 → 1.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.0 — 2026-09-13
4
+
5
+ - Add capability-discovered Clip Candidates and Video Clip commands, bounded ranges,
6
+ original or vertical blurred framing, keyword controls, and opt-in burned captions.
7
+ - Attach local JSON plans/segment arrays, SRT, or VTT with source timestamps; reuse
8
+ transcripts for analysis without another Whisper run. Validate bounded UTF-8 attachments
9
+ before submission and preserve the normal wait and verified ZIP download flow.
10
+ - Require `@mediaruntime/node` 1.4.0 for clipping serialization and report parity.
11
+ - Correct the executable version and regression-test it against the package manifest.
12
+
3
13
  ## 1.3.0 — 2026-08-31
4
14
 
5
15
  - Add complete Hosted Sticker Runtime commands for collection lifecycle, pack binding,
package/README.md CHANGED
@@ -4,7 +4,12 @@ Official command-line client for MediaRuntime. It submits media, waits for jobs,
4
4
  the canonical ZIP output bundle, inspects account jobs, and sends correctly signed
5
5
  synthetic webhooks to a local receiver.
6
6
 
7
- Status: stable `1.3.0`. The documented `1.x` command names, flags, JSON envelopes, exit
7
+ Version `1.4.0` adds [clipping commands](docs/clipping.md) for analysis,
8
+ manual ranges, vertical blur-fill, and JSON/SRT/VTT transcript attachments. It requires
9
+ Node SDK 1.4.0 or newer and a gateway/engine deployment supporting the clipping
10
+ presets. The live capability catalog determines which presets are available.
11
+
12
+ The documented `1.x` command names, flags, JSON envelopes, exit
8
13
  codes, and credential precedence follow semantic versioning. Breaking changes require a
9
14
  new major version; additive commands and fields may ship in minor releases.
10
15
 
package/dist/cli.js CHANGED
@@ -608,6 +608,93 @@ async function runJobsCommand(args, dependencies) {
608
608
  throw new UsageError("Usage: mediaruntime jobs <list|get>");
609
609
  }
610
610
 
611
+ // src/clipTranscript.ts
612
+ import { open } from "fs/promises";
613
+ var MAX_FILE_BYTES = 1024 * 1024;
614
+ function timestamp(value) {
615
+ const match = /^(?:(\d{2,}):)?(\d{2}):(\d{2})[.,](\d{3})$/.exec(value);
616
+ if (!match || Number(match[2]) > 59 || Number(match[3]) > 59) {
617
+ throw new UsageError("Transcript cue times must use HH:MM:SS,mmm (SRT) or MM:SS.mmm / HH:MM:SS.mmm (VTT)");
618
+ }
619
+ return Number(match[1] ?? 0) * 3600 + Number(match[2]) * 60 + Number(match[3]) + Number(match[4]) / 1e3;
620
+ }
621
+ function subtitleSegments(text, vtt) {
622
+ if (vtt && !/^WEBVTT(?:\s|$)/.test(text)) throw new UsageError("VTT transcripts must begin with WEBVTT");
623
+ const segments = [];
624
+ for (const block of text.split(/\n[ \t]*\n/)) {
625
+ if (vtt && /^(WEBVTT(?:\s|$)|NOTE(?:\s|$)|STYLE(?:\s|$)|REGION(?:\s|$))/.test(block)) continue;
626
+ const lines = block.split("\n");
627
+ const timing = lines[0]?.includes("-->") ? 0 : 1;
628
+ const match = /^(\S+)\s+-->\s+(\S+)(?:\s+.*)?$/.exec(lines[timing] ?? "");
629
+ if (!match) throw new UsageError("Transcript contains an invalid SRT/VTT cue");
630
+ const entities = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
631
+ const cue = lines.slice(timing + 1).join("\n").replace(/<[^>]*>/g, "").replace(/&(amp|lt|gt|quot|apos|nbsp);/g, (_, name) => entities[name]);
632
+ segments.push({ startTimeSec: timestamp(match[1]), endTimeSec: timestamp(match[2]), text: cue });
633
+ }
634
+ return segments;
635
+ }
636
+ async function readClipTranscript(path) {
637
+ if (!/\.(json|srt|vtt)$/i.test(path)) throw new UsageError("--clip-transcript accepts a local .json, .srt, or .vtt file");
638
+ let data;
639
+ try {
640
+ const file = await open(path, "r");
641
+ try {
642
+ if (!(await file.stat()).isFile()) throw new UsageError("Clip transcript must be a regular file");
643
+ const buffer = Buffer.alloc(MAX_FILE_BYTES + 1);
644
+ let size = 0;
645
+ while (size < buffer.length) {
646
+ const read = await file.read(buffer, size, buffer.length - size, null);
647
+ if (!read.bytesRead) break;
648
+ size += read.bytesRead;
649
+ }
650
+ if (size > MAX_FILE_BYTES) throw new UsageError("Clip transcript file must be at most 1 MiB");
651
+ data = buffer.subarray(0, size);
652
+ } finally {
653
+ await file.close();
654
+ }
655
+ } catch (error) {
656
+ if (error instanceof UsageError) throw error;
657
+ throw new UsageError("Cannot read clip transcript file; check its path and permissions");
658
+ }
659
+ let text;
660
+ try {
661
+ text = new TextDecoder("utf-8", { fatal: true }).decode(data).replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n").trim();
662
+ } catch {
663
+ throw new UsageError("Clip transcript must be valid UTF-8 text");
664
+ }
665
+ let segments;
666
+ if (/\.json$/i.test(path)) {
667
+ let value;
668
+ try {
669
+ value = JSON.parse(text);
670
+ } catch {
671
+ throw new UsageError("Clip transcript must be valid JSON");
672
+ }
673
+ segments = Array.isArray(value) ? value : value && typeof value === "object" && "transcript" in value ? value.transcript : void 0;
674
+ } else segments = subtitleSegments(text, /\.vtt$/i.test(path));
675
+ if (!Array.isArray(segments) || !segments.length || segments.length > 2e3) {
676
+ throw new UsageError("Clip transcript must contain 1 to 2000 segments");
677
+ }
678
+ let previous = -1;
679
+ let totalBytes = 0;
680
+ return segments.map((value) => {
681
+ const segment = value && typeof value === "object" ? value : {};
682
+ const start = segment.start_time_sec ?? segment.startTimeSec;
683
+ const end = segment.end_time_sec ?? segment.endTimeSec;
684
+ const cue = segment.text;
685
+ if (typeof start !== "number" || !Number.isFinite(start) || typeof end !== "number" || !Number.isFinite(end) || start < 0 || end <= start || end > 604800 || start < previous) {
686
+ throw new UsageError("Transcript segments require ordered finite source times with end after start (within 604800 seconds)");
687
+ }
688
+ if (typeof cue !== "string" || !cue.trim() || Buffer.byteLength(cue, "utf8") > 2e3) {
689
+ throw new UsageError("Transcript segments require nonblank text of at most 2000 UTF-8 bytes");
690
+ }
691
+ previous = start;
692
+ totalBytes += Buffer.byteLength(cue, "utf8");
693
+ if (totalBytes > 256 * 1024) throw new UsageError("Transcript text exceeds 256 KiB");
694
+ return { startTimeSec: start, endTimeSec: end, text: cue.trim() };
695
+ });
696
+ }
697
+
611
698
  // src/commands/run.ts
612
699
  var UNSUCCESSFUL_TERMINAL_STATUSES2 = /* @__PURE__ */ new Set(["FAILED", "REJECTED", "PARTIAL"]);
613
700
  var OUTPUT_ALIASES = /* @__PURE__ */ new Set([
@@ -665,6 +752,9 @@ function parseRunOptions(args) {
665
752
  const contactSheet = {};
666
753
  const image = {};
667
754
  const audiogram = {};
755
+ const clip = {};
756
+ const clipAnalysis = {};
757
+ let clipTranscriptFile;
668
758
  const privacyRedaction = {};
669
759
  const privacyDetectors = [];
670
760
  for (let index = 0; index < args.length; index += 1) {
@@ -794,6 +884,36 @@ function parseRunOptions(args) {
794
884
  } else if (argument === "--image-min-quality") {
795
885
  image.minQuality = boundedNumber(args, index, argument, 1, 100, true);
796
886
  index += 1;
887
+ } else if (argument === "--clip-start") {
888
+ clip.startTimeSec = boundedNumber(args, index, argument, 0, 604800);
889
+ index += 1;
890
+ } else if (argument === "--clip-duration") {
891
+ clip.durationSec = boundedNumber(args, index, argument, 0.1, 300);
892
+ index += 1;
893
+ } else if (argument === "--clip-layout") {
894
+ const value = optionValue2(args, index, argument);
895
+ if (value !== "original" && value !== "vertical_blur") {
896
+ throw new UsageError("--clip-layout must be original or vertical_blur");
897
+ }
898
+ clip.layout = value;
899
+ index += 1;
900
+ } else if (argument === "--clip-captions") {
901
+ clip.burnCaptions = true;
902
+ } else if (argument === "--clip-transcript") {
903
+ clipTranscriptFile = optionValue2(args, index, argument);
904
+ index += 1;
905
+ } else if (argument === "--clip-min-duration") {
906
+ clipAnalysis.minDurationSec = boundedNumber(args, index, argument, 1, 300);
907
+ index += 1;
908
+ } else if (argument === "--clip-max-duration") {
909
+ clipAnalysis.maxDurationSec = boundedNumber(args, index, argument, 1, 300);
910
+ index += 1;
911
+ } else if (argument === "--clip-count") {
912
+ clipAnalysis.maxCandidates = boundedNumber(args, index, argument, 1, 20, true);
913
+ index += 1;
914
+ } else if (argument === "--clip-keyword") {
915
+ (clipAnalysis.keywords ??= []).push(optionValue2(args, index, argument));
916
+ index += 1;
797
917
  } else if (argument === "--audiogram-artwork") {
798
918
  audiogram.artworkSource = optionValue2(args, index, argument);
799
919
  index += 1;
@@ -994,6 +1114,33 @@ function parseRunOptions(args) {
994
1114
  }
995
1115
  }
996
1116
  const hasPrivacyOptions = privacyDetectors.length > 0 || Object.keys(privacyRedaction).length > 0;
1117
+ const clipPreset = outputs.length === 1 && outputs[0]?.kind === "preset" ? outputs[0].value : void 0;
1118
+ const hasClip = Object.keys(clip).length > 0 || clipPreset === "video_clip_v1";
1119
+ const hasClipAnalysis = Object.keys(clipAnalysis).length > 0 || clipPreset === "clip_candidates_v1";
1120
+ if (hasClip && (recipe || clipPreset !== "video_clip_v1" || hasClipAnalysis)) {
1121
+ throw new UsageError("clip render options require exactly one video_clip_v1 --preset");
1122
+ }
1123
+ if (hasClip && (clip.startTimeSec === void 0 || clip.durationSec === void 0)) {
1124
+ throw new UsageError("video_clip_v1 requires --clip-start and --clip-duration");
1125
+ }
1126
+ if (hasClipAnalysis && (recipe || clipPreset !== "clip_candidates_v1")) {
1127
+ throw new UsageError("clip analysis options require exactly one clip_candidates_v1 --preset");
1128
+ }
1129
+ if ((clipAnalysis.minDurationSec ?? 15) > (clipAnalysis.maxDurationSec ?? 60)) {
1130
+ throw new UsageError("--clip-min-duration must not exceed --clip-max-duration");
1131
+ }
1132
+ if ((clipAnalysis.keywords?.length ?? 0) > 20) {
1133
+ throw new UsageError("At most 20 --clip-keyword values are supported");
1134
+ }
1135
+ if (clipAnalysis.keywords?.some((keyword) => !keyword.trim() || Buffer.byteLength(keyword, "utf8") > 100)) {
1136
+ throw new UsageError("Each --clip-keyword must contain 1 to 100 UTF-8 bytes of nonblank text");
1137
+ }
1138
+ if (clipTranscriptFile && !hasClip && !hasClipAnalysis) {
1139
+ throw new UsageError("--clip-transcript requires a clipping preset");
1140
+ }
1141
+ if (clip.burnCaptions && !clipTranscriptFile) {
1142
+ throw new UsageError("--clip-captions requires --clip-transcript with source timestamps");
1143
+ }
997
1144
  if (hasPrivacyOptions) {
998
1145
  if (recipe) throw new UsageError("privacy-redaction options cannot be combined with --recipe");
999
1146
  if (privacyDetectors.length === 0) {
@@ -1021,6 +1168,9 @@ function parseRunOptions(args) {
1021
1168
  ...hasPlaceholderOptions ? { placeholders } : {},
1022
1169
  ...hasContactSheetOptions ? { contactSheet } : {},
1023
1170
  ...hasImageOptions ? { image } : {},
1171
+ ...hasClip ? { clip } : {},
1172
+ ...hasClipAnalysis ? { clipAnalysis } : {},
1173
+ ...clipTranscriptFile ? { clipTranscriptFile } : {},
1024
1174
  ...hasAudiogramOptions || audiogramSelections.length > 0 ? { audiogram } : {},
1025
1175
  ...hasPrivacyOptions ? { privacyRedaction } : {}
1026
1176
  };
@@ -1047,7 +1197,7 @@ async function resolveAudiogram(value, uploads) {
1047
1197
  ...captionsSource === void 0 ? {} : { captionsSource, burnCaptions: true }
1048
1198
  };
1049
1199
  }
1050
- async function resolveOutputs(selections, capabilitiesClient, animation, placeholders, contactSheet, image, audiogram, privacyRedaction) {
1200
+ async function resolveOutputs(selections, capabilitiesClient, animation, placeholders, contactSheet, image, audiogram, privacyRedaction, clip, clipAnalysis) {
1051
1201
  if (!privacyRedaction && !selections.some((selection) => selection.kind === "preset")) {
1052
1202
  return selections.map((selection) => selection.value);
1053
1203
  }
@@ -1084,6 +1234,8 @@ async function resolveOutputs(selections, capabilitiesClient, animation, placeho
1084
1234
  }
1085
1235
  if (audiogram) output3.audiogram = audiogram;
1086
1236
  if (privacyRedaction) output3.privacyRedaction = privacyRedaction;
1237
+ if (clip) output3.clip = clip;
1238
+ if (clipAnalysis) output3.clipAnalysis = clipAnalysis;
1087
1239
  return output3;
1088
1240
  });
1089
1241
  }
@@ -1146,6 +1298,14 @@ async function runCommand(args, dependencies) {
1146
1298
  isLocalSource || hasLocalAudiogramAsset ? "Uploading local media and creating job\u2026" : "Creating job\u2026"
1147
1299
  );
1148
1300
  try {
1301
+ if ((options.clip || options.clipAnalysis) && !("getClipCandidates" in dependencies.jobs)) {
1302
+ throw new UsageError("Clipping requires the MediaRuntime Node SDK release with getClipCandidates support");
1303
+ }
1304
+ if (options.clipTranscriptFile) {
1305
+ const transcript = await readClipTranscript(options.clipTranscriptFile);
1306
+ if (options.clip) options.clip.transcript = transcript;
1307
+ if (options.clipAnalysis) options.clipAnalysis.transcript = transcript;
1308
+ }
1149
1309
  const audiogram = options.audiogram === void 0 ? void 0 : await resolveAudiogram(options.audiogram, dependencies.uploads);
1150
1310
  const outputs = options.recipe ? void 0 : await resolveOutputs(
1151
1311
  options.outputs,
@@ -1155,7 +1315,9 @@ async function runCommand(args, dependencies) {
1155
1315
  options.contactSheet,
1156
1316
  options.image,
1157
1317
  audiogram,
1158
- options.privacyRedaction
1318
+ options.privacyRedaction,
1319
+ options.clip,
1320
+ options.clipAnalysis
1159
1321
  );
1160
1322
  const params = {
1161
1323
  source: options.source,
@@ -2032,7 +2194,7 @@ async function runTriggerCommand(args, dependencies = {}) {
2032
2194
  writeStdout: dependencies.writeStdout ?? ((text) => process.stdout.write(text))
2033
2195
  };
2034
2196
  const secret = await resolveSecret(parsed, deps);
2035
- const timestamp = Math.floor(deps.now() / 1e3);
2197
+ const timestamp2 = Math.floor(deps.now() / 1e3);
2036
2198
  const unique = deps.randomUUID();
2037
2199
  const eventId = `evt_cli_${unique}`;
2038
2200
  const jobId = parsed.jobId ?? `job_cli_${unique}`;
@@ -2041,9 +2203,9 @@ async function runTriggerCommand(args, dependencies = {}) {
2041
2203
  eventId,
2042
2204
  jobId,
2043
2205
  accountId,
2044
- timestamp
2206
+ timestamp: timestamp2
2045
2207
  });
2046
- const signed = signSyntheticWebhook(payload, { eventId, timestamp, secret });
2208
+ const signed = signSyntheticWebhook(payload, { eventId, timestamp: timestamp2, secret });
2047
2209
  const response = await deps.fetch(parsed.destination, {
2048
2210
  method: "POST",
2049
2211
  headers: signed.headers,
@@ -2117,7 +2279,7 @@ function createActivityIndicator(write, enabled) {
2117
2279
  }
2118
2280
 
2119
2281
  // src/cli.ts
2120
- var VERSION = "1.2.0";
2282
+ var VERSION = "1.4.0";
2121
2283
  var HELP = `MediaRuntime CLI
2122
2284
 
2123
2285
  Usage:
@@ -2133,6 +2295,11 @@ Usage:
2133
2295
  [--image-width PX] [--image-height PX] [--image-mode fit|fill|cover|contain]
2134
2296
  [--image-format jpg|png|webp|avif] [--image-quality N]
2135
2297
  [--image-max-bytes N] [--image-min-quality N]
2298
+ Clip render: --preset video_clip_v1 --clip-start SEC --clip-duration SEC
2299
+ [--clip-layout original|vertical_blur] [--clip-transcript <plan.json|captions.srt|captions.vtt>] [--clip-captions]
2300
+ Clip suggestions: --preset clip_candidates_v1 [--clip-min-duration SEC]
2301
+ [--clip-max-duration SEC] [--clip-count 1..20] [--clip-keyword TEXT]
2302
+ [--clip-transcript <plan.json|captions.srt|captions.vtt>]
2136
2303
  Audiogram preset: --audiogram-artwork <source> [--audiogram-captions <source>]
2137
2304
  [--audiogram-layout square|portrait|landscape]
2138
2305
  [--audiogram-fit contain|cover|blurred_background]