@mediaruntime/cli 1.3.0 → 1.5.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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.0 — 2026-09-21
4
+
5
+ - Add `run --no-webhook` to suppress callbacks for a billable job without changing account settings.
6
+ - Preserve normal delivery when omitted; waiting and downloading work with either policy.
7
+ - Require Node SDK 1.5.0 and expose the returned policy in job detail JSON.
8
+
9
+ ## 1.4.0 — 2026-09-13
10
+
11
+ - Add capability-discovered Clip Candidates and Video Clip commands, bounded ranges,
12
+ original or vertical blurred framing, keyword controls, and opt-in burned captions.
13
+ - Attach local JSON plans/segment arrays, SRT, or VTT with source timestamps; reuse
14
+ transcripts for analysis without another Whisper run. Validate bounded UTF-8 attachments
15
+ before submission and preserve the normal wait and verified ZIP download flow.
16
+ - Require `@mediaruntime/node` 1.4.0 for clipping serialization and report parity.
17
+ - Correct the executable version and regression-test it against the package manifest.
18
+
3
19
  ## 1.3.0 — 2026-08-31
4
20
 
5
21
  - Add complete Hosted Sticker Runtime commands for collection lifecycle, pack binding,
package/README.md CHANGED
@@ -4,7 +4,20 @@ 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.5.0` adds `run --no-webhook` for local development against hosted MediaRuntime. Use `--wait` or `--download` to retrieve results without a callback. Normal account billing applies; omitting the switch preserves normal webhook delivery.
8
+
9
+ ```bash
10
+ mediaruntime run ./product-video.mp4 --output video.web --no-webhook --download ./result.zip
11
+ ```
12
+
13
+ This suppresses callbacks even when your account has a live endpoint. The account configuration is unchanged. Webhook retries cannot override the job's opt-out. A polling timeout does not cancel processing: keep the printed job ID and use `mediaruntime jobs get <job-id>` to check it instead of creating another billable job.
14
+
15
+ Version `1.4.0` adds [clipping commands](docs/clipping.md) for analysis,
16
+ manual ranges, vertical blur-fill, and JSON/SRT/VTT transcript attachments. It requires
17
+ Node SDK 1.4.0 or newer and a gateway/engine deployment supporting the clipping
18
+ presets. The live capability catalog determines which presets are available.
19
+
20
+ The documented `1.x` command names, flags, JSON envelopes, exit
8
21
  codes, and credential precedence follow semantic versioning. Breaking changes require a
9
22
  new major version; additive commands and fields may ship in minor releases.
10
23
 
package/dist/cli.js CHANGED
@@ -459,6 +459,7 @@ function detailsProjection(job) {
459
459
  id: job.id,
460
460
  status: job.status,
461
461
  tier: job.tier,
462
+ ...job.deliverWebhook === void 0 ? {} : { deliverWebhook: job.deliverWebhook },
462
463
  usage: job.usage,
463
464
  billing: job.billing,
464
465
  bundle: {
@@ -608,6 +609,93 @@ async function runJobsCommand(args, dependencies) {
608
609
  throw new UsageError("Usage: mediaruntime jobs <list|get>");
609
610
  }
610
611
 
612
+ // src/clipTranscript.ts
613
+ import { open } from "fs/promises";
614
+ var MAX_FILE_BYTES = 1024 * 1024;
615
+ function timestamp(value) {
616
+ const match = /^(?:(\d{2,}):)?(\d{2}):(\d{2})[.,](\d{3})$/.exec(value);
617
+ if (!match || Number(match[2]) > 59 || Number(match[3]) > 59) {
618
+ throw new UsageError("Transcript cue times must use HH:MM:SS,mmm (SRT) or MM:SS.mmm / HH:MM:SS.mmm (VTT)");
619
+ }
620
+ return Number(match[1] ?? 0) * 3600 + Number(match[2]) * 60 + Number(match[3]) + Number(match[4]) / 1e3;
621
+ }
622
+ function subtitleSegments(text, vtt) {
623
+ if (vtt && !/^WEBVTT(?:\s|$)/.test(text)) throw new UsageError("VTT transcripts must begin with WEBVTT");
624
+ const segments = [];
625
+ for (const block of text.split(/\n[ \t]*\n/)) {
626
+ if (vtt && /^(WEBVTT(?:\s|$)|NOTE(?:\s|$)|STYLE(?:\s|$)|REGION(?:\s|$))/.test(block)) continue;
627
+ const lines = block.split("\n");
628
+ const timing = lines[0]?.includes("-->") ? 0 : 1;
629
+ const match = /^(\S+)\s+-->\s+(\S+)(?:\s+.*)?$/.exec(lines[timing] ?? "");
630
+ if (!match) throw new UsageError("Transcript contains an invalid SRT/VTT cue");
631
+ const entities = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
632
+ const cue = lines.slice(timing + 1).join("\n").replace(/<[^>]*>/g, "").replace(/&(amp|lt|gt|quot|apos|nbsp);/g, (_, name) => entities[name]);
633
+ segments.push({ startTimeSec: timestamp(match[1]), endTimeSec: timestamp(match[2]), text: cue });
634
+ }
635
+ return segments;
636
+ }
637
+ async function readClipTranscript(path) {
638
+ if (!/\.(json|srt|vtt)$/i.test(path)) throw new UsageError("--clip-transcript accepts a local .json, .srt, or .vtt file");
639
+ let data;
640
+ try {
641
+ const file = await open(path, "r");
642
+ try {
643
+ if (!(await file.stat()).isFile()) throw new UsageError("Clip transcript must be a regular file");
644
+ const buffer = Buffer.alloc(MAX_FILE_BYTES + 1);
645
+ let size = 0;
646
+ while (size < buffer.length) {
647
+ const read = await file.read(buffer, size, buffer.length - size, null);
648
+ if (!read.bytesRead) break;
649
+ size += read.bytesRead;
650
+ }
651
+ if (size > MAX_FILE_BYTES) throw new UsageError("Clip transcript file must be at most 1 MiB");
652
+ data = buffer.subarray(0, size);
653
+ } finally {
654
+ await file.close();
655
+ }
656
+ } catch (error) {
657
+ if (error instanceof UsageError) throw error;
658
+ throw new UsageError("Cannot read clip transcript file; check its path and permissions");
659
+ }
660
+ let text;
661
+ try {
662
+ text = new TextDecoder("utf-8", { fatal: true }).decode(data).replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n").trim();
663
+ } catch {
664
+ throw new UsageError("Clip transcript must be valid UTF-8 text");
665
+ }
666
+ let segments;
667
+ if (/\.json$/i.test(path)) {
668
+ let value;
669
+ try {
670
+ value = JSON.parse(text);
671
+ } catch {
672
+ throw new UsageError("Clip transcript must be valid JSON");
673
+ }
674
+ segments = Array.isArray(value) ? value : value && typeof value === "object" && "transcript" in value ? value.transcript : void 0;
675
+ } else segments = subtitleSegments(text, /\.vtt$/i.test(path));
676
+ if (!Array.isArray(segments) || !segments.length || segments.length > 2e3) {
677
+ throw new UsageError("Clip transcript must contain 1 to 2000 segments");
678
+ }
679
+ let previous = -1;
680
+ let totalBytes = 0;
681
+ return segments.map((value) => {
682
+ const segment = value && typeof value === "object" ? value : {};
683
+ const start = segment.start_time_sec ?? segment.startTimeSec;
684
+ const end = segment.end_time_sec ?? segment.endTimeSec;
685
+ const cue = segment.text;
686
+ if (typeof start !== "number" || !Number.isFinite(start) || typeof end !== "number" || !Number.isFinite(end) || start < 0 || end <= start || end > 604800 || start < previous) {
687
+ throw new UsageError("Transcript segments require ordered finite source times with end after start (within 604800 seconds)");
688
+ }
689
+ if (typeof cue !== "string" || !cue.trim() || Buffer.byteLength(cue, "utf8") > 2e3) {
690
+ throw new UsageError("Transcript segments require nonblank text of at most 2000 UTF-8 bytes");
691
+ }
692
+ previous = start;
693
+ totalBytes += Buffer.byteLength(cue, "utf8");
694
+ if (totalBytes > 256 * 1024) throw new UsageError("Transcript text exceeds 256 KiB");
695
+ return { startTimeSec: start, endTimeSec: end, text: cue.trim() };
696
+ });
697
+ }
698
+
611
699
  // src/commands/run.ts
612
700
  var UNSUCCESSFUL_TERMINAL_STATUSES2 = /* @__PURE__ */ new Set(["FAILED", "REJECTED", "PARTIAL"]);
613
701
  var OUTPUT_ALIASES = /* @__PURE__ */ new Set([
@@ -655,6 +743,7 @@ function parseRunOptions(args) {
655
743
  let metadata;
656
744
  let recipe;
657
745
  let idempotencyKey;
746
+ let deliverWebhook;
658
747
  let wait = false;
659
748
  let timeoutMs;
660
749
  let download;
@@ -665,6 +754,9 @@ function parseRunOptions(args) {
665
754
  const contactSheet = {};
666
755
  const image = {};
667
756
  const audiogram = {};
757
+ const clip = {};
758
+ const clipAnalysis = {};
759
+ let clipTranscriptFile;
668
760
  const privacyRedaction = {};
669
761
  const privacyDetectors = [];
670
762
  for (let index = 0; index < args.length; index += 1) {
@@ -684,6 +776,8 @@ function parseRunOptions(args) {
684
776
  } else if (argument === "--idempotency-key") {
685
777
  idempotencyKey = optionValue2(args, index, argument);
686
778
  index += 1;
779
+ } else if (argument === "--no-webhook") {
780
+ deliverWebhook = false;
687
781
  } else if (argument === "--wait") {
688
782
  wait = true;
689
783
  } else if (argument === "--timeout-ms") {
@@ -794,6 +888,36 @@ function parseRunOptions(args) {
794
888
  } else if (argument === "--image-min-quality") {
795
889
  image.minQuality = boundedNumber(args, index, argument, 1, 100, true);
796
890
  index += 1;
891
+ } else if (argument === "--clip-start") {
892
+ clip.startTimeSec = boundedNumber(args, index, argument, 0, 604800);
893
+ index += 1;
894
+ } else if (argument === "--clip-duration") {
895
+ clip.durationSec = boundedNumber(args, index, argument, 0.1, 300);
896
+ index += 1;
897
+ } else if (argument === "--clip-layout") {
898
+ const value = optionValue2(args, index, argument);
899
+ if (value !== "original" && value !== "vertical_blur") {
900
+ throw new UsageError("--clip-layout must be original or vertical_blur");
901
+ }
902
+ clip.layout = value;
903
+ index += 1;
904
+ } else if (argument === "--clip-captions") {
905
+ clip.burnCaptions = true;
906
+ } else if (argument === "--clip-transcript") {
907
+ clipTranscriptFile = optionValue2(args, index, argument);
908
+ index += 1;
909
+ } else if (argument === "--clip-min-duration") {
910
+ clipAnalysis.minDurationSec = boundedNumber(args, index, argument, 1, 300);
911
+ index += 1;
912
+ } else if (argument === "--clip-max-duration") {
913
+ clipAnalysis.maxDurationSec = boundedNumber(args, index, argument, 1, 300);
914
+ index += 1;
915
+ } else if (argument === "--clip-count") {
916
+ clipAnalysis.maxCandidates = boundedNumber(args, index, argument, 1, 20, true);
917
+ index += 1;
918
+ } else if (argument === "--clip-keyword") {
919
+ (clipAnalysis.keywords ??= []).push(optionValue2(args, index, argument));
920
+ index += 1;
797
921
  } else if (argument === "--audiogram-artwork") {
798
922
  audiogram.artworkSource = optionValue2(args, index, argument);
799
923
  index += 1;
@@ -994,6 +1118,33 @@ function parseRunOptions(args) {
994
1118
  }
995
1119
  }
996
1120
  const hasPrivacyOptions = privacyDetectors.length > 0 || Object.keys(privacyRedaction).length > 0;
1121
+ const clipPreset = outputs.length === 1 && outputs[0]?.kind === "preset" ? outputs[0].value : void 0;
1122
+ const hasClip = Object.keys(clip).length > 0 || clipPreset === "video_clip_v1";
1123
+ const hasClipAnalysis = Object.keys(clipAnalysis).length > 0 || clipPreset === "clip_candidates_v1";
1124
+ if (hasClip && (recipe || clipPreset !== "video_clip_v1" || hasClipAnalysis)) {
1125
+ throw new UsageError("clip render options require exactly one video_clip_v1 --preset");
1126
+ }
1127
+ if (hasClip && (clip.startTimeSec === void 0 || clip.durationSec === void 0)) {
1128
+ throw new UsageError("video_clip_v1 requires --clip-start and --clip-duration");
1129
+ }
1130
+ if (hasClipAnalysis && (recipe || clipPreset !== "clip_candidates_v1")) {
1131
+ throw new UsageError("clip analysis options require exactly one clip_candidates_v1 --preset");
1132
+ }
1133
+ if ((clipAnalysis.minDurationSec ?? 15) > (clipAnalysis.maxDurationSec ?? 60)) {
1134
+ throw new UsageError("--clip-min-duration must not exceed --clip-max-duration");
1135
+ }
1136
+ if ((clipAnalysis.keywords?.length ?? 0) > 20) {
1137
+ throw new UsageError("At most 20 --clip-keyword values are supported");
1138
+ }
1139
+ if (clipAnalysis.keywords?.some((keyword) => !keyword.trim() || Buffer.byteLength(keyword, "utf8") > 100)) {
1140
+ throw new UsageError("Each --clip-keyword must contain 1 to 100 UTF-8 bytes of nonblank text");
1141
+ }
1142
+ if (clipTranscriptFile && !hasClip && !hasClipAnalysis) {
1143
+ throw new UsageError("--clip-transcript requires a clipping preset");
1144
+ }
1145
+ if (clip.burnCaptions && !clipTranscriptFile) {
1146
+ throw new UsageError("--clip-captions requires --clip-transcript with source timestamps");
1147
+ }
997
1148
  if (hasPrivacyOptions) {
998
1149
  if (recipe) throw new UsageError("privacy-redaction options cannot be combined with --recipe");
999
1150
  if (privacyDetectors.length === 0) {
@@ -1012,6 +1163,7 @@ function parseRunOptions(args) {
1012
1163
  ...recipe === void 0 ? {} : { recipe },
1013
1164
  ...metadata === void 0 ? {} : { metadata },
1014
1165
  ...idempotencyKey === void 0 ? {} : { idempotencyKey },
1166
+ ...deliverWebhook === void 0 ? {} : { deliverWebhook },
1015
1167
  wait,
1016
1168
  ...timeoutMs === void 0 ? {} : { timeoutMs },
1017
1169
  ...download === void 0 ? {} : { download },
@@ -1021,6 +1173,9 @@ function parseRunOptions(args) {
1021
1173
  ...hasPlaceholderOptions ? { placeholders } : {},
1022
1174
  ...hasContactSheetOptions ? { contactSheet } : {},
1023
1175
  ...hasImageOptions ? { image } : {},
1176
+ ...hasClip ? { clip } : {},
1177
+ ...hasClipAnalysis ? { clipAnalysis } : {},
1178
+ ...clipTranscriptFile ? { clipTranscriptFile } : {},
1024
1179
  ...hasAudiogramOptions || audiogramSelections.length > 0 ? { audiogram } : {},
1025
1180
  ...hasPrivacyOptions ? { privacyRedaction } : {}
1026
1181
  };
@@ -1047,7 +1202,7 @@ async function resolveAudiogram(value, uploads) {
1047
1202
  ...captionsSource === void 0 ? {} : { captionsSource, burnCaptions: true }
1048
1203
  };
1049
1204
  }
1050
- async function resolveOutputs(selections, capabilitiesClient, animation, placeholders, contactSheet, image, audiogram, privacyRedaction) {
1205
+ async function resolveOutputs(selections, capabilitiesClient, animation, placeholders, contactSheet, image, audiogram, privacyRedaction, clip, clipAnalysis) {
1051
1206
  if (!privacyRedaction && !selections.some((selection) => selection.kind === "preset")) {
1052
1207
  return selections.map((selection) => selection.value);
1053
1208
  }
@@ -1084,6 +1239,8 @@ async function resolveOutputs(selections, capabilitiesClient, animation, placeho
1084
1239
  }
1085
1240
  if (audiogram) output3.audiogram = audiogram;
1086
1241
  if (privacyRedaction) output3.privacyRedaction = privacyRedaction;
1242
+ if (clip) output3.clip = clip;
1243
+ if (clipAnalysis) output3.clipAnalysis = clipAnalysis;
1087
1244
  return output3;
1088
1245
  });
1089
1246
  }
@@ -1103,6 +1260,7 @@ function detailsProjection2(job) {
1103
1260
  id: job.id,
1104
1261
  status: job.status,
1105
1262
  tier: job.tier,
1263
+ ...job.deliverWebhook === void 0 ? {} : { deliverWebhook: job.deliverWebhook },
1106
1264
  usage: job.usage,
1107
1265
  billing: job.billing,
1108
1266
  bundle: {
@@ -1146,6 +1304,14 @@ async function runCommand(args, dependencies) {
1146
1304
  isLocalSource || hasLocalAudiogramAsset ? "Uploading local media and creating job\u2026" : "Creating job\u2026"
1147
1305
  );
1148
1306
  try {
1307
+ if ((options.clip || options.clipAnalysis) && !("getClipCandidates" in dependencies.jobs)) {
1308
+ throw new UsageError("Clipping requires the MediaRuntime Node SDK release with getClipCandidates support");
1309
+ }
1310
+ if (options.clipTranscriptFile) {
1311
+ const transcript = await readClipTranscript(options.clipTranscriptFile);
1312
+ if (options.clip) options.clip.transcript = transcript;
1313
+ if (options.clipAnalysis) options.clipAnalysis.transcript = transcript;
1314
+ }
1149
1315
  const audiogram = options.audiogram === void 0 ? void 0 : await resolveAudiogram(options.audiogram, dependencies.uploads);
1150
1316
  const outputs = options.recipe ? void 0 : await resolveOutputs(
1151
1317
  options.outputs,
@@ -1155,14 +1321,18 @@ async function runCommand(args, dependencies) {
1155
1321
  options.contactSheet,
1156
1322
  options.image,
1157
1323
  audiogram,
1158
- options.privacyRedaction
1324
+ options.privacyRedaction,
1325
+ options.clip,
1326
+ options.clipAnalysis
1159
1327
  );
1160
1328
  const params = {
1161
1329
  source: options.source,
1162
1330
  ...outputs === void 0 ? {} : { outputs },
1163
1331
  ...options.recipe === void 0 ? {} : { recipe: options.recipe },
1164
1332
  ...options.metadata === void 0 ? {} : { metadata: options.metadata },
1165
- ...options.idempotencyKey === void 0 ? {} : { idempotencyKey: options.idempotencyKey }
1333
+ ...options.idempotencyKey === void 0 ? {} : { idempotencyKey: options.idempotencyKey },
1334
+ // Omit by default so normal account webhook delivery stays unchanged.
1335
+ ...options.deliverWebhook === void 0 ? {} : { deliverWebhook: options.deliverWebhook }
1166
1336
  };
1167
1337
  const submitted = await dependencies.jobs.create(params);
1168
1338
  if (!options.wait) {
@@ -2032,7 +2202,7 @@ async function runTriggerCommand(args, dependencies = {}) {
2032
2202
  writeStdout: dependencies.writeStdout ?? ((text) => process.stdout.write(text))
2033
2203
  };
2034
2204
  const secret = await resolveSecret(parsed, deps);
2035
- const timestamp = Math.floor(deps.now() / 1e3);
2205
+ const timestamp2 = Math.floor(deps.now() / 1e3);
2036
2206
  const unique = deps.randomUUID();
2037
2207
  const eventId = `evt_cli_${unique}`;
2038
2208
  const jobId = parsed.jobId ?? `job_cli_${unique}`;
@@ -2041,9 +2211,9 @@ async function runTriggerCommand(args, dependencies = {}) {
2041
2211
  eventId,
2042
2212
  jobId,
2043
2213
  accountId,
2044
- timestamp
2214
+ timestamp: timestamp2
2045
2215
  });
2046
- const signed = signSyntheticWebhook(payload, { eventId, timestamp, secret });
2216
+ const signed = signSyntheticWebhook(payload, { eventId, timestamp: timestamp2, secret });
2047
2217
  const response = await deps.fetch(parsed.destination, {
2048
2218
  method: "POST",
2049
2219
  headers: signed.headers,
@@ -2117,11 +2287,12 @@ function createActivityIndicator(write, enabled) {
2117
2287
  }
2118
2288
 
2119
2289
  // src/cli.ts
2120
- var VERSION = "1.2.0";
2290
+ var VERSION = "1.5.0";
2121
2291
  var HELP = `MediaRuntime CLI
2122
2292
 
2123
2293
  Usage:
2124
- mediaruntime run <source> (--recipe <name[@version]> | --output <alias> | --preset <name>) [...] [--wait]
2294
+ mediaruntime run <source> (--recipe <name[@version]> | --output <alias> | --preset <name>) [...] [--no-webhook] [--wait]
2295
+ --no-webhook disables callbacks for this job; normal billing applies.
2125
2296
  Animation presets: [--animation-width N] [--animation-fps N] [--animation-start SEC]
2126
2297
  [--animation-duration SEC] [--animation-loop N] [--animation-quality N]
2127
2298
  Placeholder preset: [--placeholder-max-dimension N] [--placeholder-time SEC]
@@ -2133,6 +2304,11 @@ Usage:
2133
2304
  [--image-width PX] [--image-height PX] [--image-mode fit|fill|cover|contain]
2134
2305
  [--image-format jpg|png|webp|avif] [--image-quality N]
2135
2306
  [--image-max-bytes N] [--image-min-quality N]
2307
+ Clip render: --preset video_clip_v1 --clip-start SEC --clip-duration SEC
2308
+ [--clip-layout original|vertical_blur] [--clip-transcript <plan.json|captions.srt|captions.vtt>] [--clip-captions]
2309
+ Clip suggestions: --preset clip_candidates_v1 [--clip-min-duration SEC]
2310
+ [--clip-max-duration SEC] [--clip-count 1..20] [--clip-keyword TEXT]
2311
+ [--clip-transcript <plan.json|captions.srt|captions.vtt>]
2136
2312
  Audiogram preset: --audiogram-artwork <source> [--audiogram-captions <source>]
2137
2313
  [--audiogram-layout square|portrait|landscape]
2138
2314
  [--audiogram-fit contain|cover|blurred_background]