@ossclip/core 0.1.24 → 0.1.26

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.
@@ -0,0 +1,93 @@
1
+ Copyright 2022 The Noto Project Authors (https://github.com/notofonts/nastaliq)
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ https://scripts.sil.org/OFL
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,15 @@
1
+ # Bundled fonts
2
+
3
+ ## NotoNastaliqUrdu-Bold.ttf
4
+
5
+ Noto Nastaliq Urdu Bold, from the notofonts/nastaliq **v3.007** GitHub
6
+ release (`googlefonts/ttf/NotoNastaliqUrdu-Bold.ttf`), © 2022 The Noto
7
+ Project Authors — licensed under the SIL Open Font License 1.1 (`OFL.txt`
8
+ beside this file). The OFL permits bundling and redistribution with the
9
+ license text included, which is why the license ships in this directory.
10
+
11
+ Why it is bundled at all: caption rendering must be deterministic across
12
+ machines, and a Linux CI box (or a fresh Windows install) has no Nastaliq
13
+ face — Urdu captions there fell back to whatever Arabic-script font the OS
14
+ had, or to tofu. Only the Bold face ships because captions render at
15
+ `fontWeight: 900` and one face keeps the package small (~616 KB).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossclip/core",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "ossclip's framework-free pipeline: schema, transcription, analysis, cutlist, captions, framing, and the LLM producer",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,6 +20,7 @@
20
20
  ],
21
21
  "dependencies": {
22
22
  "@anthropic-ai/sdk": "^0.115.0",
23
+ "@google/genai": "^2.17.1",
23
24
  "zod": "^3.25.0"
24
25
  },
25
26
  "homepage": "https://github.com/AhsanAyaz/ossclip#readme",
package/src/blooper.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { normalizeToken } from "./analyze";
2
2
  import { isSentenceStart } from "./clip";
3
3
  import { levenshtein } from "./phonetics";
4
- import type { Transcript } from "./schema";
4
+ import type { Span, Transcript } from "./schema";
5
5
 
6
6
  /**
7
7
  * Blooper removal by SPOKEN MARKER (R27 §122).
@@ -55,8 +55,40 @@ export interface BloopSpan {
55
55
  * silently cutting a good take (Task 3, editor-dogfood-fixes plan).
56
56
  */
57
57
  matched: string[];
58
+ /**
59
+ * True when the sentence-start backscan hit MAX_WALKBACK_SEC before it
60
+ * found punctuation — the span was cut short of a real sentence boundary
61
+ * and the report must say so out loud (`formatBloopSpan`).
62
+ */
63
+ truncated?: boolean;
58
64
  }
59
65
 
66
+ /**
67
+ * How far past the marker word's STAMPED end a silence may start and still be
68
+ * treated as the marker's own trailing dead air. Whisper's `-ml 1` stamps
69
+ * stretch over pauses (§18, PHASE1-FINDINGS.md), so the stamped end of a
70
+ * spoken marker routinely lands BEFORE its acoustic end — the 2026-08-16
71
+ * incident: "blooper." stamped to end at 670.0 while the audio ran to ~670.4
72
+ * (proven by the bracketing silences 668.09–669.3 and 670.4–671.68), so 0.4s
73
+ * of audible "blooper" leaked into the output at ~10:02. Extending the cut
74
+ * through any silence starting within this window swallows the acoustic tail
75
+ * — and the debris sliver ("And", 670.0–670.4) whisper stamped between the
76
+ * marker and the pause. 0.75s is deliberately wider than the observed 0.4s
77
+ * stretch but well under the shortest gap a speaker leaves before a real
78
+ * next take.
79
+ */
80
+ const MAX_MARKER_BLEED_SEC = 0.75;
81
+
82
+ /**
83
+ * Ceiling on the sentence-start backscan, in source seconds. An ASR stretch
84
+ * with no terminal punctuation (rambling delivery, a non-English fine-tune,
85
+ * a hallucinated run-on) lets the scan walk back through MINUTES of good
86
+ * take from one spoken marker — the same failure shape as §133's 7.08s fuzzy
87
+ * cut, unbounded. 30s is longer than any real single-sentence flub and short
88
+ * enough that a capped cut is reviewable in the report.
89
+ */
90
+ const MAX_WALKBACK_SEC = 30;
91
+
60
92
  // Fuzzy matching only turns on once the marker is long enough that a false
61
93
  // positive is unlikely — a short marker like "cut" sound-alikes ("cat") and
62
94
  // sits within edit distance 2 of half the dictionary ("but", "gut", "cot"),
@@ -120,9 +152,18 @@ function isPluralPair(a: string, b: string): boolean {
120
152
  * marker of at least `FUZZY_MIN_MARKER_LEN` characters also matches an ASR
121
153
  * mishearing — see `matchMarker`.
122
154
  *
155
+ * `silences` (source seconds, `analysis.silences`' shape) lets a span's end
156
+ * extend through the marker's own trailing dead air — the §18 stamp-stretch
157
+ * bleed `MAX_MARKER_BLEED_SEC` documents. Omitting it reproduces the
158
+ * stamped-end behavior exactly.
159
+ *
123
160
  * Returns spans in transcript order, non-overlapping.
124
161
  */
125
- export function findBloopSpans(transcript: Transcript, marker: string): BloopSpan[] {
162
+ export function findBloopSpans(
163
+ transcript: Transcript,
164
+ marker: string,
165
+ silences?: readonly Span[],
166
+ ): BloopSpan[] {
126
167
  const want = normalizeToken(marker);
127
168
  if (!want) return [];
128
169
  const words = transcript.words;
@@ -138,31 +179,67 @@ export function findBloopSpans(transcript: Transcript, marker: string): BloopSpa
138
179
 
139
180
  // Walk back over the attempt this marker spoiled, to the start of its
140
181
  // sentence. The marker's own text usually ENDS a sentence ("blooper."), so
141
- // the scan starts at the word before it.
182
+ // the scan starts at the word before it. Capped at MAX_WALKBACK_SEC of
183
+ // source time (rationale on the constant); a capped span is flagged so
184
+ // the report can shout about it.
142
185
  let start = i;
143
- while (start > 0 && !isSentenceStart(transcript, start)) start--;
186
+ let truncated = false;
187
+ while (start > 0 && !isSentenceStart(transcript, start)) {
188
+ if (words[i]!.end - words[start - 1]!.start > MAX_WALKBACK_SEC) {
189
+ truncated = true;
190
+ break;
191
+ }
192
+ start--;
193
+ }
194
+
195
+ // Extend the end through the marker's trailing dead air (2026-08-16
196
+ // incident, see MAX_MARKER_BLEED_SEC): repeatedly absorb any silence that
197
+ // starts within the bleed window and ends past the current end. The loop
198
+ // re-scans because absorbing one silence can bring the next within reach
199
+ // — the chained-silence shape.
200
+ let endSec = words[i]!.end;
201
+ if (silences && silences.length > 0) {
202
+ let extended = true;
203
+ while (extended) {
204
+ extended = false;
205
+ for (const s of silences) {
206
+ if (s.start <= endSec + MAX_MARKER_BLEED_SEC && s.end > endSec) {
207
+ endSec = s.end;
208
+ extended = true;
209
+ }
210
+ }
211
+ }
212
+ }
144
213
 
145
214
  // Consecutive attempts: if everything between the previous span's end and
146
215
  // this attempt's start is already being dropped, merge rather than leave a
147
- // one-word island of a sentence nobody finished.
216
+ // one-word island of a sentence nobody finished. The seconds arm exists
217
+ // because the previous span's EXTENDED end can reach past word stamps the
218
+ // index arm never sees — the incident's "And" debris sat between two
219
+ // attempts that were not word-index adjacent once the first end grew.
148
220
  const prev = spans[spans.length - 1];
149
221
  let markers = 1;
150
222
  let matched = match.exact ? [] : [match.surface];
151
- if (prev && start <= prev.endWord + 1) {
223
+ if (prev && (start <= prev.endWord + 1 || words[start]!.start <= prev.endSec)) {
152
224
  spans.pop();
153
225
  start = prev.startWord;
154
226
  markers = prev.markers + 1;
155
227
  matched = [...prev.matched, ...matched];
228
+ truncated = truncated || prev.truncated === true;
229
+ // A merged span must never shrink: the previous extension already
230
+ // proved that audio dead.
231
+ endSec = Math.max(endSec, prev.endSec);
156
232
  }
157
233
 
158
234
  spans.push({
159
235
  startWord: start,
160
236
  endWord: i,
161
237
  startSec: words[start]!.start,
162
- endSec: words[i]!.end,
238
+ endSec,
163
239
  markers,
164
240
  marker: want,
165
241
  matched,
242
+ ...(truncated ? { truncated: true } : {}),
166
243
  });
167
244
  }
168
245
  return spans;
@@ -187,5 +264,11 @@ export function formatBloopSpan(transcript: Transcript, span: BloopSpan): string
187
264
  span.matched.length > 0
188
265
  ? " " + span.matched.map((m) => `matched "${m}" ~ "${span.marker}"`).join(", ")
189
266
  : "";
190
- return `"${said}"${attempts}${fuzzy}`;
267
+ // A capped walk-back is a cut whose start the code chose by fiat, not by
268
+ // punctuation — the one case where the span boundary is a guess, so the
269
+ // report line must be loud about it.
270
+ const capped = span.truncated
271
+ ? " (walk-back capped at 30s — unpunctuated stretch; check this cut)"
272
+ : "";
273
+ return `"${said}"${attempts}${fuzzy}${capped}`;
191
274
  }
package/src/browser.ts CHANGED
@@ -18,6 +18,7 @@ export {
18
18
  cropFilter,
19
19
  type ContentRect,
20
20
  type ContentRectSegment,
21
+ type FramingSegment,
21
22
  } from "./content-rect";
22
23
  // lineDirection is a VALUE export but stays browser-safe: captions.ts
23
24
  // imports types only. CaptionTrack needs it at render time (Urdu field test
@@ -27,9 +28,16 @@ export {
27
28
  // a caption key from it (§137), and the editor imports this surface. Pure, and
28
29
  // captions.ts stays browser-safe — it imports types plus timemap, which is
29
30
  // itself type-only against ./schema.
31
+ // The Nastaliq trio rides the same browser-safe surface: CaptionTrack needs
32
+ // the family name + served path for its @font-face, and `captionsNeedNastaliq`
33
+ // is the ONE predicate produce and the render share for "does this caption
34
+ // set need the bundled font" (2026-08-17 — two conditions would drift).
30
35
  export {
31
36
  backfillSrcStart,
37
+ captionsNeedNastaliq,
32
38
  lineDirection,
39
+ NASTALIQ_FONT_NAME,
40
+ NASTALIQ_FONT_REL,
33
41
  type CaptionLine,
34
42
  type CaptionWord,
35
43
  } from "./captions";
package/src/captions.ts CHANGED
@@ -92,6 +92,31 @@ export function lineDirection(text: string): "rtl" | "ltr" {
92
92
  return "ltr";
93
93
  }
94
94
 
95
+ /**
96
+ * The bundled Nastaliq face (Urdu captions, 2026-08-17): rendering must not
97
+ * depend on the render machine having an Arabic-script font — a Linux CI box
98
+ * has none, and macOS/Windows each substitute a DIFFERENT one, so the same
99
+ * render-props drew three different caption sets. The family name is what
100
+ * the render-side @font-face registers; the REL path is the served URL under
101
+ * the render's public dir, POSIX-literal like `sideImageDestRel` (produce.ts:
102
+ * `staticFile()` splits only on `/`, so `path.join` would break every
103
+ * Windows render of it).
104
+ */
105
+ export const NASTALIQ_FONT_NAME = "Noto Nastaliq Urdu";
106
+ export const NASTALIQ_FONT_REL = "fonts/NotoNastaliqUrdu-Bold.ttf";
107
+
108
+ /**
109
+ * Whether this caption set needs the bundled Nastaliq face at all — some
110
+ * line lays out RTL. ONE predicate shared by produce (copies the font into
111
+ * the public dir) and CaptionTrack (injects the @font-face): if the two
112
+ * sides tested different conditions, one could fetch a file the other never
113
+ * wrote. Pure-Latin runs — the overwhelmingly common case — copy nothing and
114
+ * fetch nothing, so their renders stay byte-identical.
115
+ */
116
+ export function captionsNeedNastaliq(lines: readonly CaptionLine[]): boolean {
117
+ return lines.some((l) => lineDirection(l.words.map((w) => w.text).join(" ")) === "rtl");
118
+ }
119
+
95
120
  export interface CaptionOptions {
96
121
  maxWordsPerLine?: number;
97
122
  maxLineDuration?: number;
@@ -109,6 +134,18 @@ export interface CaptionOptions {
109
134
  breakpoints?: number[];
110
135
  }
111
136
 
137
+ /**
138
+ * No spoken word takes this long; a stamped interval longer than it is the
139
+ * §18 contiguous-stamp stretch (`parseWhisperJson` clamps
140
+ * `next.start = w.end`, so non-speech audio is smeared into the next word's
141
+ * START — field case 2026-08-17: 21s of played-back video audio put "Okay,"
142
+ * on screen 21 seconds early). The END stamp is the trustworthy edge — clamp
143
+ * the start toward it. Display-only: cuts, analysis and the transcript
144
+ * itself never see this, and `srcStart` keeps the RAW source stamp so the
145
+ * §137 edit anchor does not move.
146
+ */
147
+ export const MAX_CAPTION_WORD_LEAD_SEC = 2;
148
+
112
149
  export function buildCaptionLines(
113
150
  transcript: Transcript,
114
151
  map: TimeMap,
@@ -124,8 +161,14 @@ export function buildCaptionLines(
124
161
  for (const w of transcript.words) {
125
162
  const m = map.mapWord(w as Word);
126
163
  // `w.start` is source time, `m.start` output — both are needed, and only
127
- // the source one is stable across a re-cut (§137).
128
- if (m) mapped.push({ text: w.text, start: m.start, end: m.end, srcStart: w.start });
164
+ // the source one is stable across a re-cut (§137). The display start is
165
+ // clamped toward the end stamp (MAX_CAPTION_WORD_LEAD_SEC above); the
166
+ // clamped gap to the previous word then exceeds `maxGap`, so the packer
167
+ // naturally breaks the line and nothing renders during the dead span.
168
+ if (m) {
169
+ const start = Math.max(m.start, m.end - MAX_CAPTION_WORD_LEAD_SEC);
170
+ mapped.push({ text: w.text, start, end: m.end, srcStart: w.start });
171
+ }
129
172
  }
130
173
 
131
174
  const lines: CaptionLine[] = [];
package/src/concat.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
- import { join } from "node:path";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { z } from "zod/v4";
5
5
  import { probe, type IngestTools } from "./ingest";
6
6
  import { run } from "./exec";
@@ -42,6 +42,78 @@ export interface ConcatEntry {
42
42
  size: number;
43
43
  }
44
44
 
45
+ /**
46
+ * Whether a produce output path lands INSIDE the folder being produced.
47
+ * 2026-08-18 field cascade: a folder input's clips are re-enumerated on
48
+ * EVERY run, so an `--out` written into that folder became a 7th "source
49
+ * clip" on the next run — the folder-content hash changed, produce minted a
50
+ * fresh workdir with EMPTY overrides, and the render silently dropped the
51
+ * user's saved edits while the output's duration doubled. It cascaded three
52
+ * times before the doubling duration was connected to the out path.
53
+ *
54
+ * Containment via `path.relative`, the same idiom as edit.ts's `isInside`:
55
+ * a `startsWith` string-prefix test has no separator boundary and is fooled
56
+ * by a sibling folder that merely shares a prefix (`/a/Clips` vs
57
+ * `/a/Clips-old/out.mp4`) — `child` is inside `parent` iff the relative path
58
+ * from one to the other never has to climb out with `..`. Both arguments are
59
+ * resolved here so a relative path is judged against cwd; `~` expansion is
60
+ * the CALLER's job (the 2026-08-16 rule in the CLI's paths.ts — expandHome
61
+ * at the call site — which also keeps core homedir-free).
62
+ */
63
+ export function outPathInsideInput(outPath: string, inputDir: string): boolean {
64
+ const rel = relative(resolve(inputDir), resolve(outPath));
65
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
66
+ }
67
+
68
+ /**
69
+ * The refusal for `outPathInsideInput`, shared verbatim by produce's own
70
+ * gate and the edit server's `/api/render` 400 — one message, one place, so
71
+ * the two boundaries can never describe the same hazard differently. Names
72
+ * the actual risk (the field cascade above) rather than just "invalid path",
73
+ * and suggests the exact default a flag-less run would pick (produce's
74
+ * `defaultOutPath` shape: at most the LAST dot-segment replaced).
75
+ */
76
+ /**
77
+ * The default output path for an input: beside it, `.ossclip.mp4` suffix.
78
+ * Trailing separators are stripped FIRST (2026-08-18 field case, second
79
+ * report): shells tab-complete folders with the slash, and the bare regex
80
+ * appended after it — the "default" landed INSIDE the folder as a hidden
81
+ * `.ossclip.mp4` dotfile, the exact self-ingesting shape
82
+ * `outPathInsideInput` exists to refuse. One definition shared by produce's
83
+ * `defaultOutPath` and the refusal message below, so the suggestion can
84
+ * never disagree with what omitting `--out` actually does.
85
+ */
86
+ export function ossclipOutputPathFor(input: string): string {
87
+ return input.replace(/[/\\]+$/, "").replace(/(\.[^.]+)?$/, ".ossclip.mp4");
88
+ }
89
+
90
+ export function outInsideInputFolderMessage(folder: string): string {
91
+ const suggestion = ossclipOutputPathFor(folder);
92
+ return (
93
+ `refusing to write the output inside the input folder ${folder} — the next ` +
94
+ `run would ingest this output as a source clip and re-plan from scratch, ` +
95
+ `abandoning your edits (the folder's content hash changes, so produce mints ` +
96
+ `a fresh workdir with empty overrides). Write it beside the folder instead, ` +
97
+ `e.g. --out ${suggestion}`
98
+ );
99
+ }
100
+
101
+ /**
102
+ * Defense-in-depth BEHIND the out-path refusal above (same 2026-08-18 field
103
+ * cascade): a produce output already sitting in the folder — written by a
104
+ * pre-fix run, or moved there by hand — must never be ingested as a source
105
+ * clip, and must be filtered BEFORE `folderManifestKey` sees the entries so
106
+ * its presence can't re-key the workdir either. Matches any name carrying
107
+ * the `.ossclip` marker: `X.ossclip.mp4`, `X.ossclip_fixed.mp4`, and
108
+ * `X.ossclip.mp4.partial.mp4` leftovers all qualify (bare `.ossclip.mp4` is
109
+ * a dotfile and already skipped upstream). A custom-named output
110
+ * (`--out final.mp4`) is undetectable here — the out-path refusal is the
111
+ * real gate; this only keeps a pre-fix folder from compounding further.
112
+ */
113
+ export function isOssclipOutputName(name: string): boolean {
114
+ return name.includes(".ossclip");
115
+ }
116
+
45
117
  /**
46
118
  * Order clips for concatenation. `name` (default, per the field request "sort
47
119
  * them by name or date modified, name being default") is a PLAIN codepoint
@@ -321,6 +393,8 @@ export interface FolderListing {
321
393
  entries: ConcatEntry[];
322
394
  /** Files skipped for not matching a video extension (dotfiles excluded). */
323
395
  nonVideoCount: number;
396
+ /** Video files skipped as produce's own outputs (`isOssclipOutputName`). */
397
+ ossclipOutputCount: number;
324
398
  }
325
399
 
326
400
  /**
@@ -339,6 +413,7 @@ export interface FolderListing {
339
413
  export async function listFolderVideos(folder: string): Promise<FolderListing> {
340
414
  const dirents = await readdir(folder, { withFileTypes: true });
341
415
  let nonVideoCount = 0;
416
+ let ossclipOutputCount = 0;
342
417
  const entries: ConcatEntry[] = [];
343
418
  for (const d of dirents) {
344
419
  if (d.name.startsWith(".")) continue;
@@ -357,11 +432,19 @@ export async function listFolderVideos(folder: string): Promise<FolderListing> {
357
432
  nonVideoCount++;
358
433
  continue;
359
434
  }
435
+ // Skipped HERE, before the entries ever exist — never in concatFolder —
436
+ // so an ossclip output left in the folder can neither become a source
437
+ // clip nor perturb the workdir hash `folderManifestKey` derives from
438
+ // this listing (2026-08-18 field cascade, see isOssclipOutputName).
439
+ if (isOssclipOutputName(d.name)) {
440
+ ossclipOutputCount++;
441
+ continue;
442
+ }
360
443
  const st = await stat(join(folder, d.name));
361
444
  entries.push({ name: d.name, mtimeMs: st.mtimeMs, size: st.size });
362
445
  }
363
446
  if (entries.length === 0) throw noVideoFilesError(folder);
364
- return { entries, nonVideoCount };
447
+ return { entries, nonVideoCount, ossclipOutputCount };
365
448
  }
366
449
 
367
450
  export interface FolderConcatResult {
@@ -371,6 +454,8 @@ export interface FolderConcatResult {
371
454
  clips: Array<{ name: string; durationSec: number }>;
372
455
  /** Files skipped for not matching a video extension. */
373
456
  nonVideoCount: number;
457
+ /** Video files skipped as produce's own outputs (`isOssclipOutputName`). */
458
+ ossclipOutputCount: number;
374
459
  /** True when the existing `source-concat.mp4` was reused, not rebuilt. */
375
460
  cached: boolean;
376
461
  /** The concat's own total duration (ffprobe'd from the output). */
@@ -393,7 +478,7 @@ export async function concatFolder(
393
478
  sort: "name" | "mtime",
394
479
  target: { w: number; h: number },
395
480
  ): Promise<FolderConcatResult> {
396
- const { entries: current, nonVideoCount } = listing;
481
+ const { entries: current, nonVideoCount, ossclipOutputCount } = listing;
397
482
  if (current.length === 0) throw noVideoFilesError(folder);
398
483
  const order = planFolderConcat(current, sort);
399
484
 
@@ -408,6 +493,7 @@ export async function concatFolder(
408
493
  path: outPath,
409
494
  clips: order.map((name) => ({ name, durationSec: byName.get(name)!.durationSec })),
410
495
  nonVideoCount,
496
+ ossclipOutputCount,
411
497
  cached: true,
412
498
  durationSec: outProbe.duration,
413
499
  };
@@ -424,11 +510,11 @@ export async function concatFolder(
424
510
 
425
511
  const filter = buildConcatFilter(order.length, target);
426
512
  const inputArgs = order.flatMap((name) => ["-i", join(folder, name)]);
427
- // Encode to a sibling temp path, rename only on success — same reasoning as
428
- // `bakeNormalizedSource` in normalize.ts (R27 §125): ffmpeg writes the
429
- // container header as it goes, so a bake that dies mid-graph leaves a file
430
- // with no `moov` atom, and a cache keyed on EXISTENCE would reuse that
431
- // corpse forever. Rename is atomic on a POSIX filesystem.
513
+ // Encode to a sibling temp path, rename only on success (R27 §125, learned
514
+ // on the since-removed normalization bake): ffmpeg writes the container
515
+ // header as it goes, so an encode that dies mid-graph leaves a file with no
516
+ // `moov` atom, and a cache keyed on EXISTENCE would reuse that corpse
517
+ // forever. Rename is atomic on a POSIX filesystem.
432
518
  const partial = `${outPath}.partial.mp4`;
433
519
  try {
434
520
  await run(tools.ffmpegPath, [
@@ -461,6 +547,7 @@ export async function concatFolder(
461
547
  path: outPath,
462
548
  clips: order.map((name, i) => ({ name, durationSec: probes[i]!.duration })),
463
549
  nonVideoCount,
550
+ ossclipOutputCount,
464
551
  cached: false,
465
552
  durationSec: outProbe.duration,
466
553
  };