batchedits 1.1.0 → 1.3.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.
Files changed (3) hide show
  1. package/README.md +22 -2
  2. package/dist/cli.js +141 -28
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -19,15 +19,35 @@ Needs Node.js 18.17+ and a [BatchEdits](https://batchedits.com) account (7-day f
19
19
  | `login` / `logout` / `whoami` | Sign in through your browser; check the connection |
20
20
  | `styles` | List your styles |
21
21
  | `actions` | List editing actions and their settings |
22
- | `style create <name> [--captions] [--emojis] [--silence] [--repeats] [--crop 9:16] [--zoom] [--clips [n]]` | Create a style from flags |
22
+ | `style create <name> [--captions] [--emojis] [--silence] [--repeats] [--crop 9:16] [--zoom] [--clips [n]] [--hook] [--set action.setting=value…]` | Create a style from flags |
23
+ | `style edit <name> [same flags] [--set …] [--remove <action>…] [--name <new>]` | Change a saved style's edits and settings |
23
24
  | `upload <files or folders…>` | Upload up to 50 videos, print their ids |
24
25
  | `process <ids…> --style <name> [--yes]` | Edit videos (1 credit each, 1 per AI clip) |
25
26
  | `status [ids…] [--watch]` | Show status, or wait until done |
26
27
  | `download <ids…> [--out dir]` | Save finished videos (and their AI clips) |
27
- | `run <files or folders…> --style <name> [--out dir] [--yes]` | All of the above in one go |
28
+ | `run <files or folders…> --style <name> [--out dir] [--hooks file] [--yes]` | All of the above in one go |
28
29
 
29
30
  Every command has `--help` with an example, and `--json` for machine-readable output.
30
31
 
32
+ ### Settings
33
+
34
+ `--set action.setting=value` sets anything `batchedits actions` lists. Short action names: `captions`, `silence`, `crop`, `zoom`, `clips`, `repeats`, `hook`.
35
+
36
+ ### Hooks
37
+
38
+ `--hook` adds a text hook at the start of every video; AI writes each line. To use your own, put one hook per line in a file and pass it with `--hooks` (line 1 → first video, in upload order; a folder uploads its files A-Z):
39
+
40
+ ```bash
41
+ batchedits style create "Hooks" --captions --silence --hook --set hook.position=top
42
+ batchedits run ./raw --style "Hooks" --hooks hooks.txt --out ./edited --yes
43
+ ```
44
+
45
+ ```bash
46
+ batchedits style create "Shorts" --crop 9:16 --silence --captions \
47
+ --set captions.position=center --set captions.highlightColor=#FF6B00 --set zoom.zoomPercent=125
48
+ batchedits style edit "Shorts" --set captions.fontColor=#FFFF00 --remove zoom
49
+ ```
50
+
31
51
  ## Sign in
32
52
 
33
53
  - `batchedits login` opens BatchEdits in your browser to approve the CLI, then stores the login in `~/.config/batchedits`. The token is never printed, so agents can run commands without seeing it.
package/dist/cli.js CHANGED
@@ -355,6 +355,81 @@ async function confirm(flags, question) {
355
355
  rl.close();
356
356
  if (answer !== "y" && answer !== "yes") throw new CliError("Cancelled. Nothing was processed.", EXIT.ERROR);
357
357
  }
358
+ var ACTION_ORDER = ["crop_video", "remove_silence", "remove_repeated_sentences", "add_captions", "punch_zoom", "ai_clips", "hook"];
359
+ var ACTION_ALIASES = {
360
+ crop: "crop_video",
361
+ silence: "remove_silence",
362
+ repeats: "remove_repeated_sentences",
363
+ captions: "add_captions",
364
+ zoom: "punch_zoom",
365
+ clips: "ai_clips",
366
+ hook: "hook"
367
+ };
368
+ var SET_HELP = [
369
+ " --set <action.setting=value> Any setting from `batchedits actions` (repeatable). Adds the edit if missing.",
370
+ " captions.position=top|center|bottom captions.fontColor=#FFFFFF captions.highlightColor=#FF6B00",
371
+ " captions.fontSize=64 silence.silentThreshold=0.06 zoom.zoomPercent=125 clips.maxSeconds=60",
372
+ " hook.position=top|center|bottom hook.y=0.3 hook.duration=auto|3 hook.source=ai|manual hook.backgroundColor=#FF6B00"
373
+ ].join("\n");
374
+ var actionName = (raw) => ACTION_ALIASES[raw.trim().toLowerCase()] ?? raw.trim().toLowerCase();
375
+ var list = (v) => Array.isArray(v) ? v : typeof v === "string" ? [v] : [];
376
+ var sortActions = (a) => [...a].sort((x, y) => (ACTION_ORDER.indexOf(x.name) + 1 || 99) - (ACTION_ORDER.indexOf(y.name) + 1 || 99));
377
+ function upsert(actions, name, params) {
378
+ const existing = actions.find((a) => a.name === name);
379
+ if (!existing) return sortActions([...actions, { name, ...params ? { customParams: params } : {} }]);
380
+ return actions.map((a) => a.name === name && params ? { ...a, customParams: { ...a.customParams ?? {}, ...params } } : a);
381
+ }
382
+ function applyFlagActions(actions, flags) {
383
+ let out = actions;
384
+ if (typeof flags.crop === "string") out = upsert(out, "crop_video", { aspectRatio: flags.crop });
385
+ if (flags.silence) out = upsert(out, "remove_silence");
386
+ if (flags.repeats) out = upsert(out, "remove_repeated_sentences");
387
+ if (flags.captions || flags.emojis) out = upsert(out, "add_captions", flags.emojis ? { emojis: true } : void 0);
388
+ if (flags.zoom) out = upsert(out, "punch_zoom");
389
+ if (flags.clips) out = upsert(out, "ai_clips", typeof flags.clips === "string" ? { maxClips: Number(flags.clips) } : void 0);
390
+ if (flags.hook) out = upsert(out, "hook");
391
+ return out;
392
+ }
393
+ function parseValue(raw) {
394
+ if (raw === "true" || raw === "false") return raw === "true";
395
+ if (raw.trim() !== "" && !Number.isNaN(Number(raw))) return Number(raw);
396
+ return raw;
397
+ }
398
+ function applySets(actions, sets) {
399
+ let out = actions;
400
+ for (const entry of list(sets)) {
401
+ const m = entry.match(/^([\w-]+)\.([\w-]+)=(.*)$/s);
402
+ if (!m) throw new CliError(`Can't read --set ${entry}.`, EXIT.USAGE, "Use action.setting=value, e.g. --set captions.position=center");
403
+ out = upsert(out, actionName(m[1]), { [m[2]]: parseValue(m[3]) });
404
+ }
405
+ return out;
406
+ }
407
+ async function actionIds(api) {
408
+ const { actions } = await api.call("list_actions");
409
+ return new Map(actions.map((a) => [a.name, a.id]));
410
+ }
411
+ function toToolActions(actions, ids) {
412
+ const unknown = actions.filter((a) => !ids.has(a.name)).map((a) => a.name);
413
+ if (unknown.length) throw new CliError(`Unknown edit: ${unknown.join(", ")}.`, EXIT.USAGE, `Use one of: ${Object.keys(ACTION_ALIASES).join(", ")} (or a name from: batchedits actions).`);
414
+ return actions.map((a) => ({ id: ids.get(a.name), ...a.customParams ? { customParams: a.customParams } : {} }));
415
+ }
416
+ function describeActions(actions) {
417
+ return actions.map((a) => {
418
+ const p = Object.entries(a.customParams ?? {}).filter(([k]) => !["focusX", "focusY", "fitToVideo"].includes(k));
419
+ return p.length ? `${a.name} (${p.map(([k, v]) => `${k} ${v}`).join(", ")})` : a.name;
420
+ }).join(", ");
421
+ }
422
+ function readHooksFile(file, style, count) {
423
+ if (typeof file !== "string" || !file) return [];
424
+ if (!import_node_fs3.default.existsSync(file)) throw new CliError(`No such file: ${file}`, EXIT.USAGE, "Pass a text file with one hook per line.");
425
+ const lines = import_node_fs3.default.readFileSync(file, "utf8").split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
426
+ if (!(style.actions ?? []).some((a) => a.name === "hook")) {
427
+ info(`Note: "${style.name}" has no hook, so --hooks won't show. Add one with: batchedits style edit "${style.name}" --hook`);
428
+ }
429
+ if (lines.length < count) info(`Note: ${lines.length} hook${lines.length === 1 ? "" : "s"} for ${count} videos; the rest get an AI hook (or none, if the style's hook source is manual).`);
430
+ else if (lines.length > count) info(`Note: ${lines.length - count} extra hook line${lines.length - count === 1 ? "" : "s"} ignored.`);
431
+ return lines.slice(0, count).map((l) => l.slice(0, 120));
432
+ }
358
433
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
359
434
  function withClips(all, ids) {
360
435
  const wanted = new Set(ids);
@@ -463,7 +538,7 @@ var commands = {
463
538
  },
464
539
  "style create": {
465
540
  summary: "Create a style from simple flags",
466
- usage: "batchedits style create <name> [--captions] [--emojis] [--silence] [--crop 9:16] [--zoom] [--clips [n]] [--repeats]",
541
+ usage: "batchedits style create <name> [--captions] [--emojis] [--silence] [--crop 9:16] [--zoom] [--clips [n]] [--repeats] [--hook] [--set action.setting=value\u2026]",
467
542
  help: [
468
543
  "Creates a reusable style. Order of edits: crop, silence, repeats, captions, zoom, AI clips.",
469
544
  " --captions Auto captions (word-by-word highlight)",
@@ -473,29 +548,59 @@ var commands = {
473
548
  " --crop <ratio> Crop every video, e.g. 9:16, 1:1, 4:5",
474
549
  " --zoom Zoom in on key moments",
475
550
  " --clips [n] Cut long videos into n short clips (default 10; 1 credit per clip)",
476
- 'Example: batchedits style create "Shorts" --crop 9:16 --silence --captions --zoom'
551
+ " --hook A text hook at the start of each video (AI writes it, or pass --hooks to run/process)",
552
+ SET_HELP,
553
+ 'Example: batchedits style create "Shorts" --crop 9:16 --silence --captions --zoom --set captions.position=center',
554
+ 'Example: batchedits style create "Hooks" --captions --hook --set hook.position=top --set hook.duration=3'
477
555
  ].join("\n"),
478
556
  async run({ api, positionals, flags }) {
479
557
  const name = positionals.join(" ").trim();
480
558
  if (!name) throw new CliError("Give the style a name.", EXIT.USAGE, 'Example: batchedits style create "Shorts" --captions --silence');
481
- const { actions } = await api.call("list_actions");
482
- const byName = new Map(actions.map((a) => [a.name, a.id]));
483
- const wanted = [];
484
- if (typeof flags.crop === "string") wanted.push({ name: "crop_video", customParams: { aspectRatio: flags.crop } });
485
- if (flags.silence) wanted.push({ name: "remove_silence" });
486
- if (flags.repeats) wanted.push({ name: "remove_repeated_sentences" });
487
- if (flags.captions || flags.emojis) wanted.push({ name: "add_captions", customParams: flags.emojis ? { emojis: true } : void 0 });
488
- if (flags.zoom) wanted.push({ name: "punch_zoom" });
489
- if (flags.clips) wanted.push({ name: "ai_clips", customParams: typeof flags.clips === "string" ? { maxClips: Number(flags.clips) } : void 0 });
559
+ const ids = await actionIds(api);
560
+ const wanted = applySets(applyFlagActions([], flags), flags.set);
490
561
  if (wanted.length === 0) throw new CliError("Pick at least one edit.", EXIT.USAGE, "See: batchedits style create --help");
491
- const missing = wanted.filter((w) => !byName.has(w.name)).map((w) => w.name);
492
- if (missing.length) throw new CliError(`This server doesn't support: ${missing.join(", ")}.`);
493
- const res = await api.call("create_style", {
494
- name,
495
- actions: wanted.map((w) => ({ id: byName.get(w.name), ...w.customParams ? { customParams: w.customParams } : {} }))
496
- });
497
- if (isJson()) printJson({ styleId: res.styleId, name });
498
- else say(`Created style "${name}" (${res.styleId}). Use it with: --style "${name}"`);
562
+ const res = await api.call("create_style", { name, actions: toToolActions(wanted, ids) });
563
+ if (isJson()) printJson({ styleId: res.styleId, name, actions: wanted });
564
+ else say(`Created style "${name}" (${res.styleId}): ${describeActions(wanted)}.
565
+ Use it with: --style "${name}"`);
566
+ return EXIT.OK;
567
+ }
568
+ },
569
+ "style edit": {
570
+ summary: "Change a style's edits and settings",
571
+ usage: "batchedits style edit <name|id> [same flags as style create] [--set action.setting=value\u2026] [--remove <action>\u2026] [--name <new name>]",
572
+ help: [
573
+ "Changes a saved style in place. Flags add an edit (or update it, e.g. --crop 1:1); other edits stay as they are.",
574
+ " --remove <action> Remove an edit, e.g. --remove zoom (repeatable)",
575
+ " --name <name> Rename the style",
576
+ SET_HELP,
577
+ "Videos already edited keep their look; the next ones use the new settings.",
578
+ 'Example: batchedits style edit "Shorts" --set captions.fontColor=#FFFF00 --remove zoom'
579
+ ].join("\n"),
580
+ async run({ api, positionals, flags }) {
581
+ const ref = positionals.join(" ").trim();
582
+ if (!ref) throw new CliError("Give the style to edit.", EXIT.USAGE, 'Example: batchedits style edit "Shorts" --set captions.position=center');
583
+ const style = await resolveStyle(api, ref);
584
+ const ids = await actionIds(api);
585
+ let actions = (style.actions ?? []).map((a) => ({ name: a.name, ...a.customParams ? { customParams: { ...a.customParams } } : {} }));
586
+ const removes = list(flags.remove).map(actionName);
587
+ const missing = removes.filter((r) => !actions.some((a) => a.name === r));
588
+ if (missing.length) throw new CliError(`"${style.name}" has no ${missing.join(", ")} to remove.`, EXIT.USAGE, `It has: ${describeActions(actions) || "no edits"}.`);
589
+ actions = actions.filter((a) => !removes.includes(a.name));
590
+ actions = applySets(applyFlagActions(actions, flags), flags.set);
591
+ const newName = typeof flags.name === "string" ? flags.name.trim() : void 0;
592
+ if (actions.length === 0) throw new CliError("A style needs at least one edit.", EXIT.USAGE, "Remove fewer edits, or add one with a flag like --captions.");
593
+ await api.call("edit_style", { styleId: style.id, ...newName ? { name: newName } : {}, actions: toToolActions(actions, ids) });
594
+ const saved = (await api.styles()).find((s) => s.id === style.id);
595
+ const savedNames = (saved?.actions ?? []).map((a) => a.name).join(",");
596
+ const setKeys = list(flags.set).map((e) => e.split("=")[0].split(".")).map(([a, k]) => [actionName(a), k]);
597
+ const keysSaved = setKeys.every(([a, k]) => saved?.actions?.find((x) => x.name === a)?.customParams?.[k] !== void 0);
598
+ if (savedNames !== actions.map((a) => a.name).join(",") || !keysSaved) {
599
+ throw new CliError("The server didn't save the new edits.", EXIT.ERROR, "It may not support editing styles yet. Create a new style with: batchedits style create");
600
+ }
601
+ const finalName = newName || style.name;
602
+ if (isJson()) printJson({ styleId: style.id, name: finalName, actions });
603
+ else say(`Updated style "${finalName}": ${describeActions(actions)}.`);
499
604
  return EXIT.OK;
500
605
  }
501
606
  },
@@ -521,17 +626,18 @@ Next: batchedits process ${uploaded.map((v) => v.id).join(" ")} --style "<name>"
521
626
  },
522
627
  process: {
523
628
  summary: "Edit videos with a style (uses credits)",
524
- usage: "batchedits process <video ids\u2026> --style <name|id> [--yes]",
525
- help: 'Queues each video with the style. Each video uses 1 credit; with AI clips each clip uses 1.\nAsks before spending credits; agents and scripts pass --yes.\nExample: batchedits process 3f2a\u2026 9c1d\u2026 --style "Shorts" --yes',
629
+ usage: "batchedits process <video ids\u2026> --style <name|id> [--hooks <file>] [--yes]",
630
+ help: 'Queues each video with the style. Each video uses 1 credit; with AI clips each clip uses 1.\nAsks before spending credits; agents and scripts pass --yes.\n --hooks <file> One hook per line, in the same order as the ids (for styles with a hook)\nExample: batchedits process 3f2a\u2026 9c1d\u2026 --style "Shorts" --yes',
526
631
  async run({ api, positionals, flags }) {
527
632
  if (positionals.length === 0) throw new CliError("Give the video ids to process.", EXIT.USAGE, "Find them with: batchedits status");
528
633
  const style = await resolveStyle(api, flags.style);
634
+ const hooks = readHooksFile(flags.hooks, style, positionals.length);
529
635
  const clips = (style.actions ?? []).some((a) => a.name === "ai_clips");
530
636
  await confirm(flags, `Edit ${positionals.length} video${positionals.length === 1 ? "" : "s"} with "${style.name}"? This uses ${positionals.length} credit${positionals.length === 1 ? "" : "s"}${clips ? ", plus 1 per AI clip" : ""}.`);
531
637
  const results = [];
532
- for (const videoId of positionals) {
638
+ for (const [i, videoId] of positionals.entries()) {
533
639
  try {
534
- const r = await api.call("process_video", { videoId, styleId: style.id });
640
+ const r = await api.call("process_video", { videoId, styleId: style.id, ...hooks[i] ? { hookText: hooks[i] } : {} });
535
641
  results.push({ videoId, status: r.status ?? "queued" });
536
642
  } catch (err) {
537
643
  if (err instanceof CliError && err.hint && results.length === 0) throw err;
@@ -581,12 +687,13 @@ Next: batchedits status ${positionals.join(" ")} --watch`);
581
687
  },
582
688
  run: {
583
689
  summary: "Upload, edit, wait and download in one go",
584
- usage: "batchedits run <files or folders\u2026> --style <name|id> [--out ./edited] [--yes]",
585
- help: 'Uploads up to 50 videos, edits them with the style, waits, and downloads the results.\nExample: batchedits run ./raw --style "Shorts" --out ./edited --yes',
690
+ usage: "batchedits run <files or folders\u2026> --style <name|id> [--out ./edited] [--hooks <file>] [--yes]",
691
+ help: 'Uploads up to 50 videos, edits them with the style, waits, and downloads the results.\n --hooks <file> One hook per line, in upload order (a folder uploads its files A-Z), for styles with a hook\nExample: batchedits run ./raw --style "Shorts" --out ./edited --yes',
586
692
  async run(ctx) {
587
693
  const { api, positionals, flags } = ctx;
588
694
  const files = expandFiles(positionals);
589
695
  const style = await resolveStyle(api, flags.style);
696
+ const hooks = readHooksFile(flags.hooks, style, files.length);
590
697
  const clips = (style.actions ?? []).some((a) => a.name === "ai_clips");
591
698
  await confirm(flags, `Edit ${files.length} video${files.length === 1 ? "" : "s"} with "${style.name}"? This uses ${files.length} credit${files.length === 1 ? "" : "s"}${clips ? ", plus 1 per AI clip" : ""}.`);
592
699
  const ids = [];
@@ -594,7 +701,7 @@ Next: batchedits status ${positionals.join(" ")} --watch`);
594
701
  info(`Uploading ${i + 1}/${files.length}: ${import_node_path3.default.basename(files[i])}`);
595
702
  ids.push((await api.upload(files[i])).id);
596
703
  }
597
- for (const videoId of ids) await api.call("process_video", { videoId, styleId: style.id });
704
+ for (const [i, videoId] of ids.entries()) await api.call("process_video", { videoId, styleId: style.id, ...hooks[i] ? { hookText: hooks[i] } : {} });
598
705
  info(`Editing ${ids.length} video${ids.length === 1 ? "" : "s"} with "${style.name}"\u2026`);
599
706
  const group = await watch(api, ids, Number(flags.interval) || 10);
600
707
  const { saved, notReady } = await downloadAll(api, group, typeof flags.out === "string" ? flags.out : "edited");
@@ -610,7 +717,7 @@ Next: batchedits status ${positionals.join(" ")} --watch`);
610
717
  };
611
718
 
612
719
  // src/cli.ts
613
- var VERSION = true ? "1.1.0" : "dev";
720
+ var VERSION = true ? "1.3.0" : "dev";
614
721
  var FLAG_OPTIONS = {
615
722
  help: { type: "boolean", short: "h" },
616
723
  version: { type: "boolean", short: "v" },
@@ -627,7 +734,12 @@ var FLAG_OPTIONS = {
627
734
  repeats: { type: "boolean" },
628
735
  crop: { type: "string" },
629
736
  zoom: { type: "boolean" },
630
- clips: { type: "string" }
737
+ clips: { type: "string" },
738
+ hook: { type: "boolean" },
739
+ hooks: { type: "string" },
740
+ set: { type: "string", multiple: true },
741
+ remove: { type: "string", multiple: true },
742
+ name: { type: "string" }
631
743
  };
632
744
  function mainHelp() {
633
745
  const names = Object.keys(commands);
@@ -646,6 +758,7 @@ function mainHelp() {
646
758
  "Quick start:",
647
759
  " batchedits login",
648
760
  ' batchedits style create "Shorts" --crop 9:16 --silence --captions --zoom',
761
+ ' batchedits style edit "Shorts" --set captions.position=center',
649
762
  ' batchedits run ./raw --style "Shorts" --out ./edited',
650
763
  "",
651
764
  "Exit codes: 0 ok \xB7 1 error \xB7 2 usage \xB7 3 sign in needed \xB7 4 some videos failed \xB7 5 needs --yes"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "batchedits",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "BatchEdits CLI, SDK and React widgets: edit up to 50 videos at once from your terminal or AI agent (Claude Code, OpenClaw, Hermes)",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",