@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.
- package/package.json +1 -1
- package/src/concat.ts +142 -14
package/package.json
CHANGED
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
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
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
|
|
143
|
-
if (
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
289
|
-
|
|
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)]);
|