ziplayer 0.3.5 → 0.3.7

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,6 +4,8 @@ import type { Player } from "./Player";
4
4
  import type { PlayerManager } from "./PlayerManager";
5
5
  import prism, { FFmpeg } from "prism-media";
6
6
  import type { Readable } from "stream";
7
+ import { spawn, type ChildProcess } from "child_process";
8
+ import ffmpegPath from "ffmpeg-static";
7
9
 
8
10
  type DebugFn = (message?: any, ...optionalParams: any[]) => void;
9
11
 
@@ -14,6 +16,7 @@ export class FilterManager {
14
16
  private ffmpeg: FFmpeg | null = null;
15
17
  private currentInputStream: Readable | null = null;
16
18
  public StreamType: "webm/opus" | "ogg/opus" | "mp3" | "arbitrary" = "mp3";
19
+ private ffmpegProcess: ChildProcess | null = null;
17
20
 
18
21
  constructor(player: Player, manager: PlayerManager) {
19
22
  this.player = player as Player;
@@ -35,15 +38,18 @@ export class FilterManager {
35
38
  destroy(): void {
36
39
  this.activeFilters = [];
37
40
 
38
- // Destroy FFmpeg process
39
41
  if (this.ffmpeg) {
40
42
  try {
41
43
  this.ffmpeg.destroy();
42
44
  } catch {}
43
45
  this.ffmpeg = null;
44
46
  }
45
-
46
- // Destroy input stream
47
+ if (this.ffmpegProcess) {
48
+ try {
49
+ this.ffmpegProcess.kill("SIGKILL");
50
+ } catch {}
51
+ this.ffmpegProcess = null;
52
+ }
47
53
  if (this.currentInputStream && typeof (this.currentInputStream as any).destroy === "function") {
48
54
  try {
49
55
  (this.currentInputStream as any).destroy();
@@ -221,83 +227,154 @@ export class FilterManager {
221
227
  */
222
228
  public async applyFiltersAndSeek(stream: Readable, position: number = -1): Promise<Readable> {
223
229
  const filterString = this.getFilterString();
224
- this.debug(`[FilterManager] Applying filters and seek to stream: ${filterString || "none"}, seek: ${position}ms`);
225
- try {
226
- const args = ["-analyzeduration", "0", "-loglevel", "0"];
230
+ this.debug(`Applying filters and seek filters: ${filterString || "none"}, seek: ${position}ms`);
227
231
 
228
- if (position > 0) {
229
- const seekSeconds = Math.floor(position / 1000);
230
- args.push("-ss", seekSeconds.toString());
232
+ // Tear down any previous FFmpeg instances.
233
+ try {
234
+ if (this.ffmpeg) {
235
+ this.ffmpeg.destroy();
236
+ this.ffmpeg = null;
231
237
  }
232
-
233
- // Add filter if any are active
234
- if (filterString) {
235
- args.push("-af", filterString);
238
+ if (this.ffmpegProcess) {
239
+ this.ffmpegProcess.kill("SIGKILL");
240
+ this.ffmpegProcess = null;
241
+ }
242
+ if (
243
+ this.currentInputStream &&
244
+ typeof (this.currentInputStream as any).destroy === "function" &&
245
+ !(this.currentInputStream as any).destroyed
246
+ ) {
247
+ try {
248
+ (this.currentInputStream as any).destroy();
249
+ } catch {}
236
250
  }
237
- args.push(
238
- "-f",
239
- this.StreamType === "webm/opus" ? "webm/opus"
240
- : this.StreamType === "ogg/opus" ? "ogg/opus"
241
- : "mp3",
242
- );
243
- args.push("-ar", "48000", "-ac", "2");
251
+ this.currentInputStream = null;
252
+ } catch {}
244
253
 
254
+ this.currentInputStream = stream;
255
+
256
+ // ── INPUT-SIDE SEEKING ─────────────────────────────────────────────────────
257
+ // When a seek position is requested, place -ss BEFORE -i so FFmpeg seeks
258
+ // in the compressed domain (keyframe-level) rather than decoding every
259
+ // frame up to the target. This is dramatically faster for large positions.
260
+ //
261
+ // Output-side (slow): ffmpeg -i pipe:0 -ss 109 ...
262
+ // → reads and decodes all 109 s before outputting anything
263
+ //
264
+ // Input-side (fast): ffmpeg -ss 109 -i pipe:0 ...
265
+ // → seeks to nearest keyframe < 109 s, outputs from there
266
+ //
267
+ // prism.FFmpeg always places user args after -i, so we spawn directly.
268
+ if (position >= 0 && ffmpegPath) {
269
+ return this.spawnFFmpegInputSeek(stream, position, filterString);
270
+ }
271
+
272
+ // ── FILTER-ONLY (no seek) — use prism.FFmpeg as before ────────────────────
273
+ const args = ["-analyzeduration", "0", "-loglevel", "0"];
274
+ if (filterString) {
275
+ args.push("-af", filterString);
276
+ }
277
+ args.push(
278
+ "-f",
279
+ this.StreamType === "webm/opus" ? "webm/opus"
280
+ : this.StreamType === "ogg/opus" ? "ogg/opus"
281
+ : "mp3",
282
+ );
283
+ args.push("-ar", "48000", "-ac", "2");
284
+
285
+ try {
286
+ this.ffmpeg = stream.pipe(new prism.FFmpeg({ args }));
287
+ } catch (spawnError) {
288
+ this.debug(`FFmpeg spawn error:`, spawnError);
289
+ this.currentInputStream = null;
290
+ throw spawnError;
291
+ }
292
+
293
+ this.ffmpeg.on("close", () => {
294
+ this.debug(`FFmpeg processing completed`);
295
+ try {
296
+ if (this.ffmpeg) {
297
+ this.ffmpeg.destroy();
298
+ this.ffmpeg = null;
299
+ }
300
+ } catch {}
301
+ });
302
+ this.ffmpeg.on("error", (err: Error) => {
303
+ this.debug(`FFmpeg error:`, err);
245
304
  try {
246
305
  if (this.ffmpeg) {
247
306
  this.ffmpeg.destroy();
248
307
  this.ffmpeg = null;
249
308
  }
250
- // Destroy previous input stream
251
- if (this.currentInputStream && typeof (this.currentInputStream as any).destroy === "function") {
309
+ if (this.currentInputStream && !(this.currentInputStream as any).destroyed) {
252
310
  try {
253
311
  (this.currentInputStream as any).destroy();
254
312
  } catch {}
255
313
  }
256
- this.currentInputStream = null;
257
314
  } catch {}
315
+ this.currentInputStream = null;
316
+ });
258
317
 
259
- // Store reference to input stream
260
- this.currentInputStream = stream;
318
+ return this.ffmpeg;
319
+ }
261
320
 
262
- this.ffmpeg = stream.pipe(new prism.FFmpeg({ args }));
321
+ private spawnFFmpegInputSeek(stream: Readable, position: number, filterString: string): Readable {
322
+ const seekSeconds = (position / 1000).toFixed(3);
263
323
 
264
- this.ffmpeg.on("close", () => {
265
- this.debug(`[FilterManager] FFmpeg filter+seek processing completed`);
266
- try {
267
- if (this.ffmpeg) {
268
- this.ffmpeg.destroy();
269
- this.ffmpeg = null;
270
- }
271
- } catch {}
272
- });
324
+ const args: string[] = [
325
+ // INPUT-SIDE SEEK: placed before -i
326
+ "-ss",
327
+ seekSeconds,
328
+ "-i",
329
+ "pipe:0",
330
+ // Output options
331
+ "-analyzeduration",
332
+ "0",
333
+ "-loglevel",
334
+ "0",
335
+ ];
273
336
 
274
- this.ffmpeg.on("error", (err: Error) => {
275
- this.debug(`[FilterManager] FFmpeg filter+seek error:`, err);
276
- try {
277
- if (this.ffmpeg) {
278
- this.ffmpeg.destroy();
279
- this.ffmpeg = null;
280
- }
281
- // Also destroy input stream on error
282
- if (this.currentInputStream && typeof (this.currentInputStream as any).destroy === "function") {
283
- (this.currentInputStream as any).destroy();
284
- }
285
- } catch {}
286
- this.currentInputStream = null;
287
- });
288
-
289
- return this.ffmpeg;
290
- } catch (error) {
291
- this.debug(`[FilterManager] Error creating FFmpeg instance:`, error);
292
- // Destroy input stream if FFmpeg fails
293
- if (this.currentInputStream && typeof (this.currentInputStream as any).destroy === "function") {
294
- try {
295
- (this.currentInputStream as any).destroy();
296
- } catch {}
297
- }
298
- this.currentInputStream = null;
299
- // Fallback to original stream if FFmpeg fails
300
- throw error;
337
+ if (filterString) {
338
+ args.push("-af", filterString);
301
339
  }
340
+
341
+ const outFormat =
342
+ this.StreamType === "webm/opus" ? "webm"
343
+ : this.StreamType === "ogg/opus" ? "ogg"
344
+ : "mp3";
345
+
346
+ args.push("-f", outFormat, "-ar", "48000", "-ac", "2", "pipe:1");
347
+
348
+ const proc = spawn(ffmpegPath!, args, {
349
+ stdio: ["pipe", "pipe", "ignore"],
350
+ });
351
+ this.ffmpegProcess = proc;
352
+
353
+ // Pipe source → ffmpeg stdin
354
+ stream.pipe(proc.stdin!);
355
+
356
+ // Suppress EPIPE on stdin when the process exits early
357
+ proc.stdin!.on("error", (err: Error) => {
358
+ if ((err as any).code !== "EPIPE") {
359
+ this.debug(`FFmpeg stdin error: ${err.message}`);
360
+ }
361
+ });
362
+
363
+ proc.stdout!.on("error", (err: Error) => {
364
+ this.debug(`FFmpeg stdout error: ${err.message}`);
365
+ });
366
+
367
+ proc.on("close", (code) => {
368
+ this.debug(`FFmpeg process exited (code: ${code})`);
369
+ this.ffmpegProcess = null;
370
+ });
371
+
372
+ proc.on("error", (err: Error) => {
373
+ this.debug(`FFmpeg process error: ${err.message}`);
374
+ this.ffmpegProcess = null;
375
+ throw err;
376
+ });
377
+
378
+ return proc.stdout as Readable;
302
379
  }
303
380
  }