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/editor-dist/assets/index-Bx2VQLP8.css +1 -0
- package/editor-dist/assets/index-CWIUcpJC.js +163 -0
- package/editor-dist/index.html +2 -2
- package/package.json +4 -4
- package/src/caption-report.ts +141 -3
- package/src/cover.ts +846 -0
- package/src/edit.ts +232 -39
- package/src/interactive/resolve-workdir.ts +11 -2
- package/src/open.ts +38 -0
- package/src/produce.ts +491 -67
- package/src/program.ts +143 -22
- package/editor-dist/assets/index-C9n6EfII.js +0 -163
- package/editor-dist/assets/index-ChRVBVLj.css +0 -1
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
|
-
|
|
795
|
-
|
|
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(
|