ossclip 0.1.34 → 0.1.35

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
@@ -9,13 +9,14 @@ import {
9
9
  RESOLUTION_CHOICES,
10
10
  ResolutionChoiceSchema,
11
11
  SceneComponentIdSchema,
12
+ SfxLevelSchema,
12
13
  } from "@ossclip/core";
13
14
  import { STUDIO_ENTRY } from "@ossclip/renderer";
14
15
  import { loadEnvFiles } from "./env";
15
16
  import { ExportFormatSchema, runAnalyze } from "./analyze";
16
17
  import { expandHome } from "./paths";
17
18
  import { phaseBucketProps } from "./phase-timing";
18
- import { dictionaryFlag, jumpCutsFlag, produce, reviewFlag } from "./produce";
19
+ import { dictionaryFlag, jumpCutsFlag, produce, reviewFlag, sfxFlag } from "./produce";
19
20
  import { accountsFlag, atFlag, deliveryFlag, platformsFlag, youtubePrivacyFlag } from "./publish";
20
21
  // The one interactive import that is STATIC rather than `await import()`: the
21
22
  // `resetInputSource()` run boundary in `buildProgram` has to run synchronously
@@ -343,6 +344,31 @@ export function buildProgram(): Command {
343
344
  "runs so the editor's Render replays the same window without an LLM call",
344
345
  )
345
346
  .option("--intent <text>", "what the video should be ('educational video about agents…')")
347
+ .option(
348
+ "--sfx",
349
+ // No commander default, the `--watermark` contract: untyped must stay
350
+ // UNDEFINED so the config's `sfx` key can supply it — a `false` default
351
+ // here would make every run's flag beat the config it was written for.
352
+ "place sound effects from the bundled pack (and any pack in ~/.ossclip/sfx) " +
353
+ "on the beats the producer planned. Requires --produce. Config key: \"sfx\"",
354
+ )
355
+ .option(
356
+ "--sfx-level <level>",
357
+ "how much sound design: subtle | normal (default) | meme (unlocks the " +
358
+ "meme-tagged sounds). Implies --sfx. Config key: \"sfxLevel\"",
359
+ (v: string) => {
360
+ // Parse, never coerce (CLAUDE.md): a typo'd `--sfx-level mem` must not
361
+ // silently fall back to `normal` — the level decides whether a vine
362
+ // boom can land in the video at all.
363
+ const parsed = SfxLevelSchema.safeParse(v.trim());
364
+ if (!parsed.success) {
365
+ throw new InvalidArgumentError(
366
+ `--sfx-level wants one of ${SfxLevelSchema.options.join(", ")}, got "${v}"`,
367
+ );
368
+ }
369
+ return parsed.data;
370
+ },
371
+ )
346
372
  .option(
347
373
  "--llm <provider>",
348
374
  // Must state `defaultProviderName`'s real order — the old text omitted
@@ -661,6 +687,12 @@ export function buildProgram(): Command {
661
687
  noiseDb: opts.noiseDb,
662
688
  produce: opts.produce,
663
689
  intent: opts.intent,
690
+ // `--sfx-level` implies `--sfx` (sfxFlag), resolved HERE so the
691
+ // implication is one pure function rather than a condition produce
692
+ // has to remember. Untyped stays undefined, which is what lets the
693
+ // config's `sfx` key decide (`resolveSfx` at the use site).
694
+ sfx: sfxFlag(opts.sfx, opts.sfxLevel),
695
+ sfxLevel: opts.sfxLevel,
664
696
  provider,
665
697
  llmModel: opts.llmModel,
666
698
  llmFastModel: opts.llmFastModel,
@@ -761,7 +793,13 @@ export function buildProgram(): Command {
761
793
  // run does write. Reusing this path is what gives --review the edit
762
794
  // command's whole posture (resolveEditorPageDir's loud "run pnpm
763
795
  // build" degrade, startEditServer, openInBrowser) for free.
764
- await offerEditor(result, { flag: openEditor, port: opts.editorPort });
796
+ await offerEditor(result, {
797
+ flag: openEditor,
798
+ port: opts.editorPort,
799
+ // Typed-only, like `edit`'s --port: commander's 5174 default is not
800
+ // a choice, so a busy port still attaches or bumps for it.
801
+ portPinned: command.getOptionValueSource("editorPort") === "cli",
802
+ });
765
803
  // Deliberately LAST — after the render summary and the editor offer —
766
804
  // so the one question ossclip ever asks is the last thing on screen.
767
805
  await maybeAskRating(telemetry);
@@ -956,7 +994,7 @@ export function buildProgram(): Command {
956
994
  .argument("[workdir]", "a work directory containing render-props.json")
957
995
  .option("--port <n>", "port to listen on", (v) => Number.parseInt(v, 10), 5174)
958
996
  .option("--no-open", "do not open a browser")
959
- .action(async (workdir: string | undefined, opts) => {
997
+ .action(async (workdir: string | undefined, opts, command: Command) => {
960
998
  const { startEditServer, resolveEditorPageDir } = await import("./edit");
961
999
  // An npm install ships the page prebuilt (editor-dist/); a clone builds
962
1000
  // it once with `pnpm build`. A server that starts fine but 404s every
@@ -976,11 +1014,29 @@ export function buildProgram(): Command {
976
1014
  const target =
977
1015
  workdir === undefined ? undefined : await resolveWorkdirArgument(workdir, "edit");
978
1016
 
979
- const server = await startEditServer(target, { port: opts.port, pageDir });
980
- console.log(`▸ editor at ${server.url}`);
1017
+ // A busy port is not a crash. `openEditServer` owns the whole ladder —
1018
+ // attach to our own editor already serving THIS project, prompt about
1019
+ // one serving another, step around a stranger — and it needs to know
1020
+ // whether the port was TYPED: someone who passes `--port` has a reason
1021
+ // for that number (a bookmark, a tunnel), so their run refuses rather
1022
+ // than silently landing somewhere else. `getOptionValueSource` is the
1023
+ // same "did the user type it" test `--sort` uses.
1024
+ const { openEditServer, liveEditPortDeps } = await import("./edit-port");
1025
+ const opened = await openEditServer(
1026
+ target ?? null,
1027
+ { port: opts.port, pinned: command.getOptionValueSource("port") === "cli" },
1028
+ liveEditPortDeps((port) => startEditServer(target, { port, pageDir })),
1029
+ );
1030
+ // The user picked "cancel" at the conflict prompt: nothing started,
1031
+ // nothing to open, exit 0.
1032
+ if (opened.kind === "cancelled") return;
1033
+ const url = opened.kind === "attached" ? opened.url : opened.server.url;
1034
+ // The attach path already printed "▸ already open at …" — saying
1035
+ // "editor at" underneath it would read as a second server.
1036
+ if (opened.kind === "started") console.log(`▸ editor at ${url}`);
981
1037
  if (opts.open) {
982
1038
  const { openInBrowser } = await import("./open");
983
- openInBrowser(server.url);
1039
+ openInBrowser(url);
984
1040
  }
985
1041
  // Fire-and-forget on purpose: the server keeps the process alive for
986
1042
  // the request's lifetime, and awaiting here would put a metrics POST
@@ -20,6 +20,9 @@
20
20
  // Type-only, so no runtime edge back into produce.ts (which imports this
21
21
  // module): the tri-state's vocabulary belongs to its resolver.
22
22
  import type { JumpCutsMode } from "./produce";
23
+ // Type-only for the same reason: the level's own zod enum lives in core's
24
+ // producer half, and this module must stay a pure argv builder.
25
+ import type { SfxLevel } from "@ossclip/core";
23
26
 
24
27
  let stashed: string[] | null = null;
25
28
 
@@ -71,6 +74,10 @@ export function recordedProduceArgs(pins: {
71
74
  audience?: string;
72
75
  /** The RESOLVED thumbnail brief — pinned only when non-empty. */
73
76
  thumbnailBrief?: string;
77
+ /** The RESOLVED `--sfx` switch (flag or config), pinned only when ON. */
78
+ sfx?: boolean;
79
+ /** The RESOLVED `--sfx-level`, pinned alongside an ON `sfx`. */
80
+ sfxLevel?: SfxLevel;
74
81
  }): string[] {
75
82
  // --review and --no-render are stripped at record (cut-review step 1):
76
83
  // command.json exists for exactly one consumer — the editor's Render
@@ -201,5 +208,28 @@ export function recordedProduceArgs(pins: {
201
208
  ) {
202
209
  args.push("--thumbnail-brief", pins.thumbnailBrief);
203
210
  }
211
+ // The sound-effect pins, the watermark's config-dependent-default rationale
212
+ // on a pair of flags: `sfx`/`sfxLevel` are both config keys, so an unpinned
213
+ // record replays a different amount of sound design — or none — the moment
214
+ // that config is edited or the replay runs on another machine. It matters
215
+ // more here than for the watermark: an editor render carries the REVIEWED
216
+ // plan forward from production.json instead of re-placing (produce's
217
+ // `priorSfxPlan`), so `--sfx` is what decides whether the sound design the
218
+ // user just dragged into place is in the video at all.
219
+ //
220
+ // ON ONLY, and that is the jump-cuts "auto" case rather than the watermark's
221
+ // both-directions rule: there is no `--no-sfx` spelling to pin an off-run
222
+ // with (program.ts declares `--sfx` alone, so the config key can still
223
+ // supply the default). An off-record replayed under a later config-on
224
+ // therefore GAINS sound effects — the accepted cost of a flag with one
225
+ // spelling, and the day `--no-sfx` exists this pin becomes unconditional
226
+ // like the watermark's. `--sfx-level` implies `--sfx`, so a typed level in
227
+ // the argv already settles the switch and the includes-guard leaves it be.
228
+ if (pins.sfx === true) {
229
+ if (!args.includes("--sfx") && !args.includes("--sfx-level")) args.push("--sfx");
230
+ if (pins.sfxLevel !== undefined && !args.includes("--sfx-level")) {
231
+ args.push("--sfx-level", pins.sfxLevel);
232
+ }
233
+ }
204
234
  return args;
205
235
  }