@ossclip/core 0.1.9 → 0.1.11

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/concat.ts +142 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossclip/core",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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",
package/src/concat.ts CHANGED
@@ -131,21 +131,148 @@ export function buildConcatFilter(n: number, target: { w: number; h: number }):
131
131
  * no way to know a clip is silent. Handed a video-only clip (b-roll with no
132
132
  * audio stream), ffmpeg dies deep inside the filtergraph with a bare stream-
133
133
  * specifier error that names neither the clip nor the reason. `probe()`
134
- * already reports `hasAudio`; failing HERE, before ffmpeg ever runs, keeps
135
- * faith with the brief's "a file that probe() rejects is an error naming the
136
- * file, not a silent skip" — a clip with no audio is the same class of
137
- * problem, just discovered an instant later than "no video stream at all".
134
+ * already reports `hasAudio`; failing BEFORE ffmpeg ever runs keeps faith
135
+ * with the brief's "a file that probe() rejects is an error naming the file,
136
+ * not a silent skip" — a clip with no audio is the same class of problem,
137
+ * just discovered an instant later than "no video stream at all".
138
+ *
139
+ * `evaluateAudioProbes` is the guard's pure decision (0.1.9 first-contact,
140
+ * 2026-08-05): the fail-fast pool below fires it the moment a silent clip is
141
+ * seen, when only SOME probes have settled — so the verdict carries both the
142
+ * offenders known at that moment and how many clips were never checked, and
143
+ * the message can be honest about the difference instead of implying a
144
+ * complete list it doesn't have.
138
145
  */
146
+ export interface AudioGuardFailure {
147
+ /** Clips known silent when the guard fired, in enumeration order. */
148
+ offenders: string[];
149
+ /** Clips whose probes had not settled when the guard fired. */
150
+ uncheckedCount: number;
151
+ }
152
+
153
+ export function evaluateAudioProbes(
154
+ settled: ReadonlyArray<{ name: string; hasAudio: boolean }>,
155
+ total: number,
156
+ ): AudioGuardFailure | null {
157
+ const offenders = settled.filter((c) => !c.hasAudio).map((c) => c.name);
158
+ if (offenders.length === 0) return null;
159
+ return { offenders, uncheckedCount: total - settled.length };
160
+ }
161
+
162
+ export function audioGuardMessage(failure: AudioGuardFailure): string {
163
+ const { offenders, uncheckedCount } = failure;
164
+ // "Result", not "clip": probes settle in parallel, so the unchecked set can
165
+ // include clips EARLIER in concat order than the offender — "later clips"
166
+ // would overclaim an ordering the pool doesn't have (review, Minor 1).
167
+ const stopped =
168
+ uncheckedCount > 0
169
+ ? ` Stopped at the first missing-audio result; ${uncheckedCount} ` +
170
+ `clip${uncheckedCount === 1 ? " was" : "s were"} not checked.`
171
+ : "";
172
+ // The ~/Downloads sentence is the 0.1.9 first-contact lesson: the guard
173
+ // fired CORRECTLY, but on the wrong folder — the wizard had been answered
174
+ // with all of ~/Downloads, and "13 clips lack audio" never suggested the
175
+ // user was one directory too high.
176
+ return (
177
+ `no audio stream in: ${offenders.join(", ")} — produce cuts by silence, so ` +
178
+ "every clip in a folder concat needs one (a silent b-roll clip can't be " +
179
+ "concatenated this way)." +
180
+ stopped +
181
+ " If this is a mixed folder like ~/Downloads rather than a folder of just " +
182
+ "the takes to concatenate, point produce at the clips folder instead."
183
+ );
184
+ }
185
+
139
186
  export function assertAllClipsHaveAudio(
140
187
  clips: ReadonlyArray<{ name: string; hasAudio: boolean }>,
141
188
  ): void {
142
- const silent = clips.filter((c) => !c.hasAudio).map((c) => c.name);
143
- if (silent.length > 0) {
144
- throw new Error(
145
- `no audio stream in: ${silent.join(", ")} — produce cuts by silence, so ` +
146
- "every clip in a folder concat needs one (a silent b-roll clip can't be concatenated this way).",
147
- );
148
- }
189
+ const failure = evaluateAudioProbes(clips, clips.length);
190
+ if (failure !== null) throw new Error(audioGuardMessage(failure));
191
+ }
192
+
193
+ /**
194
+ * Bounded, fail-fast probe pool (0.1.9 first-contact, 2026-08-05): the guard
195
+ * used to `Promise.all` every probe — pointed at ~/Downloads, that meant
196
+ * 4m32s of ffprobe over ~100 unrelated videos before the error the FIRST
197
+ * silent clip already implied. Two fixes in one shape: a concurrency bound so
198
+ * a big folder doesn't stampede the disk with a hundred simultaneous
199
+ * ffprobes, and an immediate reject on the first missing-audio result — the
200
+ * verdict is built from whatever has settled at that moment, and in-flight
201
+ * probes are simply ignored (ffprobe is a short-lived read-only process;
202
+ * letting it finish unobserved is harmless).
203
+ *
204
+ * Generic over `probeOne` so the scheduling is testable without ffprobe; the
205
+ * abort/offenders DECISION lives in `evaluateAudioProbes` (pure), this is the
206
+ * IO glue around it.
207
+ */
208
+ export async function probeClipsWithAudioGuard<T extends { hasAudio: boolean }>(
209
+ names: readonly string[],
210
+ probeOne: (name: string, index: number) => Promise<T>,
211
+ concurrency = 8,
212
+ ): Promise<T[]> {
213
+ if (names.length === 0) return [];
214
+ const results: (T | undefined)[] = new Array(names.length);
215
+ return await new Promise<T[]>((resolveAll, rejectAll) => {
216
+ let nextIndex = 0;
217
+ let settledCount = 0;
218
+ let finished = false;
219
+
220
+ const launch = (): void => {
221
+ if (finished || nextIndex >= names.length) return;
222
+ const i = nextIndex;
223
+ nextIndex++;
224
+ const name = names[i];
225
+ if (name === undefined) return; // unreachable: i < names.length
226
+ // Deferred through a resolved promise so a probeOne that throws
227
+ // SYNCHRONOUSLY rejects the pool instead of deadlocking it (review,
228
+ // Minor 2): a sync throw out of a replacement `launch()` inside the
229
+ // success handler below would otherwise escape both handlers, leaving
230
+ // the pool one settle short of ever resolving. Unreachable with the
231
+ // real async probe, but the generic API must not depend on that.
232
+ Promise.resolve()
233
+ .then(() => probeOne(name, i))
234
+ .then(
235
+ (result) => {
236
+ if (finished) return;
237
+ results[i] = result;
238
+ settledCount++;
239
+ if (!result.hasAudio) {
240
+ const settled: Array<{ name: string; hasAudio: boolean }> = [];
241
+ for (let j = 0; j < names.length; j++) {
242
+ const r = results[j];
243
+ const n = names[j];
244
+ if (r !== undefined && n !== undefined) settled.push({ name: n, hasAudio: r.hasAudio });
245
+ }
246
+ const failure = evaluateAudioProbes(settled, names.length);
247
+ // Never null here — the result that brought us into this branch
248
+ // lacks audio — but throwing through the pure function keeps one
249
+ // single source of truth for the verdict.
250
+ if (failure !== null) {
251
+ finished = true;
252
+ rejectAll(new Error(audioGuardMessage(failure)));
253
+ }
254
+ return;
255
+ }
256
+ if (settledCount === names.length) {
257
+ finished = true;
258
+ resolveAll(results as T[]);
259
+ } else {
260
+ launch();
261
+ }
262
+ },
263
+ (err: unknown) => {
264
+ // A probe that FAILS (e.g. `no video stream in <path>`) propagates
265
+ // as-is, same as the Promise.all it replaced — an error naming the
266
+ // file, not a silent skip (folder-input-brief.md).
267
+ if (finished) return;
268
+ finished = true;
269
+ rejectAll(err instanceof Error ? err : new Error(String(err)));
270
+ },
271
+ );
272
+ };
273
+ const initial = Math.min(concurrency, names.length);
274
+ for (let k = 0; k < initial; k++) launch();
275
+ });
149
276
  }
150
277
 
151
278
  const ConcatManifestSchema = z.object({
@@ -284,9 +411,10 @@ export async function concatFolder(
284
411
  // A file with a video EXTENSION that fails to probe (no video stream) is an
285
412
  // error naming the file, not a silent skip (folder-input-brief.md) — so
286
413
  // `probe()`'s own "no video stream in <path>" is left to propagate rather
287
- // than caught here.
288
- const probes = await Promise.all(order.map((name) => probe(tools, join(folder, name))));
289
- assertAllClipsHaveAudio(order.map((name, i) => ({ name, hasAudio: probes[i]!.hasAudio })));
414
+ // than caught here. The pool bounds concurrency and rejects on the FIRST
415
+ // silent clip instead of probing the whole folder before refusing (0.1.9
416
+ // first-contact, 2026-08-05 — see probeClipsWithAudioGuard).
417
+ const probes = await probeClipsWithAudioGuard(order, (name) => probe(tools, join(folder, name)));
290
418
 
291
419
  const filter = buildConcatFilter(order.length, target);
292
420
  const inputArgs = order.flatMap((name) => ["-i", join(folder, name)]);