ossclip 0.1.25 → 0.1.27

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/src/program.ts CHANGED
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { Command, InvalidArgumentError } from "commander";
5
5
  import { z } from "zod/v4";
6
- import { CleanupLevelSchema, SceneComponentIdSchema } from "@ossclip/core";
6
+ import { CleanupLevelSchema, COVER_MAX_WORDS, SceneComponentIdSchema } from "@ossclip/core";
7
7
  import { STUDIO_ENTRY } from "@ossclip/renderer";
8
8
  import { loadEnvFiles } from "./env";
9
9
  import { ExportFormatSchema, runAnalyze } from "./analyze";
@@ -39,6 +39,63 @@ import {
39
39
  // order in `defaultProviderName`, which decides which model runs.
40
40
  const envFiles = loadEnvFiles();
41
41
 
42
+ /**
43
+ * `--concurrency <n>` → a positive whole number of browser tabs (§93a: reject
44
+ * rather than coerce, the `--clip` idiom). A typo'd `--concurrency 4x` must
45
+ * not become NaN and reach Remotion as "however many you like" — the flag
46
+ * exists precisely because the automatic count killed a browser (2026-08-19
47
+ * field case; `resolveRenderConcurrency` has it). `Number`, not `parseInt`,
48
+ * so "4.5" and "" are errors rather than a silent 4 and a silent 0.
49
+ *
50
+ * Exported so the rejection matrix is testable without commander's exit
51
+ * behaviour in the way.
52
+ */
53
+ export function concurrencyFlag(v: string): number {
54
+ const n = Number(v);
55
+ if (!Number.isInteger(n) || n <= 0) {
56
+ throw new InvalidArgumentError(
57
+ `--concurrency wants a positive whole number of browser tabs, got "${v}"`,
58
+ );
59
+ }
60
+ return n;
61
+ }
62
+
63
+ /**
64
+ * `<command> [workdir]` → the workdir the user meant.
65
+ *
66
+ * ONE spelling of the probe → resolve → pick ladder, because `edit` and
67
+ * `cover` both need it and two copies drift: the reported failure it exists
68
+ * for is `ossclip edit <video folder>` when produce wrote into
69
+ * `<video folder>/.ossclip/<name>/`, and a `cover` that resolved differently
70
+ * would rebuild a cover for a run the user is not looking at.
71
+ *
72
+ * `command` is threaded so the no-TTY candidate list names the command that
73
+ * was actually run, not always `edit`.
74
+ *
75
+ * Every import here is dynamic on purpose: the interactive stack is not
76
+ * loaded on invocations that never reach a picker, which is what keeps CLI
77
+ * startup cheap.
78
+ */
79
+ async function resolveWorkdirArgument(typed: string, command: string): Promise<string> {
80
+ const { probeWorkdir } = await import("./interactive/workdir-probe");
81
+ const { resolveWorkdir, candidateListMessage } = await import("./interactive/resolve-workdir");
82
+ const { isInteractive } = await import("./interactive/tty");
83
+ const { dir, probe } = await probeWorkdir(typed);
84
+ const resolution = resolveWorkdir(dir, probe);
85
+ if (resolution.kind === "none") throw new Error(resolution.message);
86
+ if (resolution.kind === "choose") {
87
+ if (!isInteractive()) {
88
+ throw new Error(candidateListMessage(dir, resolution.candidates, command));
89
+ }
90
+ const { pickWorkdir } = await import("./interactive/pick-workdir");
91
+ return await pickWorkdir(resolution.candidates);
92
+ }
93
+ // Say so when the path was not the one typed — a silent redirect leaves the
94
+ // user with the wrong mental model of where things live.
95
+ if (resolution.via === "nested") console.log(`▸ resolved ${typed} → ${resolution.workdir}`);
96
+ return resolution.workdir;
97
+ }
98
+
42
99
  /**
43
100
  * Every command this CLI has, built onto a fresh instance.
44
101
  *
@@ -411,6 +468,11 @@ export function buildProgram(): Command {
411
468
  "any motion crops the head. Per-scene control stays in the editor (autoZoom)",
412
469
  )
413
470
  .option("--cover <path>", "cover image output path (default: <out>.cover.jpg)")
471
+ .option(
472
+ "--cover-text-reset",
473
+ "use this run's generated cover headline even if `ossclip cover --text` set one — " +
474
+ "that headline is user-owned and kept by default (deleting cover.json does the same)",
475
+ )
414
476
  .option("--open-editor", "open the editor when the run finishes")
415
477
  .option(
416
478
  "--no-open-editor",
@@ -418,6 +480,20 @@ export function buildProgram(): Command {
418
480
  )
419
481
  .option("--editor-port <n>", "port for the editor started by --open-editor",
420
482
  (v) => Number.parseInt(v, 10), 5174)
483
+ // No default: undefined = "not typed" is what lets the config's
484
+ // renderConcurrency (and then the cpus-2 guess) supply the value —
485
+ // resolveRenderConcurrency owns the precedence. Recorded runs need nothing
486
+ // special to replay it: command.json stores the argv verbatim
487
+ // (recordedProduceArgs), so a typed --concurrency is already in there, and
488
+ // the editor's Render replays it through THIS parse.
489
+ .option(
490
+ "--concurrency <n>",
491
+ "how many browser tabs render frames in parallel (default: CPU cores - 2, " +
492
+ "floor 2). Turn it DOWN if the render logs 'The browser crashed while " +
493
+ "rendering frame N' — that is the whole browser running out of memory, " +
494
+ "not one frame failing",
495
+ concurrencyFlag,
496
+ )
421
497
  .action(async (input: string | undefined, opts, command: Command) => {
422
498
  if (input === undefined) {
423
499
  // commander 12's parseAsync does not reset option state between calls,
@@ -551,8 +627,14 @@ export function buildProgram(): Command {
551
627
  jumpCuts,
552
628
  cover: opts.cover !== false,
553
629
  coverPath: typeof opts.cover === "string" ? opts.cover : undefined,
630
+ // A separate key from --cover: this one is about the TEXT, and the
631
+ // cover/coverPath pair already shares one.
632
+ coverTextReset: opts.coverTextReset === true,
554
633
  clip: opts.clip,
555
634
  clipWindow: opts.clipWindow,
635
+ // Validated by concurrencyFlag at parse time; undefined = "not
636
+ // typed", which is what lets the config supply it.
637
+ concurrency: opts.concurrency,
556
638
  });
557
639
  // Counts, buckets and names only — the duration crosses the wire as a
558
640
  // bucket, and nothing here can carry a path (assertSafeProps enforces
@@ -791,27 +873,8 @@ export function buildProgram(): Command {
791
873
  // With no argument the editor opens on its own project picker (R17 §83).
792
874
  // With one, resolve what the user MEANT: `ossclip edit <video folder>`
793
875
  // was the reported failure, and produce's output lives one level down.
794
- let target: string | undefined = workdir;
795
- if (workdir !== undefined) {
796
- const { probeWorkdir } = await import("./interactive/workdir-probe");
797
- const { resolveWorkdir, candidateListMessage } = await import("./interactive/resolve-workdir");
798
- const { isInteractive } = await import("./interactive/tty");
799
- const { dir, probe } = await probeWorkdir(workdir);
800
- const resolution = resolveWorkdir(dir, probe);
801
- if (resolution.kind === "none") throw new Error(resolution.message);
802
- if (resolution.kind === "choose") {
803
- if (!isInteractive()) {
804
- throw new Error(candidateListMessage(dir, resolution.candidates));
805
- }
806
- const { pickWorkdir } = await import("./interactive/pick-workdir");
807
- target = await pickWorkdir(resolution.candidates);
808
- } else {
809
- target = resolution.workdir;
810
- // Say so when the path was not the one typed — a silent redirect
811
- // leaves the user with the wrong mental model of where things live.
812
- if (resolution.via === "nested") console.log(`▸ resolved ${workdir} → ${target}`);
813
- }
814
- }
876
+ const target =
877
+ workdir === undefined ? undefined : await resolveWorkdirArgument(workdir, "edit");
815
878
 
816
879
  const server = await startEditServer(target, { port: opts.port, pageDir });
817
880
  console.log(`▸ editor at ${server.url}`);
@@ -826,6 +889,64 @@ export function buildProgram(): Command {
826
889
  void telemetry.flush();
827
890
  });
828
891
 
892
+ program
893
+ .command("cover")
894
+ .description(
895
+ "regenerate a produced workdir's cover image — a new headline or a new frame, " +
896
+ "in seconds, with no video re-render",
897
+ )
898
+ // Optional, like `edit`'s: with no argument this resolves the run under
899
+ // the CURRENT directory, so `cd`-ing to the video's folder is enough.
900
+ .argument("[workdir]", "a work directory, or the folder you produced in")
901
+ .option(
902
+ "--text <headline>",
903
+ `banner headline. Capped at ${COVER_MAX_WORDS} words like produce's (§35), and the ` +
904
+ "trimmed result is printed. Omitted keeps the headline this cover already has",
905
+ )
906
+ .option(
907
+ "--at <seconds>",
908
+ "take the frame from this timestamp. Omitted re-uses the still the last cover was " +
909
+ "built from — the cheap path, which runs no ffmpeg at all",
910
+ )
911
+ .option(
912
+ "--from <video>",
913
+ "which video --at reads: `final` (default) is the FINISHED render, so the frame " +
914
+ "carries the burned-in captions, graphics and watermark; `source` is the original " +
915
+ "take, framed the way produce framed it",
916
+ "final",
917
+ )
918
+ .option(
919
+ "--out <path>",
920
+ "write the JPEG here for THIS run only; a one-off destination that does not change " +
921
+ "where this project's cover lives (that stays where this workdir's last cover went, " +
922
+ "else <out>.cover.jpg)",
923
+ )
924
+ .action(async (workdir: string | undefined, opts) => {
925
+ const { parseCoverFlags, regenerateCover } = await import("./cover");
926
+ // Parsed before anything touches disk: a typo'd `--from finall` must be
927
+ // an error naming the flag, not a cover quietly rebuilt from the wrong
928
+ // video (the --source-fit rule above).
929
+ const flags = parseCoverFlags(opts);
930
+
931
+ // The `edit` action's ladder, literally the same one: produce writes
932
+ // into <video's folder>/.ossclip/<name>/, and `ossclip cover
933
+ // ~/Downloads/MyClips` has to find that nested run exactly as `edit`
934
+ // does. With no argument, the current directory is the target.
935
+ const target = await resolveWorkdirArgument(workdir ?? ".", "cover");
936
+
937
+ // A thin shell by design: every decision lives in regenerateCover, so
938
+ // the editor's /api/cover/regenerate is the same code and not a second
939
+ // spelling of it.
940
+ await regenerateCover(target, {
941
+ text: flags.text,
942
+ atSec: flags.atSec,
943
+ from: flags.from,
944
+ // Raw: `coverDestination` owns the tilde expansion and the cwd
945
+ // anchor, so there is one site applying `expandHome` to the user half.
946
+ outPath: flags.outPath,
947
+ });
948
+ });
949
+
829
950
  program
830
951
  .command("setup")
831
952
  .description(