ossclip 0.1.12 → 0.1.14

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.
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>ossclip editor</title>
7
- <script type="module" crossorigin src="/assets/index-BBVLCP-N.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-CKpBGMBB.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-C8IPo60X.css">
9
9
  </head>
10
10
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ossclip",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Local-first CLI video producer: cuts silence and fillers, word-timed captions, face-aware framing, and LLM-planned code-rendered graphics — transcription and rendering never leave your machine",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,9 +36,9 @@
36
36
  "commander": "^12.1.0",
37
37
  "tsx": "^4.19.0",
38
38
  "zod": "^3.25.76",
39
- "@ossclip/core": "0.1.12",
40
- "@ossclip/renderer": "0.1.12",
41
- "@ossclip/scenes": "0.1.12"
39
+ "@ossclip/core": "0.1.14",
40
+ "@ossclip/scenes": "0.1.14",
41
+ "@ossclip/renderer": "0.1.14"
42
42
  },
43
43
  "homepage": "https://github.com/AhsanAyaz/ossclip#readme",
44
44
  "bugs": {
@@ -19,6 +19,11 @@ export interface ProduceExtras {
19
19
  * off is the default, and a config-on user who wants it off for one run
20
20
  * types `--no-watermark`, a flags-only surface like --sort. */
21
21
  watermark?: boolean;
22
+ /** Captions tri-state, the watermark's mirror image: ON is the default,
23
+ * so the wizard only ever turns them OFF (`false` → `--no-captions`) and
24
+ * the positive `--captions` stays flags-only — it exists for replay
25
+ * pinning, and emitting it here would restate the default. */
26
+ captions?: boolean;
22
27
  llm?: "claude" | "claude-cli" | "gemini" | "mock";
23
28
  }
24
29
 
@@ -62,6 +67,9 @@ export function produceArgv(a: ProduceAnswers): string[] {
62
67
  if (e.collapseRetakes === true) argv.push("--collapse-retakes");
63
68
  if (e.sourceIsEdited === true) argv.push("--source-is-edited");
64
69
  if (e.watermark === true) argv.push("--watermark");
70
+ // Strict `=== false` (never `!e.captions`): undefined means "the default,
71
+ // on" and must emit nothing per the elision rule above.
72
+ if (e.captions === false) argv.push("--no-captions");
65
73
  if (e.llm) argv.push("--llm", e.llm);
66
74
 
67
75
  return argv;
@@ -4,12 +4,15 @@ import { produceArgv, type ProduceAnswers, type ProduceExtras } from "./produce-
4
4
  import { assertInteractive, confirm, intro, multiselect, select, text, unwrap } from "./prompts";
5
5
 
6
6
  /**
7
- * The produce wizard. Thirty-two flags (plus the positional input path)
7
+ * The produce wizard. Thirty-four flags (plus the positional input path)
8
8
  * sorted into three tiers: six prompts asked directly — the input path, plus
9
- * five flags (--out, --cleanup, --aspect, --produce, --intent) — nine behind
9
+ * five flags (--out, --cleanup, --aspect, --produce, --intent) — ten behind
10
10
  * one "anything else?" multiselect, and the remaining stay flags-only:
11
11
  * debug/internal surfaces, replay-only fields, --no-watermark (the
12
- * multiselect only turns the credit ON; off is already the default), or
12
+ * multiselect only turns the credit ON; off is already the default),
13
+ * --captions (the mirror case: ON is already the default, so the
14
+ * multiselect entry is the OFF switch and the positive flag exists only for
15
+ * replay pinning), or
13
16
  * (final-review fix wave, Finding 1) --sort. A folder's clip order only means anything once the
14
17
  * folder has been enumerated, and that enumeration happens inside
15
18
  * `produce()` — after the wizard has already returned argv — so there is
@@ -35,6 +38,7 @@ const EXTRAS = [
35
38
  hint: "--collapse-retakes",
36
39
  },
37
40
  { value: "sourceIsEdited", label: "Source already has burned-in text", hint: "--source-is-edited" },
41
+ { value: "captionsOff", label: "Turn the burned-in captions off", hint: "--no-captions" },
38
42
  { value: "watermark", label: 'Credit the tool with a small "made with ossclip"', hint: "--watermark" },
39
43
  { value: "llm", label: "Choose the LLM provider", hint: "--llm" },
40
44
  ] as const;
@@ -235,6 +239,9 @@ export async function produceWizard(
235
239
  if (chosen.includes("sourceFit")) extras.sourceFit = "contain";
236
240
  if (chosen.includes("collapseRetakes")) extras.collapseRetakes = true;
237
241
  if (chosen.includes("sourceIsEdited")) extras.sourceIsEdited = true;
242
+ // The entry is the OFF switch (captions default ON — see EXTRAS), so a
243
+ // tick maps to `captions: false` and produceArgv emits `--no-captions`.
244
+ if (chosen.includes("captionsOff")) extras.captions = false;
238
245
  if (chosen.includes("watermark")) extras.watermark = true;
239
246
  if (chosen.includes("speaker")) {
240
247
  extras.speaker = unwrap(
package/src/produce.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  concatFolder,
25
25
  folderManifestKey,
26
26
  listFolderVideos,
27
+ coverDecision,
27
28
  coverHeadline,
28
29
  cropFilter,
29
30
  detectContentRect,
@@ -246,6 +247,15 @@ export interface ProduceOptions {
246
247
  * a free-tier limitation; this is voluntary attribution.
247
248
  */
248
249
  watermark?: boolean;
250
+ /**
251
+ * `--captions` / `--no-captions` tri-state: true/false when TYPED,
252
+ * undefined when not. Unlike `watermark` above there is no config key —
253
+ * undefined simply means the default, which is ON. Kept tri-state anyway
254
+ * so command.json can pin the resolved flag state (`recordedProduceArgs`)
255
+ * and a future config key or default change can never re-resolve an old
256
+ * record differently.
257
+ */
258
+ captions?: boolean;
249
259
  /**
250
260
  * `<input>` a DIRECTORY: order its clips before concatenating them into the
251
261
  * source produce runs on (folder-input-brief.md). `name` (default) is a
@@ -280,6 +290,26 @@ export function resolveWatermark(
280
290
  return flag ?? configValue === true;
281
291
  }
282
292
 
293
+ /**
294
+ * Whether captions are hidden this run: the flag saying OFF, or the editor's
295
+ * doc-global `captionsHidden` override saying hidden. An OR, deliberately
296
+ * NOT resolveWatermark's flag-beats-config precedence: the override is the
297
+ * user's own saved edit, not a machine-supplied default a typed flag should
298
+ * outrank — un-hiding belongs to the editor that wrote the override, so a
299
+ * typed `--captions` cannot force captions back on over it. Strict
300
+ * `=== false`/`=== true` on both sides: the override arrives zod-parsed,
301
+ * but the flag is a tri-state where undefined means "not typed" — and
302
+ * captions defaulting ON means anything short of an explicit off must read
303
+ * as visible. Pure so the whole flag × override matrix is testable without
304
+ * a workdir or an overrides.json on disk.
305
+ */
306
+ export function resolveCaptionsHidden(
307
+ flag: boolean | undefined,
308
+ overrideHidden: boolean | undefined,
309
+ ): boolean {
310
+ return flag === false || overrideHidden === true;
311
+ }
312
+
283
313
  function sha1File(path: string): Promise<string> {
284
314
  return new Promise((res, rej) => {
285
315
  const h = createHash("sha1");
@@ -2006,6 +2036,21 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2006
2036
  // that assembleScenes dropped, and the caption track knows exactly when the
2007
2037
  // ask is on screen. Quoting marks the word you type in the comments — every
2008
2038
  // other time the speaker merely says it, it must render plainly.
2039
+ // Captions are ON by default and stay so — only the OFF path announces
2040
+ // itself, naming WHICH surface turned them off: a silent-captions upload
2041
+ // must never leave the author guessing whether they typed the flag or the
2042
+ // editor's toggle did it. The flag reason wins the message when both are
2043
+ // true — it is the one visible in the command line being run. Resolved
2044
+ // BEFORE the CTA block below, because the CTA line's promise depends on it.
2045
+ const captionsHidden = resolveCaptionsHidden(opts.captions, overrideDoc.captionsHidden);
2046
+ if (captionsHidden) {
2047
+ console.log(
2048
+ opts.captions === false
2049
+ ? "▸ captions: off (--no-captions)"
2050
+ : "▸ captions: hidden by editor override",
2051
+ );
2052
+ }
2053
+
2009
2054
  const ctaCue = [...graphicCues]
2010
2055
  .reverse()
2011
2056
  .find((c) => typeof c.props?.keyword === "string" && (c.props.keyword as string).length > 0);
@@ -2014,9 +2059,15 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2014
2059
  ? { startSec: ctaCue.startSec, endSec: ctaCue.endSec }
2015
2060
  : undefined;
2016
2061
  if (ctaKeyword) {
2062
+ // Gated on the captions resolution (review minor 1): "styled only at
2063
+ // X–Ys" on a captions-hidden run is a false promise — the styling rides
2064
+ // the caption track (ProductionComposition's mount gate), so the moment
2065
+ // the trade bites is the moment to say so.
2017
2066
  console.log(
2018
- `▸ CTA keyword "${ctaKeyword}" styled only at ` +
2019
- `${ctaWindow!.startSec.toFixed(1)}–${ctaWindow!.endSec.toFixed(1)}s`,
2067
+ captionsHidden
2068
+ ? `▸ CTA keyword "${ctaKeyword}" styling skipped — captions hidden`
2069
+ : `▸ CTA keyword "${ctaKeyword}" styled only at ` +
2070
+ `${ctaWindow!.startSec.toFixed(1)}–${ctaWindow!.endSec.toFixed(1)}s`,
2020
2071
  );
2021
2072
  }
2022
2073
 
@@ -2091,6 +2142,18 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2091
2142
  // an off run's render-props.json stays byte-identical to a pre-watermark
2092
2143
  // one, so nothing downstream can tell the feature ever shipped.
2093
2144
  ...(watermark ? { watermark: true } : {}),
2145
+ // Same absent-means-default contract, polarity flipped (captions default
2146
+ // ON): written only when hidden, so a normal run's render-props.json
2147
+ // stays byte-identical to a pre-feature one. `captionsHiddenByFlag` is
2148
+ // the flag-only part, split out for the EDITOR alone: `captionsHidden`
2149
+ // bakes the override doc in, and the editor re-applies the CURRENT doc
2150
+ // onto pristine bases (see `baseSceneCues` above) — without the split,
2151
+ // its live preview either couldn't take a doc-sourced hide back after
2152
+ // an un-toggle (the add-only trap those bases exist for) or would show
2153
+ // captions that a command.json pinned with --no-captions will never
2154
+ // actually render.
2155
+ ...(captionsHidden ? { captionsHidden: true } : {}),
2156
+ ...(opts.captions === false ? { captionsHiddenByFlag: true } : {}),
2094
2157
  };
2095
2158
  await writeFile(join(work, "render-props.json"), JSON.stringify(props, null, 2));
2096
2159
 
@@ -2161,13 +2224,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2161
2224
  // A separate file, not a burned-in intro: both platforms accept a custom
2162
2225
  // cover, so nothing has to be pickable from the video — and spending the
2163
2226
  // opening seconds on a title card fights the hook-in-2s policy directly.
2164
- if (opts.cover !== false) {
2227
+ {
2165
2228
  // §35's cap applies here too: a cached beat sheet from before the fix, or
2166
2229
  // the hook fallback, must not slip a 13-word paragraph onto a thumbnail.
2167
2230
  const coverText = coverHeadline(beatSheet?.coverText ?? beatSheet?.hook ?? "");
2168
- if (!coverText) {
2169
- console.log("▸ no cover text (run --produce for one) — skipping cover");
2170
- } else {
2231
+ // Urdu field run 2026-08-05: a run without --produce has no hook text,
2232
+ // and skipping the cover for that threw away the part that never needed
2233
+ // text — the sharpness-scored face frame. No headline now means a bare
2234
+ // frame, not no cover; see `coverDecision`'s doc comment.
2235
+ const cover = coverDecision(opts.cover !== false, coverText);
2236
+ if (cover !== "none") {
2171
2237
  const detector = await createFaceDetector();
2172
2238
  const pick = await pickCoverFrame(tools, analysisInput, analysisProbe.duration, {
2173
2239
  cacheDir: work,
@@ -2195,31 +2261,45 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2195
2261
  const coverPath = resolve(
2196
2262
  opts.coverPath ?? outPath.replace(/(\.[^.]+)?$/, ".cover.jpg"),
2197
2263
  );
2198
- // §34: if the source's own title is up at this instant, the frame
2199
- // already has a headline. Adding ours states the same claim twice in
2200
- // one image — a cover with one title beats a cover with two.
2201
- const sourceTitled = regionsDuring(
2202
- sourceText.regions,
2203
- pick.timeSec - 0.5,
2204
- pick.timeSec + 0.5,
2205
- ).length > 0;
2206
- console.log(
2207
- `▸ cover from ${pick.timeSec.toFixed(1)}s ` +
2208
- `(${pick.hasFace ? "face" : "no face"}, sharpness ${pick.sharpness.toFixed(0)})…`,
2209
- );
2210
- if (sourceTitled) {
2211
- console.log(" ▸ source already has a title in this frame — shipping it without a banner");
2212
- } else if (pick.face) {
2213
- const band = coverTextRect(pick.face, frame);
2264
+ // The §34 dedupe check and the band-placement log exist only to
2265
+ // route a banner around the frame's contents — a textless cover
2266
+ // (Urdu field run 2026-08-05) has no banner to place, so both are
2267
+ // skipped rather than run against text that isn't there.
2268
+ let bannerText = "";
2269
+ if (cover === "banner") {
2270
+ // §34: if the source's own title is up at this instant, the frame
2271
+ // already has a headline. Adding ours states the same claim twice in
2272
+ // one image — a cover with one title beats a cover with two.
2273
+ const sourceTitled = regionsDuring(
2274
+ sourceText.regions,
2275
+ pick.timeSec - 0.5,
2276
+ pick.timeSec + 0.5,
2277
+ ).length > 0;
2278
+ console.log(
2279
+ `▸ cover from ${pick.timeSec.toFixed(1)}s ` +
2280
+ `(${pick.hasFace ? "face" : "no face"}, sharpness ${pick.sharpness.toFixed(0)})…`,
2281
+ );
2282
+ if (sourceTitled) {
2283
+ console.log(" ▸ source already has a title in this frame — shipping it without a banner");
2284
+ } else if (pick.face) {
2285
+ const band = coverTextRect(pick.face, frame);
2286
+ console.log(
2287
+ ` ▸ banner in the ${band.y + band.h / 2 < pick.face.centerYFrac ? "band above" : "band below"} ` +
2288
+ `the face (${(band.y * 100).toFixed(0)}-${((band.y + band.h) * 100).toFixed(0)}%)`,
2289
+ );
2290
+ }
2291
+ bannerText = sourceTitled ? "" : coverText;
2292
+ } else {
2214
2293
  console.log(
2215
- ` ▸ banner in the ${band.y + band.h / 2 < pick.face.centerYFrac ? "band above" : "band below"} ` +
2216
- `the face (${(band.y * 100).toFixed(0)}-${((band.y + band.h) * 100).toFixed(0)}%)`,
2294
+ `▸ cover from ${pick.timeSec.toFixed(1)}s ` +
2295
+ `(${pick.hasFace ? "face" : "no face"}, sharpness ${pick.sharpness.toFixed(0)}) ` +
2296
+ `— no banner text (run --produce for one)`,
2217
2297
  );
2218
2298
  }
2219
2299
  await renderCover(
2220
2300
  {
2221
2301
  frameFileName: frameName,
2222
- text: sourceTitled ? "" : coverText,
2302
+ text: bannerText,
2223
2303
  theme,
2224
2304
  face: pick.face,
2225
2305
  // The cover is the OUTPUT's thumbnail — a landscape render gets a
@@ -2267,10 +2347,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2267
2347
  // under a later/foreign config-on, an on-run would silently lose it. The
2268
2348
  // RESOLVED state is always pinned; a typed flag is already in the argv and
2269
2349
  // the includes-guard leaves it alone.
2350
+ // Captions pin: the FLAG's resolved state (`opts.captions ?? true`), never
2351
+ // the override-inclusive `captionsHidden` — overrides.json travels with
2352
+ // the workdir and is re-read on every replay, so pinning --no-captions
2353
+ // because the EDITOR hid them would freeze an edit the user may later
2354
+ // undo in that same editor. See recordedProduceArgs for why the pin is
2355
+ // unconditional even though captions' default is config-independent today.
2270
2356
  const recordedArgs = recordedProduceArgs({
2271
2357
  llm: provider ? providerName : undefined,
2272
2358
  clipWindow: clipWindow ? `${clipWindow.startWord}:${clipWindow.endWord}` : undefined,
2273
2359
  watermark,
2360
+ captions: opts.captions ?? true,
2274
2361
  });
2275
2362
  await writeFile(
2276
2363
  join(work, "command.json"),
package/src/program.ts CHANGED
@@ -270,6 +270,20 @@ export function buildProgram(): Command {
270
270
  "(set it once with watermark: true in ~/.ossclip/config.json)",
271
271
  )
272
272
  .option("--no-watermark", "no wordmark, even when the config turns it on")
273
+ // Same tri-state shape as --watermark above (positive declared first so
274
+ // commander's default stays undefined = "not typed"), though captions
275
+ // have no config key to fill the gap: the tri-state exists so
276
+ // command.json can pin the resolved flag state for replay determinism
277
+ // (recordedProduceArgs) — a bare boolean default would make "not typed"
278
+ // and "typed --captions" indistinguishable to the pin.
279
+ .option(
280
+ "--captions",
281
+ "burned-in captions — already the default; exists so a recorded replay can pin the state",
282
+ )
283
+ .option(
284
+ "--no-captions",
285
+ "no burned-in captions. The CTA keyword styling rides the caption track, so it goes too",
286
+ )
273
287
  .option("--no-cover", "skip the cover image written beside the video")
274
288
  .option("--cover <path>", "cover image output path (default: <out>.cover.jpg)")
275
289
  .option("--open-editor", "open the editor when the run finishes")
@@ -372,6 +386,9 @@ export function buildProgram(): Command {
372
386
  sourceFit,
373
387
  // undefined = "not typed", so produce can let the config decide.
374
388
  watermark: opts.watermark,
389
+ // undefined = "not typed" here too — the default (ON) is applied at
390
+ // the pin site, not coerced in transit.
391
+ captions: opts.captions,
375
392
  cover: opts.cover !== false,
376
393
  coverPath: typeof opts.cover === "string" ? opts.cover : undefined,
377
394
  clip: opts.clip,
@@ -52,6 +52,7 @@ export function recordedProduceArgs(pins: {
52
52
  llm?: string;
53
53
  clipWindow?: string;
54
54
  watermark?: boolean;
55
+ captions?: boolean;
55
56
  }): string[] {
56
57
  const args = consumeReplayArgv() ?? process.argv.slice(2);
57
58
  if (pins.llm !== undefined && !args.includes("--llm")) {
@@ -74,5 +75,19 @@ export function recordedProduceArgs(pins: {
74
75
  if (pins.watermark !== undefined && !args.includes("--watermark") && !args.includes("--no-watermark")) {
75
76
  args.push(pins.watermark ? "--watermark" : "--no-watermark");
76
77
  }
78
+ // The captions flag, pinned both ways like the watermark above — but here
79
+ // as future-proofing plus consistency rather than a live bug: captions'
80
+ // default is ON independent of any config today, so an argv without the
81
+ // flag would currently replay identically everywhere. Pinned anyway
82
+ // because (a) the watermark review already made "every record carries the
83
+ // RESOLVED state" §75's rule, and one unpinned tri-state would turn
84
+ // command.json's contract into per-flag trivia; and (b) the moment a
85
+ // config key or a changed default ever appears, every unpinned old record
86
+ // would silently re-resolve under it — the watermark's exact drift, just
87
+ // deferred. Note this pins the FLAG's state only; the editor's
88
+ // `captionsHidden` override is not folded in (see produce.ts's call site).
89
+ if (pins.captions !== undefined && !args.includes("--captions") && !args.includes("--no-captions")) {
90
+ args.push(pins.captions ? "--captions" : "--no-captions");
91
+ }
77
92
  return args;
78
93
  }