fluent-ffmpeg / node-fluent-ffmpeg

A fluent API to FFMPEG (http://www.ffmpeg.org)
MIT License
7.96k stars 881 forks source link

Cannot save a scaled and unscaled output from selected frames #1224

Closed shayypy closed 1 year ago

shayypy commented 1 year ago

Version information

Code to reproduce

ffmpeg(fileName)
  .complexFilter([
    // `frames` (number[]) - the list of frames I want to output
    { filter: 'select', options: `'${frames.map(f => `eq(n\,${f})`).join("+")}'`, outputs: 'in1' },
    { inputs: 'in1', filter: 'split', outputs: ['out1', 'in2'] },
    { inputs: 'in2', filter: 'scale', options: "-2:'min(ih\,160)'", outputs: 'out2' },
  ], ['out1', 'out2'])
  .output(`fullres/%04d.webp`)
  .outputOptions([
    "-map '[out1]'?", // My videos don't have audio tracks which I think is why ffmpeg was yelling at me. The trailing ? here is to ignore it.
    "-vsync 0", // Ignore frames not specified in the `select` filter
    "-frame_pts 1", // Name files the number of the frame that they are
    "-c:v libwebp", // Static webps
  ])
  .output(`thumbs/%04d.jpg`)
  .outputOptions([
    "-map '[out2]'?",
    "-vsync 0",
    "-frame_pts 1",
  ])
  .on('progress', (progress) => {
    if (progress.percent) {
      console.log(`${Math.floor(progress.percent)}% finished (${frames.length} frames total)`);
    }
  })
  .on("error", (err, stdout, stderr) => {
    console.log('Error: ' + err.message);
    console.log('ffmpeg output:\n' + stdout);
    console.log('ffmpeg stderr:\n' + stderr);
  })
  .on("end", () => {
    console.log("Finished");
  })
  .run();

And the corresponding ffmpeg command wherein this does work as expected (I'm using a .bat file, excuse the oddities):

ffmpeg -i S01E01.mp4 -filter_complex^
    "[0:v]select='eq(n\,50)+eq(n\,184)',split=2[out1][in2];[in2]scale=-2:'min(ih\,160)'[out2]"^
    -map "[out1]" -vsync 0 -frame_pts 1 -c:v libwebp fullres\%%04d.webp^
    -map "[out2]" -vsync 0 -frame_pts 1 thumbs\%%04d.jpg

Expected results

The frames specified in the select filter are outputted in the fullres directory as native-resolution webps, and the same frames are also outputted to the thumbs directory as jpgs with a maximum height of 160px, preserving aspect ratio.

Observed results

The webps are saved as detailed above, but instead of the same frames being written as thumbnails in the other directory, full resolution jpgs are produced for every frame in the video (i.e. not just those filtered by the select).

I have also tried using my plain filter_complex string in the complexFilter function to no avail, so I assume this is a problem with the way I have written my output options.

What am I doing wrong? Thank you for your time.

Update: This morning I decided to check the ffmpeg version I was using in my batch file and change my ffmpeg-static version accordingly. Unfortunately this did not resolve the issue, but I did notice that some (but not all) of the saved webp files were at the thumbnail resolution. I don't know what this means but I figured I'd catalogue it. Also, in my tests I am only selecting about 4 frames, but in production I'll be passing around 7,000 (from videos with upwards of 30,000). Is this "too much" to handle at any point? If so, what should I be doing instead?

shayypy commented 1 year ago

I managed to partially solve my specific problem by cloning the command and applying a filter to each cloned command. I am not sure about the performance implications of this solution.

const command = ffmpeg(fileName);

command.clone()
  .videoFilter(`select='${frames.map(f => `eq(n\,${f})`).join("+")}',scale=-2:'min(ih\,160)'`)
  .output(`thumbs/%04d.jpg`)
  .outputOptions([
    "-map '[out2]'?",
    "-vsync 0",
    "-frame_pts 1",
  ])
  .run();

command.clone()
  .videoFilter([
    { filter: 'select', options: `'${frames.map(f => `eq(n\,${f})`).join("+")}'` }
  ])
  .output(`fullres/%04d.webp`)
  .outputOptions([
    "-map '[out1]'?",
    "-vsync 0",
    "-frame_pts 1",
    "-c:v libwebp",
  ])
  .run();

However, attempting to use this solution with real data (several thousand selected frames) causes the following error, as I speculated above:

Error: spawn ENAMETOOLONG
    at ChildProcess.spawn (node:internal/child_process:413:11)
    at spawn (node:child_process:700:9)
    at <fullpath>\node_modules\fluent-ffmpeg\lib\processor.js:152:24
    at FfmpegCommand.proto._getFfmpegPath (<fullpath>\node_modules\fluent-ffmpeg\lib\capabilities.js:90:14)
    at FfmpegCommand.proto._spawnFfmpeg (<fullpath>\node_modules\fluent-ffmpeg\lib\processor.js:132:10)
    at <fullpath>\node_modules\fluent-ffmpeg\lib\processor.js:437:12
    at wrapper (<fullpath>\node_modules\async\dist\async.js:271:20)
    at next (<fullpath>\node_modules\async\dist\async.js:5795:24)
    at <fullpath>\node_modules\async\dist\async.js:327:20
    at <fullpath>\node_modules\fluent-ffmpeg\lib\processor.js:363:11 {
  errno: -4064,
  code: 'ENAMETOOLONG',
  syscall: 'spawn'
}

I'm aware why this happened - does anyone know how I could accomplish this? The closest I found was this Stack Overflow question where the only "real" solution seems to be exporting every frame and then getting rid of the ones I don't want (or perhaps determining the maximum length of a command and issuing multiple with each block of frames). Obviously I would prefer to not store all ~30,000 frames of each video in order to trim out the few thousand I want.

shayypy commented 1 year ago

This is what I came up with just to conclude the issue:

const frameBlockSize = 2800;
let remainingFrames = [...exportFrames];
while (remainingFrames.length > 0) {
  const block = remainingFrames.slice(0, frameBlockSize);
  await generateFrames(videoFile, block); // ffmpeg is wrapped in a promise
  remainingFrames = remainingFrames.slice(frameBlockSize);
  console.log(`Processed block of ${block.length} frames. ${remainingFrames.length} remaining.`)
}

It's very slow - saving only the first 2800 frames took about 9 minutes. I stopped there because the next block was too large, but with 4079 remaining I estimate it would have taken about 24 minutes total. I think instead I'll select every Nth frame and hope it's enough for my use case.