@mrgoonie/multix 0.13.0 → 0.14.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
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.14.0](https://github.com/mrgoonie/multix-cli/compare/v0.13.0...v0.14.0) (2026-10-11)
9
+
10
+
11
+ ### Added
12
+
13
+ * **doc:** read convert input from piped stdin ([#84](https://github.com/mrgoonie/multix-cli/issues/84)) ([4596f7f](https://github.com/mrgoonie/multix-cli/commit/4596f7f09da2e8e7a95582296c220f4e007e3171))
14
+
15
+
16
+ ### Fixed
17
+
18
+ * **heygen:** harden base URL, output downloads and multi-output paths ([#83](https://github.com/mrgoonie/multix-cli/issues/83)) ([fea7a95](https://github.com/mrgoonie/multix-cli/commit/fea7a950bdf37836a5344301829b028efe4c9a6d))
19
+
8
20
  ## [0.13.0](https://github.com/mrgoonie/multix-cli/compare/v0.12.0...v0.13.0) (2026-10-11)
9
21
 
10
22
 
package/README.md CHANGED
@@ -71,7 +71,7 @@ Set at least one provider key. Add to `.env` in your project root or `~/.multix/
71
71
  | `FAL_IMAGE_MODEL` | No | Default fal image model (default `fal-ai/flux/schnell`) |
72
72
  | `FAL_VIDEO_MODEL` | No | Default fal text-to-video model (default `fal-ai/kling-video/v1.6/standard/text-to-video`) |
73
73
  | `HEYGEN_API_KEY` | For HeyGen | [HeyGen API settings](https://app.heygen.com/developers/api). API usage is billed separately from HeyGen web plans |
74
- | `HEYGEN_BASE_URL` | No | Override the HeyGen API base (default `https://api.heygen.com`; proxies/tests) |
74
+ | `HEYGEN_BASE_URL` | No | Override the HeyGen API base (default `https://api.heygen.com`; proxies/tests). Must be `https://`, except `http://` on `localhost`/`127.0.0.1`/`[::1]` |
75
75
  | `FAL_VIDEO_IMAGE_MODEL` | No | Default fal image-to-video model, used when `--image-url` is passed without `-m` (default `fal-ai/kling-video/v1.6/standard/image-to-video`) |
76
76
  | `ATLASCLOUD_API_KEY` | For Atlas Cloud | [Atlas Cloud](https://www.atlascloud.ai/console/api-keys) |
77
77
  | `ATLASCLOUD_BASE_URL` | No | Override Atlas Cloud media API base (default `https://api.atlascloud.ai/api/v1`) |
@@ -581,7 +581,7 @@ multix heygen video --avatar <look-id> --audio ./voiceover.mp3
581
581
 
582
582
  # Translation (one job per language; all are downloaded), lipsync, TTS
583
583
  multix heygen languages
584
- multix heygen translate ./talk.mp4 --languages "Spanish, Chinese (Cantonese, Traditional)" [--mode speed|precision] [--output <dir>]
584
+ multix heygen translate ./talk.mp4 --languages "Spanish, Chinese (Cantonese, Traditional)" [--mode speed|precision] [--output <dir|dub.mp4>] # dub.mp4 -> dub-Spanish.mp4, ...
585
585
  multix heygen lipsync --video <ref> --audio <ref>
586
586
  multix heygen tts "Welcome to the show." --voice <voice-id> [--output hello.mp3]
587
587
 
@@ -624,13 +624,17 @@ multix media batch --input-dir <dir> --output-dir <dir> [--quality 85] [--max-wi
624
624
  ### `multix doc`
625
625
 
626
626
  ```bash
627
- multix doc convert --input <files...> [--output <path>] [--auto-name] [--model gemini-3.6-flash] [--prompt <str>] [-v]
627
+ multix doc convert --input <files...> [--output <path>] [--auto-name] [--stdin-ext <ext>] [--model gemini-3.6-flash] [--prompt <str>] [-v]
628
+
629
+ # Read piped stdin with "-" (PDF, images and text auto-detected; else pass --stdin-ext, e.g. pdf; "-" only once, ./- for a file named -)
630
+ cat report.pdf | multix doc convert --input - --output report.md
628
631
  ```
629
632
 
630
633
  Converts PDFs, DOCX/XLSX/PPTX, images, and HTML/text files to Markdown via Gemini.
631
634
  Multiple inputs are concatenated with `---` separators into a single output.
635
+ Piped stdin is labelled `<stdin>` in the output and error messages; its temp file is removed when the command ends.
632
636
 
633
- **Default output:** `./multix-output/document-extraction.md`
637
+ **Default output:** `./multix-output/document-extraction.md` (with `--auto-name`: `<input-basename>-extraction.md`, or `stdin-extraction.md` for piped input)
634
638
 
635
639
  ## Output
636
640
 
package/dist/cli.js CHANGED
@@ -1428,14 +1428,14 @@ function detectThumbUrl(obj) {
1428
1428
  }
1429
1429
  return null;
1430
1430
  }
1431
- async function downloadThumbBeside(thumbUrl, videoPath, logger) {
1431
+ async function downloadThumbBeside(thumbUrl, videoPath, logger, download = downloadFile) {
1432
1432
  const match = thumbUrl.match(IMAGE_EXT_RE);
1433
1433
  const ext = match?.[1] ? match[1].toLowerCase().replace("jpeg", "jpg") : "jpg";
1434
1434
  const dir = path6.dirname(videoPath);
1435
1435
  const base = path6.basename(videoPath, path6.extname(videoPath));
1436
1436
  const dest = path6.join(dir, `${base}_thumb.${ext}`);
1437
1437
  try {
1438
- await downloadFile(thumbUrl, dest);
1438
+ await download(thumbUrl, dest);
1439
1439
  logger?.success(`Saved thumb ${dest}`);
1440
1440
  return dest;
1441
1441
  } catch (e) {
@@ -1450,7 +1450,7 @@ async function maybeDownloadThumb(source, videoPath, opts = {}) {
1450
1450
  opts.logger?.debug("No thumbnail URL detected in provider response");
1451
1451
  return null;
1452
1452
  }
1453
- const saved = await downloadThumbBeside(url, videoPath, opts.logger);
1453
+ const saved = await downloadThumbBeside(url, videoPath, opts.logger, opts.download);
1454
1454
  if (saved && opts.copyTo) {
1455
1455
  const copyExt = path6.extname(saved);
1456
1456
  const copyBase = path6.basename(opts.copyTo, path6.extname(opts.copyTo));
@@ -4611,11 +4611,15 @@ function trackTempDir(dir) {
4611
4611
  for (const s of SIGNALS) process.once(s, onSignal);
4612
4612
  return dispose;
4613
4613
  }
4614
+ var DEFAULT_FLAG = "--files";
4615
+ var DEFAULT_EXAMPLE = "cat clip.mp3 | multix gemini transcribe --files -";
4616
+ var DEFAULT_EXT_EXAMPLE = "mp3";
4614
4617
  async function stdinToTempFile(opts = {}) {
4615
4618
  const input = opts.input ?? process.stdin;
4619
+ const flag = opts.flag ?? DEFAULT_FLAG;
4616
4620
  if (input.isTTY) {
4617
4621
  throw new ValidationError(
4618
- "`--files -` reads piped input, but stdin is a terminal. Example: cat clip.mp3 | multix gemini transcribe --files -"
4622
+ `\`${flag} -\` reads piped input, but stdin is a terminal. Example: ${opts.example ?? DEFAULT_EXAMPLE}`
4619
4623
  );
4620
4624
  }
4621
4625
  const forcedExt = opts.ext ? normalizeExt(opts.ext) : void 0;
@@ -4624,7 +4628,8 @@ async function stdinToTempFile(opts = {}) {
4624
4628
  const cleanup = trackTempDir(dir);
4625
4629
  const rawPath = path23.join(dir, "input");
4626
4630
  const hint = setTimeout(() => {
4627
- process.stderr.write("Waiting for data on stdin (`--files -`)... press Ctrl+C to cancel\n");
4631
+ process.stderr.write(`Waiting for data on stdin (\`${flag} -\`)... press Ctrl+C to cancel
4632
+ `);
4628
4633
  }, WAIT_HINT_MS);
4629
4634
  hint.unref?.();
4630
4635
  let head = Buffer.alloc(0);
@@ -4651,7 +4656,7 @@ async function stdinToTempFile(opts = {}) {
4651
4656
  clearTimeout(hint);
4652
4657
  fs20.closeSync(fd);
4653
4658
  }
4654
- if (size === 0) throw new ValidationError("`--files -` was given but stdin was empty");
4659
+ if (size === 0) throw new ValidationError(`\`${flag} -\` was given but stdin was empty`);
4655
4660
  const detectedExt = forcedExt ?? detectExtension(head);
4656
4661
  const finalPath = path23.join(dir, `stdin${detectedExt ?? ".bin"}`);
4657
4662
  fs20.renameSync(rawPath, finalPath);
@@ -4665,10 +4670,11 @@ function labelError(message, input) {
4665
4670
  return input.path === input.label ? message : message.split(input.path).join(input.label);
4666
4671
  }
4667
4672
  async function resolveFileInputs(files, opts = {}) {
4673
+ const flag = opts.flag ?? DEFAULT_FLAG;
4668
4674
  const stdinCount = files.filter((f) => f === STDIN_ARG).length;
4669
- if (stdinCount > 1) throw new ValidationError("`-` (stdin) can only be passed once to --files");
4675
+ if (stdinCount > 1) throw new ValidationError(`\`-\` (stdin) can only be passed once to ${flag}`);
4670
4676
  if (opts.stdinExt && stdinCount === 0) {
4671
- throw new ValidationError("--stdin-ext only applies when --files includes `-`");
4677
+ throw new ValidationError(`--stdin-ext only applies when ${flag} includes \`-\``);
4672
4678
  }
4673
4679
  for (const f of files) {
4674
4680
  if (f !== STDIN_ARG && !fs20.existsSync(f)) throw new ValidationError(`File not found: ${f}`);
@@ -4680,12 +4686,14 @@ async function resolveFileInputs(files, opts = {}) {
4680
4686
  const stdin = await stdinToTempFile({
4681
4687
  input: opts.input,
4682
4688
  ext: opts.stdinExt,
4683
- maxBytes: opts.maxBytes
4689
+ maxBytes: opts.maxBytes,
4690
+ flag: opts.flag,
4691
+ example: opts.example
4684
4692
  });
4685
4693
  if (!stdin.detected) {
4686
4694
  stdin.cleanup();
4687
4695
  throw new ValidationError(
4688
- "Could not detect the type of piped stdin; pass --stdin-ext (e.g. --stdin-ext mp3)"
4696
+ `Could not detect the type of piped stdin; pass --stdin-ext (e.g. --stdin-ext ${opts.extExample ?? DEFAULT_EXT_EXAMPLE})`
4689
4697
  );
4690
4698
  }
4691
4699
  return {
@@ -6072,8 +6080,10 @@ var HEYGEN_OPERATIONS = {
6072
6080
  };
6073
6081
 
6074
6082
  // src/providers/heygen/node-helpers.ts
6083
+ import { once as once2 } from "events";
6075
6084
  import fs29 from "fs";
6076
6085
  import path32 from "path";
6086
+ import { pipeline as pipeline2 } from "stream/promises";
6077
6087
  init_errors();
6078
6088
  init_redact();
6079
6089
 
@@ -6503,6 +6513,7 @@ function videoState(video, kind = "video", id = video.id, opts = {}) {
6503
6513
  return state;
6504
6514
  }
6505
6515
  function translationState(t) {
6516
+ const audioOnly = !str(t.video_url) && !!str(t.audio_url);
6506
6517
  return {
6507
6518
  kind: "translation",
6508
6519
  id: t.id,
@@ -6510,6 +6521,7 @@ function translationState(t) {
6510
6521
  done: t.status === "completed" || t.status === "failed",
6511
6522
  succeeded: t.status === "completed",
6512
6523
  outputUrl: str(t.video_url) ?? str(t.audio_url),
6524
+ ...audioOnly ? { outputIsAudio: true } : {},
6513
6525
  captionUrl: str(t.srt_caption_url) ?? str(t.vtt_caption_url),
6514
6526
  outputLanguage: str(t.output_language),
6515
6527
  failureMessage: str(t.failure_message),
@@ -6632,13 +6644,29 @@ function getJobState(client, kind, id, opts = {}) {
6632
6644
  return createJobStatusReader(client, kind, id, opts)();
6633
6645
  }
6634
6646
  var DEFAULT_POLL_INTERVAL_MS = 1e4;
6635
- function waitForJob(client, kind, id, opts) {
6636
- const { burnCaptions, ...poll2 } = opts;
6647
+ var STAGGER_STEP_MS = 500;
6648
+ var STAGGER_MAX_MS = 5e3;
6649
+ function staggerOffsetMs(index, intervalMs) {
6650
+ if (!Number.isFinite(index) || index <= 0) return 0;
6651
+ const step = Math.min(STAGGER_STEP_MS, Math.max(0, intervalMs));
6652
+ return Math.min(index * step, STAGGER_MAX_MS);
6653
+ }
6654
+ var defaultSleep3 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
6655
+ async function waitForJob(client, kind, id, opts) {
6656
+ const { burnCaptions, startDelayMs = 0, ...poll2 } = opts;
6657
+ let pendingDelay = Math.min(Math.max(0, startDelayMs), Math.floor(poll2.timeoutMs / 2));
6658
+ const read = createJobStatusReader(client, kind, id, { burnCaptions });
6637
6659
  return pollUntil({
6638
6660
  intervalMs: DEFAULT_POLL_INTERVAL_MS,
6639
6661
  maxIntervalMs: 3e4,
6640
6662
  ...poll2,
6641
- fetchStatus: createJobStatusReader(client, kind, id, { burnCaptions }),
6663
+ fetchStatus: async (ctx) => {
6664
+ const delay = pendingDelay;
6665
+ if (delay <= 0) return read(ctx);
6666
+ pendingDelay = 0;
6667
+ await (poll2.sleep ?? defaultSleep3)(delay);
6668
+ return read({ remainingMs: Math.max(1, ctx.remainingMs - delay) });
6669
+ },
6642
6670
  isTerminal: (s) => s.done
6643
6671
  });
6644
6672
  }
@@ -6662,6 +6690,7 @@ function resumeCommand2(kind, id, opts = {}) {
6662
6690
 
6663
6691
  // src/providers/heygen/request-builders.ts
6664
6692
  init_errors();
6693
+ init_redact();
6665
6694
  function oneOf(value, allowed, flag) {
6666
6695
  if (value === void 0) return void 0;
6667
6696
  if (!allowed.includes(value)) {
@@ -6683,6 +6712,30 @@ function requireHttps(url, flag) {
6683
6712
  if (!/^https:\/\//i.test(url)) throw new ValidationError(`${flag} must be an https:// URL`);
6684
6713
  return url;
6685
6714
  }
6715
+ function isHttpsUrl(url) {
6716
+ try {
6717
+ return new URL(url).protocol === "https:";
6718
+ } catch {
6719
+ return false;
6720
+ }
6721
+ }
6722
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
6723
+ function checkBaseUrl(raw, variable) {
6724
+ const value = raw.trim();
6725
+ let url;
6726
+ try {
6727
+ url = new URL(value);
6728
+ } catch {
6729
+ throw new ConfigError(`${variable} must be an absolute https:// URL`);
6730
+ }
6731
+ const loopbackHttp = url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
6732
+ if (url.protocol !== "https:" && !loopbackHttp) {
6733
+ throw new ConfigError(
6734
+ `${variable} must use https:// (got ${redactUrl(value)}); plain http:// is only allowed for localhost, 127.0.0.1, or [::1]`
6735
+ );
6736
+ }
6737
+ return value;
6738
+ }
6686
6739
  function classifyMediaRef(value) {
6687
6740
  const v = value.trim();
6688
6741
  if (!v) throw new ValidationError("Media reference must not be empty");
@@ -6948,9 +7001,11 @@ function requireHeygenKey() {
6948
7001
  return key;
6949
7002
  }
6950
7003
  function createHeygenClientFromEnv() {
7004
+ const apiKey = requireHeygenKey();
7005
+ const override = resolveKey("HEYGEN_BASE_URL")?.trim();
6951
7006
  return new HeygenClient({
6952
- apiKey: requireHeygenKey(),
6953
- baseUrl: resolveKey("HEYGEN_BASE_URL") ?? DEFAULT_HEYGEN_BASE_URL
7007
+ apiKey,
7008
+ baseUrl: override ? checkBaseUrl(override, "HEYGEN_BASE_URL") : DEFAULT_HEYGEN_BASE_URL
6954
7009
  });
6955
7010
  }
6956
7011
  async function uploadLocalFile(client, filePath, logger) {
@@ -7012,9 +7067,15 @@ function addWaitOptions(cmd, defaultTimeoutMs = DEFAULT_WAIT_TIMEOUT_MS) {
7012
7067
  "--poll-interval <ms>",
7013
7068
  "Initial poll interval (backs off to 30s)",
7014
7069
  String(DEFAULT_POLL_INTERVAL_MS)
7015
- ).option("--no-download", "Do not download outputs; print output URLs instead").option("--output <path>", "Output file (or directory when several outputs are produced)").option("--captions", "Also download the caption/subtitle file when one is available").option("--no-thumb", "Skip downloading the video thumbnail").option("--json", "Print a JSON summary instead of human-readable output").option("-v, --verbose", "Verbose logging");
7016
- }
7017
- var OUTPUT_EXTS = /* @__PURE__ */ new Set(["mp4", "webm", "mov", "mp3", "wav", "m4a", "aac", "srt", "vtt"]);
7070
+ ).option("--no-download", "Do not download outputs; print output URLs instead").option(
7071
+ "--output <path>",
7072
+ "Output file or directory. A path ending in / or \\, an existing directory, or a path without an extension is a directory; with several outputs, a media file name gets a per-language suffix (dub.mp4 -> dub-Spanish.mp4)"
7073
+ ).option("--captions", "Also download the caption/subtitle file when one is available").option("--no-thumb", "Skip downloading the video thumbnail").option("--json", "Print a JSON summary instead of human-readable output").option("-v, --verbose", "Verbose logging");
7074
+ }
7075
+ var VIDEO_EXTS = /* @__PURE__ */ new Set(["mp4", "webm", "mov"]);
7076
+ var AUDIO_EXTS = /* @__PURE__ */ new Set(["mp3", "wav", "m4a", "aac"]);
7077
+ var MEDIA_EXTS = /* @__PURE__ */ new Set([...VIDEO_EXTS, ...AUDIO_EXTS]);
7078
+ var OUTPUT_EXTS = /* @__PURE__ */ new Set([...MEDIA_EXTS, "srt", "vtt"]);
7018
7079
  function inferExtension(url, fallback) {
7019
7080
  try {
7020
7081
  const ext = path32.extname(new URL(url).pathname).slice(1).toLowerCase();
@@ -7026,16 +7087,57 @@ function inferExtension(url, fallback) {
7026
7087
  function safeSlug(value) {
7027
7088
  return value.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "out";
7028
7089
  }
7090
+ var MAX_REDIRECTS = 5;
7091
+ var isRedirect = (status) => [301, 302, 303, 307, 308].includes(status);
7092
+ async function downloadHttpsOnly(url, dest, timeoutMs) {
7093
+ const signal = AbortSignal.timeout(timeoutMs);
7094
+ let current = url;
7095
+ for (let hop = 0; ; hop++) {
7096
+ if (!isHttpsUrl(current)) {
7097
+ throw new HttpError(0, "refusing to follow a redirect to a non-https URL", current);
7098
+ }
7099
+ let response;
7100
+ try {
7101
+ response = await globalThis.fetch(current, { signal, redirect: "manual" });
7102
+ } catch (cause) {
7103
+ throw new HttpError(0, cause instanceof Error ? cause.message : String(cause), current);
7104
+ }
7105
+ if (response.url && !isHttpsUrl(response.url)) {
7106
+ await response.body?.cancel();
7107
+ throw new HttpError(0, "redirected to a non-https URL", response.url);
7108
+ }
7109
+ const location = response.headers.get("location");
7110
+ if (isRedirect(response.status) && location) {
7111
+ await response.body?.cancel();
7112
+ if (hop >= MAX_REDIRECTS) throw new HttpError(0, "too many redirects", url);
7113
+ current = new URL(location, current).href;
7114
+ continue;
7115
+ }
7116
+ if (!response.ok) throw new HttpError(response.status, "(download failed)", current);
7117
+ if (!response.body) throw new HttpError(0, "Response body is null", current);
7118
+ fs29.mkdirSync(path32.dirname(dest), { recursive: true });
7119
+ const fileStream = fs29.createWriteStream(dest);
7120
+ try {
7121
+ await pipeline2(response.body, fileStream);
7122
+ } catch (err2) {
7123
+ if (!fileStream.closed) await once2(fileStream, "close");
7124
+ throw err2;
7125
+ }
7126
+ return;
7127
+ }
7128
+ }
7029
7129
  async function downloadOutput(url, dest, resumeHint) {
7030
7130
  if (!url) {
7031
7131
  throw new ValidationError(`HeyGen reported success but returned no output URL. ${resumeHint}`);
7032
7132
  }
7033
- if (!/^https?:\/\//i.test(url)) {
7034
- throw new ValidationError(`HeyGen returned an invalid output URL (${redactUrl(url)})`);
7133
+ if (!isHttpsUrl(url)) {
7134
+ throw new ValidationError(
7135
+ `Refusing to download HeyGen output from a non-https URL (${redactUrl(url)})`
7136
+ );
7035
7137
  }
7036
7138
  const tmp = `${dest}.${process.pid}-${Date.now()}.part`;
7037
7139
  try {
7038
- await downloadFile(url, tmp, 6e5);
7140
+ await downloadHttpsOnly(url, tmp, 6e5);
7039
7141
  fs29.renameSync(tmp, dest);
7040
7142
  } catch (e) {
7041
7143
  fs29.rmSync(tmp, { force: true });
@@ -7052,15 +7154,55 @@ function defaultOutputPath(state, ext) {
7052
7154
  const lang = state.outputLanguage ? `-${safeSlug(state.outputLanguage)}` : "";
7053
7155
  return path32.join(getOutputDir(), `heygen-${state.kind}${lang}-${safeSlug(state.id)}.${ext}`);
7054
7156
  }
7055
- function outputPathFor(state, ext, output, count) {
7056
- if (!output) return defaultOutputPath(state, ext);
7057
- if (count > 1) {
7058
- const dir = path32.resolve(output);
7059
- fs29.mkdirSync(dir, { recursive: true });
7060
- return path32.join(dir, path32.basename(defaultOutputPath(state, ext)));
7157
+ function statOrUndefined(p) {
7158
+ try {
7159
+ return fs29.statSync(p);
7160
+ } catch {
7161
+ return void 0;
7061
7162
  }
7062
- return path32.resolve(output);
7063
7163
  }
7164
+ function isDirectoryOutput2(output, target) {
7165
+ if (/[\\/]$/.test(output)) return true;
7166
+ const stat = statOrUndefined(target);
7167
+ if (stat) return stat.isDirectory();
7168
+ return path32.extname(target) === "";
7169
+ }
7170
+ function withMediaExt(file, ext) {
7171
+ const given = path32.extname(file).slice(1).toLowerCase();
7172
+ const mismatch = VIDEO_EXTS.has(given) && AUDIO_EXTS.has(ext) || AUDIO_EXTS.has(given) && VIDEO_EXTS.has(ext);
7173
+ return mismatch ? `${file.slice(0, -(given.length + 1))}.${ext}` : file;
7174
+ }
7175
+ function outputPathFor(state, ext, output, count) {
7176
+ if (!output) return defaultOutputPath(state, ext);
7177
+ const target = path32.resolve(output);
7178
+ const givenExt = path32.extname(target);
7179
+ const isTemplate = count > 1 && MEDIA_EXTS.has(givenExt.slice(1).toLowerCase());
7180
+ if (isDirectoryOutput2(output, target) || count > 1 && !isTemplate) {
7181
+ fs29.mkdirSync(target, { recursive: true });
7182
+ return path32.join(target, path32.basename(defaultOutputPath(state, ext)));
7183
+ }
7184
+ if (!isTemplate) return withMediaExt(target, ext);
7185
+ const base = path32.basename(target, givenExt);
7186
+ const suffix = safeSlug(state.outputLanguage ?? state.id);
7187
+ return withMediaExt(path32.join(path32.dirname(target), `${base}-${suffix}${givenExt}`), ext);
7188
+ }
7189
+ function claimPath(dest, state, used) {
7190
+ const ext = path32.extname(dest);
7191
+ const stem = dest.slice(0, dest.length - ext.length);
7192
+ let candidate = dest;
7193
+ for (let n = 1; used.has(candidate.toLowerCase()); n++) {
7194
+ const id = safeSlug(state.id);
7195
+ candidate = n === 1 ? `${stem}-${id}${ext}` : `${stem}-${id}-${n}${ext}`;
7196
+ }
7197
+ used.add(candidate.toLowerCase());
7198
+ return candidate;
7199
+ }
7200
+ function captionPathFor(dest, capExt) {
7201
+ const stem = path32.join(path32.dirname(dest), path32.basename(dest, path32.extname(dest)));
7202
+ const capDest = `${stem}.${capExt}`;
7203
+ return capDest.toLowerCase() === dest.toLowerCase() ? `${stem}.captions.${capExt}` : capDest;
7204
+ }
7205
+ var downloadThumbHttpsOnly = (url, dest) => downloadHttpsOnly(url, dest, 12e4);
7064
7206
  function summarizeState(state) {
7065
7207
  const out = {
7066
7208
  kind: state.kind,
@@ -7078,7 +7220,7 @@ function summarizeState(state) {
7078
7220
  if (state.failureMessage) out.failure_message = state.failureMessage;
7079
7221
  return out;
7080
7222
  }
7081
- async function deliverOne(state, opts, count, logger, files) {
7223
+ async function deliverOne(state, opts, count, logger, files, usedPaths) {
7082
7224
  const hint = `Refresh with: ${resumeCommand2(state.kind, state.id, opts)}`;
7083
7225
  if (opts.download === false) {
7084
7226
  if (!state.outputUrl) {
@@ -7091,39 +7233,42 @@ async function deliverOne(state, opts, count, logger, files) {
7091
7233
  };
7092
7234
  return { state, files, urls };
7093
7235
  }
7094
- const ext = inferExtension(state.outputUrl ?? "", "mp4");
7095
- const dest = outputPathFor(state, ext, opts.output, count);
7236
+ const ext = inferExtension(state.outputUrl ?? "", state.outputIsAudio ? "mp3" : "mp4");
7237
+ const dest = claimPath(outputPathFor(state, ext, opts.output, count), state, usedPaths);
7096
7238
  await downloadOutput(state.outputUrl, dest, hint);
7097
7239
  logger.success(`Saved ${dest}`);
7098
7240
  files.push(dest);
7099
7241
  if (opts.captions && state.captionUrl) {
7100
7242
  const capExt = inferExtension(state.captionUrl, "srt");
7101
- const capDest = path32.join(
7102
- path32.dirname(dest),
7103
- `${path32.basename(dest, path32.extname(dest))}.${capExt}`
7104
- );
7243
+ const capDest = claimPath(captionPathFor(dest, capExt), state, usedPaths);
7105
7244
  await downloadOutput(state.captionUrl, capDest, hint);
7106
7245
  logger.success(`Saved ${capDest}`);
7107
7246
  files.push(capDest);
7108
7247
  } else if (opts.captions) {
7109
7248
  logger.warn(`No caption file available for ${state.kind} ${state.id}`);
7110
7249
  }
7111
- if (state.kind === "video" || state.kind === "agent") {
7112
- const thumb = await maybeDownloadThumb(state.raw, dest, {
7113
- skip: opts.thumb === false,
7114
- logger
7115
- });
7116
- if (thumb) files.push(thumb);
7250
+ if ((state.kind === "video" || state.kind === "agent") && opts.thumb !== false) {
7251
+ const thumbUrl = detectThumbUrl(state.raw);
7252
+ if (thumbUrl && !isHttpsUrl(thumbUrl)) {
7253
+ logger.warn(`Skipping thumbnail from a non-https URL (${redactUrl(thumbUrl)})`);
7254
+ } else {
7255
+ const thumb = await maybeDownloadThumb(state.raw, dest, {
7256
+ logger,
7257
+ download: downloadThumbHttpsOnly
7258
+ });
7259
+ if (thumb) files.push(thumb);
7260
+ }
7117
7261
  }
7118
7262
  return { state, files };
7119
7263
  }
7120
7264
  async function deliverOutputs(states, opts, logger) {
7121
7265
  const finished = [];
7122
7266
  const failures = [];
7267
+ const usedPaths = /* @__PURE__ */ new Set();
7123
7268
  for (const state of states) {
7124
7269
  const files = [];
7125
7270
  try {
7126
- finished.push(await deliverOne(state, opts, states.length, logger, files));
7271
+ finished.push(await deliverOne(state, opts, states.length, logger, files, usedPaths));
7127
7272
  } catch (e) {
7128
7273
  failures.push(`${state.kind} ${state.id}: ${e instanceof Error ? e.message : String(e)}`);
7129
7274
  if (files.length > 0) finished.push({ state, files });
@@ -7176,9 +7321,10 @@ async function completeJobs(client, kind, ids, opts, logger) {
7176
7321
  );
7177
7322
  const settled = await Promise.allSettled(
7178
7323
  ids.map(
7179
- (id) => waitForJob(client, kind, id, {
7324
+ (id, index) => waitForJob(client, kind, id, {
7180
7325
  timeoutMs,
7181
7326
  intervalMs,
7327
+ startDelayMs: staggerOffsetMs(index, intervalMs),
7182
7328
  maxIntervalMs: Math.max(intervalMs, 3e4),
7183
7329
  burnCaptions: opts.burnCaptions,
7184
7330
  resumeHint: `Resume with: ${resumeCommand2(kind, id, opts)}`,
@@ -10079,8 +10225,8 @@ async function downloadOpenRouterVideo(opts) {
10079
10225
  }
10080
10226
  fs43.mkdirSync(path50.dirname(dest), { recursive: true });
10081
10227
  const ws = fs43.createWriteStream(dest);
10082
- const { pipeline: pipeline3 } = await import("stream/promises");
10083
- await pipeline3(r.body, ws);
10228
+ const { pipeline: pipeline4 } = await import("stream/promises");
10229
+ await pipeline4(r.body, ws);
10084
10230
  logger.success(`Saved ${dest}`);
10085
10231
  if (opts.output) {
10086
10232
  fs43.mkdirSync(path50.dirname(path50.resolve(opts.output)), { recursive: true });
@@ -10408,7 +10554,7 @@ function assertSucceeded(prediction2) {
10408
10554
  PROVIDER
10409
10555
  );
10410
10556
  }
10411
- var defaultSleep3 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10557
+ var defaultSleep4 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10412
10558
  var ReplicateClient = class {
10413
10559
  apiKey;
10414
10560
  baseUrl;
@@ -10419,7 +10565,7 @@ var ReplicateClient = class {
10419
10565
  this.apiKey = opts.apiKey;
10420
10566
  this.baseUrl = (opts.baseUrl || DEFAULT_REPLICATE_BASE_URL).replace(/\/+$/, "");
10421
10567
  this.doFetch = opts.fetch ?? ((input, init) => globalThis.fetch(input, init));
10422
- this.sleep = opts.sleep ?? defaultSleep3;
10568
+ this.sleep = opts.sleep ?? defaultSleep4;
10423
10569
  }
10424
10570
  headers(extra = {}) {
10425
10571
  return { accept: "application/json", authorization: `Bearer ${this.apiKey}`, ...extra };
@@ -10610,11 +10756,11 @@ init_errors();
10610
10756
  // src/providers/replicate/node-outputs.ts
10611
10757
  init_errors();
10612
10758
  init_redact();
10613
- import { once as once2 } from "events";
10759
+ import { once as once3 } from "events";
10614
10760
  import fs44 from "fs";
10615
10761
  import path51 from "path";
10616
10762
  import { Readable } from "stream";
10617
- import { pipeline as pipeline2 } from "stream/promises";
10763
+ import { pipeline as pipeline3 } from "stream/promises";
10618
10764
 
10619
10765
  // src/providers/replicate/output.ts
10620
10766
  init_redact();
@@ -10785,12 +10931,12 @@ async function writeResponse(response, dest) {
10785
10931
  if (!response.body) throw new HttpError(0, "Response body is null", response.url);
10786
10932
  const fileStream = fs44.createWriteStream(dest);
10787
10933
  try {
10788
- await pipeline2(
10934
+ await pipeline3(
10789
10935
  Readable.fromWeb(response.body),
10790
10936
  fileStream
10791
10937
  );
10792
10938
  } catch (err2) {
10793
- if (!fileStream.closed) await once2(fileStream, "close");
10939
+ if (!fileStream.closed) await once3(fileStream, "close");
10794
10940
  throw err2;
10795
10941
  }
10796
10942
  }
@@ -11460,7 +11606,6 @@ function registerCheckCommand(program2) {
11460
11606
  }
11461
11607
 
11462
11608
  // src/commands/doc/convert.ts
11463
- init_errors();
11464
11609
  import fs47 from "fs";
11465
11610
  import path53 from "path";
11466
11611
 
@@ -11478,27 +11623,45 @@ Requirements:
11478
11623
  Output only the markdown content without any preamble or explanation.`;
11479
11624
 
11480
11625
  // src/commands/doc/convert.ts
11626
+ function autoNameBase(input) {
11627
+ if (input === STDIN_ARG) return "stdin";
11628
+ return path53.basename(input, path53.extname(input));
11629
+ }
11481
11630
  async function convertDocuments(opts) {
11482
- const { inputs, output, autoName = false, verbose, logger } = opts;
11631
+ const { output, autoName = false, verbose, logger } = opts;
11483
11632
  const model = opts.model ?? DOC_MODEL_DEFAULT;
11484
11633
  const prompt = opts.prompt ?? DEFAULT_CONVERT_PROMPT;
11485
- for (const f of inputs) {
11486
- if (!fs47.existsSync(f)) throw new ValidationError(`Input file not found: ${f}`);
11487
- }
11488
- let outputPath;
11489
- if (output) {
11490
- outputPath = path53.resolve(output);
11491
- } else if (autoName && inputs.length === 1 && inputs[0]) {
11492
- const base = path53.basename(inputs[0], path53.extname(inputs[0]));
11493
- outputPath = path53.join(getOutputDir(), `${base}-extraction.md`);
11494
- } else {
11495
- outputPath = path53.join(getOutputDir(), "document-extraction.md");
11634
+ const { inputs, cleanup } = await resolveFileInputs(opts.inputs, {
11635
+ stdinExt: opts.stdinExt,
11636
+ input: opts.stdin,
11637
+ flag: "--input",
11638
+ example: "cat report.pdf | multix doc convert --input -",
11639
+ extExample: "pdf"
11640
+ });
11641
+ let failed;
11642
+ try {
11643
+ let outputPath;
11644
+ if (output) {
11645
+ outputPath = path53.resolve(output);
11646
+ } else if (autoName && opts.inputs.length === 1 && opts.inputs[0]) {
11647
+ outputPath = path53.join(getOutputDir(), `${autoNameBase(opts.inputs[0])}-extraction.md`);
11648
+ } else {
11649
+ outputPath = path53.join(getOutputDir(), "document-extraction.md");
11650
+ }
11651
+ fs47.mkdirSync(path53.dirname(outputPath), { recursive: true });
11652
+ failed = await convertResolved(inputs, { outputPath, model, prompt, verbose, logger });
11653
+ } finally {
11654
+ cleanup();
11496
11655
  }
11497
- fs47.mkdirSync(path53.dirname(outputPath), { recursive: true });
11656
+ if (failed > 0) process.exit(1);
11657
+ }
11658
+ async function convertResolved(inputs, ctx) {
11659
+ const { outputPath, model, prompt, verbose, logger } = ctx;
11498
11660
  const results = [];
11499
11661
  for (let i = 0; i < inputs.length; i++) {
11500
- const filePath = inputs[i];
11501
- logger?.debug(`[${i + 1}/${inputs.length}] Converting: ${filePath}`);
11662
+ const input = inputs[i];
11663
+ const filePath = input.path;
11664
+ logger?.debug(`[${i + 1}/${inputs.length}] Converting: ${input.label}`);
11502
11665
  let markdown;
11503
11666
  let errorMsg;
11504
11667
  for (let attempt = 0; attempt < 3; attempt++) {
@@ -11530,7 +11693,7 @@ async function convertDocuments(opts) {
11530
11693
  markdown = extractText(resp);
11531
11694
  break;
11532
11695
  } catch (e) {
11533
- errorMsg = e instanceof Error ? e.message : String(e);
11696
+ errorMsg = labelError(e instanceof Error ? e.message : String(e), input);
11534
11697
  if (isGeminiRateLimitError(e)) break;
11535
11698
  if (attempt < 2) {
11536
11699
  const wait = 2 ** attempt * 1e3;
@@ -11539,8 +11702,11 @@ async function convertDocuments(opts) {
11539
11702
  }
11540
11703
  }
11541
11704
  }
11705
+ if (markdown === void 0) {
11706
+ logger?.error(`Failed: ${input.label} \u2014 ${errorMsg}`);
11707
+ }
11542
11708
  results.push({
11543
- file: filePath,
11709
+ file: input.label,
11544
11710
  status: markdown !== void 0 ? "success" : "error",
11545
11711
  markdown,
11546
11712
  error: errorMsg
@@ -11554,7 +11720,7 @@ async function convertDocuments(opts) {
11554
11720
  `);
11555
11721
  for (const r of results) {
11556
11722
  lines.push(`
11557
- ## ${path53.basename(r.file)}
11723
+ ## ${path53.basename(r.file).replace(/[<>]/g, "\\$&")}
11558
11724
  `);
11559
11725
  if (r.status === "success" && r.markdown) {
11560
11726
  lines.push(`${r.markdown}
@@ -11580,8 +11746,7 @@ ${"=".repeat(50)}`);
11580
11746
  console.log(` [${r.status}] ${r.file}`);
11581
11747
  }
11582
11748
  }
11583
- const failed = results.filter((r) => r.status === "error").length;
11584
- if (failed > 0) process.exit(1);
11749
+ return results.length - succeeded;
11585
11750
  }
11586
11751
  function sleep4(ms) {
11587
11752
  return new Promise((r) => setTimeout(r, ms));
@@ -11594,14 +11759,21 @@ function registerDocCommands(program2) {
11594
11759
  );
11595
11760
  doc.command("convert").description("Convert documents to Markdown using Gemini Files API").requiredOption(
11596
11761
  "--input <files...>",
11597
- "Input file(s) to convert",
11762
+ "Input file(s) to convert (use - to read piped stdin)",
11598
11763
  (val, prev) => [...prev, val],
11599
11764
  []
11600
- ).alias("-i").option("--output <path>", "Output markdown file path").option("--auto-name", "Derive output filename from input basename (single file only)").option("--model <id>", `Gemini model to use (default: ${DOC_MODEL_DEFAULT})`).option("--prompt <text>", "Custom conversion prompt").option("-v, --verbose", "Verbose logging").action(
11765
+ ).alias("-i").option(
11766
+ "--stdin-ext <ext>",
11767
+ "Extension for piped stdin when auto-detection fails (e.g. pdf, txt)"
11768
+ ).option("--output <path>", "Output markdown file path").option(
11769
+ "--auto-name",
11770
+ "Derive output filename from input basename (single file only; stdin becomes stdin-extraction.md)"
11771
+ ).option("--model <id>", `Gemini model to use (default: ${DOC_MODEL_DEFAULT})`).option("--prompt <text>", "Custom conversion prompt").option("-v, --verbose", "Verbose logging").action(
11601
11772
  async (opts) => {
11602
11773
  const logger = createLogger({ verbose: opts.verbose ?? false });
11603
11774
  await convertDocuments({
11604
11775
  inputs: opts.input,
11776
+ stdinExt: opts.stdinExt,
11605
11777
  output: opts.output,
11606
11778
  autoName: opts.autoName,
11607
11779
  model: opts.model,
@@ -11623,13 +11795,13 @@ import fs48 from "fs";
11623
11795
  import path54 from "path";
11624
11796
 
11625
11797
  // src/commands/media/detect-type.ts
11626
- var VIDEO_EXTS = /* @__PURE__ */ new Set([".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".mpg", ".mpeg"]);
11627
- var AUDIO_EXTS = /* @__PURE__ */ new Set([".mp3", ".wav", ".m4a", ".flac", ".aac", ".ogg", ".aiff"]);
11798
+ var VIDEO_EXTS2 = /* @__PURE__ */ new Set([".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".mpg", ".mpeg"]);
11799
+ var AUDIO_EXTS2 = /* @__PURE__ */ new Set([".mp3", ".wav", ".m4a", ".flac", ".aac", ".ogg", ".aiff"]);
11628
11800
  var IMAGE_EXTS2 = /* @__PURE__ */ new Set([".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif", ".gif", ".bmp"]);
11629
11801
  function extToKind(ext) {
11630
11802
  const lower = ext.toLowerCase();
11631
- if (VIDEO_EXTS.has(lower)) return "video";
11632
- if (AUDIO_EXTS.has(lower)) return "audio";
11803
+ if (VIDEO_EXTS2.has(lower)) return "video";
11804
+ if (AUDIO_EXTS2.has(lower)) return "audio";
11633
11805
  if (IMAGE_EXTS2.has(lower)) return "image";
11634
11806
  return void 0;
11635
11807
  }
@@ -11805,7 +11977,7 @@ async function optimizeVideo(opts) {
11805
11977
  }
11806
11978
 
11807
11979
  // src/commands/media/batch.ts
11808
- var ALL_EXTS = /* @__PURE__ */ new Set([...VIDEO_EXTS, ...AUDIO_EXTS, ...IMAGE_EXTS2]);
11980
+ var ALL_EXTS = /* @__PURE__ */ new Set([...VIDEO_EXTS2, ...AUDIO_EXTS2, ...IMAGE_EXTS2]);
11809
11981
  async function batchOptimize(opts) {
11810
11982
  const {
11811
11983
  inputDir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrgoonie/multix",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "AI multimodal CLI — OpenAI, Gemini, MiniMax, OpenRouter, Leonardo, BytePlus, ElevenLabs, fal.ai, Atlas Cloud, Replicate, HeyGen, ffmpeg, ImageMagick, doc-to-md",
5
5
  "type": "module",
6
6
  "bin": {
package/skill/SKILL.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: multix
3
3
  description: AI multimodal CLI — generate and edit images (OpenAI, Codex driver, Gemini Nano Banana, Imagen, MiniMax, OpenRouter, Leonardo, BytePlus Seedream, Cloudflare Workers AI FLUX.1 Schnell, fal.ai FLUX, Replicate FLUX), generate video (Gemini Veo, MiniMax Hailuo, OpenRouter via Veo/Seedance/Kling, Leonardo via Motion/Veo/Kling/Hailuo/Seedance/LTX, BytePlus Seedance, Cloudflare AI Gateway via Replicate, fal.ai queue models, Replicate direct API models incl. P-Video, HeyGen Video Agent and scripted avatar videos), video translation and lipsync (HeyGen), TTS/STT/music (OpenAI, Gemini Flash TTS incl. Gemini 3.8 with delivery-style control, MiniMax, Cloudflare Workers AI MeloTTS, ElevenLabs), 3D models (BytePlus Hyper3D/Hitem3d), analyze/transcribe media, convert documents to Markdown, optimize media via ffmpeg/ImageMagick, and self-update the CLI. Use whenever the user wants to create, edit, or transform images/video/audio/3D/documents from a CLI, mentions any of: OpenAI, Codex image driver, image-to-image, i2i, image edit, watercolor/cyberpunk style transfer, reference image, OpenRouter, Nano Banana, Flux, Seedream, Veo, Hailuo, Seedance, Kling, Cloudflare Workers AI, AI Gateway, fal.ai, Atlas Cloud, Replicate, replicate.com models, Replicate predictions, HeyGen, avatar video, talking head, Video Agent, video translation, dubbing, lipsync, video generation, text-to-video, image-to-video, i2v, ElevenLabs voice cloning, updating/upgrading multix, or asks for batch media optimization.
4
- version: 0.13.0 # x-release-please-version
4
+ version: 0.14.0 # x-release-please-version
5
5
  ---
6
6
 
7
7
  # multix — AI Multimodal CLI
@@ -194,7 +194,7 @@ multix heygen video-agent "45-second explainer for a note app" [--avatar <look-i
194
194
  multix heygen avatars [--type photo_avatar] [--cursor <token>]
195
195
  multix heygen voices [--language English]
196
196
  multix heygen video "Hello!" --avatar <look-id> [--voice <id>] [--aspect-ratio 9:16] [--engine avatar_iv|avatar_v|avatar_iii] [--burn-captions]
197
- multix heygen translate <ref> --languages "Spanish, Chinese (Cantonese, Traditional)" [--output <dir>]
197
+ multix heygen translate <ref> --languages "Spanish, Chinese (Cantonese, Traditional)" [--output <dir|dub.mp4>] # several languages: dub.mp4 -> dub-Spanish.mp4, ...
198
198
  multix heygen lipsync --video <ref> --audio <ref>
199
199
  multix heygen tts "Hello" --voice <voice-id>
200
200
  multix heygen template <template-id> --input @vars.json
@@ -354,6 +354,7 @@ multix byteplus 3d --input-image ./front.png ./side.png --prompt "Generate from
354
354
 
355
355
  # Docs → Markdown (Gemini)
356
356
  multix doc convert --input report.pdf [--auto-name] [--output ./out.md]
357
+ cat report.pdf | multix doc convert --input - --output ./out.md # "-" reads stdin; --stdin-ext <ext> (e.g. pdf) if undetected
357
358
 
358
359
  # Media optimization (ffmpeg + ImageMagick)
359
360
  multix media optimize --input <file> --output <file> [--target-size <MB>] [--quality 85] [--max-width 1920]