diff --git a/index.js b/index.js old mode 100644 new mode 100755 diff --git a/lib/extract-video-frames.js b/lib/extract-video-frames.js index 2275bc4..ff47efe 100644 --- a/lib/extract-video-frames.js +++ b/lib/extract-video-frames.js @@ -5,14 +5,23 @@ const ffmpeg = require('fluent-ffmpeg') module.exports = (opts) => { const { videoPath, - framePattern + framePattern, + seek, + duration, + fps } = opts return new Promise((resolve, reject) => { - ffmpeg(videoPath) + const cmd = ffmpeg(videoPath) + + if (seek) cmd.seek(seek / 1000) + if (duration) cmd.setDuration(duration / 1000) + + cmd .outputOptions([ '-pix_fmt', 'rgba', - '-start_number', '0' + '-start_number', '0', + '-r', fps ]) .output(framePattern) .on('start', (cmd) => console.log({ cmd })) diff --git a/lib/index.js b/lib/index.js index 8fcaa60..eca2aaa 100644 --- a/lib/index.js +++ b/lib/index.js @@ -30,7 +30,8 @@ module.exports = async (opts) => { try { console.time(`init-frames`) const { - frames, + renders, + scenes, theme } = await initFrames({ log, @@ -44,12 +45,12 @@ module.exports = async (opts) => { console.timeEnd(`init-frames`) console.time(`render-frames`) - const framePattern = await renderFrames({ + const framePatterns = await renderFrames({ log, concurrency, outputDir: temp, frameFormat, - frames, + renders, theme, onProgress: (p) => { log(`render ${(100 * p).toFixed()}%`) @@ -60,10 +61,11 @@ module.exports = async (opts) => { console.time(`transcode-video`) await transcodeVideo({ log, - framePattern, + framePatterns, frameFormat, audio, output, + scenes, theme, onProgress: (p) => { log(`transcode ${(100 * p).toFixed()}%`) diff --git a/lib/init-frames.js b/lib/init-frames.js index 497cfdf..16159ba 100644 --- a/lib/init-frames.js +++ b/lib/init-frames.js @@ -1,7 +1,6 @@ 'use strict' const ffmpegProbe = require('ffmpeg-probe') -const fs = require('fs-extra') const leftPad = require('left-pad') const path = require('path') const pMap = require('p-map') @@ -25,13 +24,10 @@ module.exports = async (opts) => { const scenes = await pMap(videos, (video, index) => { return module.exports.initScene({ - log, index, videos, transition, - transitions, - frameFormat, - outputDir + transitions }) }, { concurrency @@ -44,40 +40,68 @@ module.exports = async (opts) => { fps } = scenes[0] - const frames = [] - let numFrames = 0 + const renders = [] - scenes.forEach((scene, index) => { - scene.frameStart = numFrames + for (let i = 0; i < scenes.length; ++i) { + const prev = scenes[i - 1] + const scene = scenes[i] + const next = scenes[i + 1] - scene.numFramesTransition = Math.floor(scene.transition.duration * fps / 1000) - scene.numFramesPreTransition = Math.max(0, scene.numFrames - scene.numFramesTransition) + scene.trimStart = (prev ? prev.transition.duration : 0) - numFrames += scene.numFramesPreTransition + // sanitize transition durations to never be longer than scene durations + scene.transition.duration = Math.max(0, Math.min(scene.transition.duration, scene.duration - scene.trimStart)) - for (let frame = 0; frame < scene.numFrames; ++frame) { - const cFrame = scene.frameStart + frame - - if (!frames[cFrame]) { - const next = (frame < scene.numFramesPreTransition ? undefined : scenes[index + 1]) + if (next) { + scene.transition.duration = Math.min(scene.transition.duration, next.duration) + } - frames[cFrame] = { - current: scene, - next - } - } + scene.trimEnd = scene.duration - (next ? scene.transition.duration : 0) + scene.trimDuration = scene.trimEnd - scene.trimStart + + if (next) { + const sceneGetFrame = await module.exports.initFrames({ + log, + prefix: `post-${i}`, + frameFormat, + outputDir, + videoPath: scene.video, + seek: scene.trimEnd, + duration: scene.transition.duration, + fps + }) + + const nextGetFrame = await module.exports.initFrames({ + log, + prefix: `pre-${i}`, + frameFormat, + outputDir, + videoPath: next.video, + seek: 0, + duration: scene.transition.duration, + fps + }) + + const numFrames = Math.floor(scene.transition.duration * fps / 1000) + + renders.push({ + scene, + next, + numFrames, + sceneGetFrame, + nextGetFrame + }) } - }) + } const duration = scenes.reduce((sum, scene, index) => ( scene.duration + sum - scene.transition.duration ), 0) return { - frames, + renders, scenes, theme: { - numFrames, duration, width, height, @@ -88,13 +112,10 @@ module.exports = async (opts) => { module.exports.initScene = async (opts) => { const { - log, index, videos, transition, - transitions, - frameFormat, - outputDir + transitions } = opts const video = videos[index] @@ -106,11 +127,10 @@ module.exports.initScene = async (opts) => { width: probe.width, height: probe.height, duration: probe.duration, + fps: probe.fps, numFrames: parseInt(probe.streams[0].nb_frames) } - scene.fps = probe.fps - const t = (transitions ? transitions[index] : transition) scene.transition = { name: 'fade', @@ -123,29 +143,33 @@ module.exports.initScene = async (opts) => { scene.transition.duration = 0 } - const fileNamePattern = `scene-${index}-%012d.${frameFormat}` + return scene +} + +module.exports.initFrames = async (opts) => { + const { + log, + prefix, + frameFormat, + outputDir, + videoPath, + seek, + duration, + fps + } = opts + + const fileNamePattern = `scene-${prefix}-%012d.${frameFormat}` const framePattern = path.join(outputDir, fileNamePattern) await extractVideoFrames({ log, - videoPath: scene.video, - framePattern + videoPath, + framePattern, + seek, + duration, + fps }) - scene.getFrame = (frame) => { + return (frame) => { return framePattern.replace('%012d', leftPad(frame, 12, '0')) } - - // guard to ensure we only use frames that exist - while (scene.numFrames > 0) { - const frame = scene.getFrame(scene.numFrames - 1) - const exists = await fs.pathExists(frame) - - if (exists) { - break - } else { - scene.numFrames-- - } - } - - return scene } diff --git a/lib/render-frames.js b/lib/render-frames.js index 2980b29..d84fcdf 100644 --- a/lib/render-frames.js +++ b/lib/render-frames.js @@ -1,6 +1,5 @@ 'use strict' -const fs = require('fs-extra') const leftPad = require('left-pad') const path = require('path') const pMap = require('p-map') @@ -10,80 +9,95 @@ const createContext = require('./context') module.exports = async (opts) => { const { frameFormat, - frames, - onProgress, + renders, + // onProgress, outputDir, theme } = opts - const ctx = await createContext({ - frameFormat, - theme - }) + // how many transitions to render concurrently + let concurrency = 4 + let contexts = [] + + for (let i = 0; i < concurrency; ++i) { + const ctx = await createContext({ + frameFormat, + theme + }) + + contexts.push(ctx) + } - await pMap(frames, (frame, index) => { - return module.exports.renderFrame({ + const framePatterns = await pMap(renders, async (render, index) => { + const ctx = contexts.pop() + const framePattern = await module.exports.renderTransition({ ctx, - frame, + render, frameFormat, index, - onProgress, - outputDir, - theme + outputDir }) + + contexts.push(ctx) + return framePattern }, { - concurrency: 8 + concurrency }) - await ctx.flush() - await ctx.dispose() + for (let i = 0; i < contexts.length; ++i) { + const ctx = contexts[i] + await ctx.flush() + await ctx.dispose() + } - const framePattern = path.join(outputDir, `%012d.${frameFormat}`) - return framePattern + return framePatterns } -module.exports.renderFrame = async (opts) => { +module.exports.renderTransition = async (opts) => { const { ctx, - frame, + render, frameFormat, index, onProgress, - outputDir, - theme + outputDir } = opts - const fileName = `${leftPad(index, 12, '0')}.${frameFormat}` - const filePath = path.join(outputDir, fileName) - const { - current, - next - } = frame - - const cFrame = index - current.frameStart - const cFramePath = current.getFrame(cFrame) - - if (!next) { - await fs.move(cFramePath, filePath, { overwrite: true }) - } else { - ctx.setTransition(current.transition) - - const nFrame = index - next.frameStart - const nFramePath = next.getFrame(nFrame) - const cProgress = (cFrame - current.numFramesPreTransition) / current.numFramesTransition - - await ctx.render({ - imagePathFrom: cFramePath, - imagePathTo: nFramePath, - progress: cProgress, - params: current.transition.params - }) + scene, + numFrames, + sceneGetFrame, + nextGetFrame + } = render + + ctx.setTransition(scene.transition) + + for (let frame = 0; frame < numFrames; ++frame) { + const fileName = `render-${index}-${leftPad(frame, 12, '0')}.${frameFormat}` + const filePath = path.join(outputDir, fileName) + + const progress = frame / numFrames + + try { + await ctx.render({ + imagePathFrom: sceneGetFrame(frame), + imagePathTo: nextGetFrame(frame), + progress: progress, + params: scene.transition.params + }) + } catch (err) { + // stop at the first missing frame + // TODO: output info in debug mode + break + } await ctx.capture(filePath) - } - if (onProgress && index % 16 === 0) { - onProgress(index / theme.numFrames) + if (onProgress && frame % 8 === 0) { + // TODO: re-add onProgress + onProgress(progress) + } } + + return path.join(outputDir, `render-${index}-%012d.${frameFormat}`) } diff --git a/lib/transcode-video.js b/lib/transcode-video.js index 7d88c4c..14ed3d5 100644 --- a/lib/transcode-video.js +++ b/lib/transcode-video.js @@ -8,9 +8,10 @@ module.exports = async (opts) => { log, audio, frameFormat, - framePattern, + framePatterns, onProgress, output, + scenes, theme } = opts @@ -27,13 +28,42 @@ module.exports = async (opts) => { ]) } - const cmd = ffmpeg(framePattern) - .inputOptions(inputOptions) + const cmd = ffmpeg() - if (audio) { - cmd.addInput(audio) + // construct complex filter graph for concat + const n = scenes.length + let filter = '' + let postFilter = '' + for (let i = 0; i < n; ++i) { + const scene = scenes[i] + const transition = framePatterns[i] + + const v0 = i * 2 + cmd.addInput(scene.video) + + filter += `[${v0}:v]trim=${scene.trimStart / 1000}:${scene.trimEnd / 1000}[t${v0}];[t${v0}]setpts=PTS-STARTPTS[v${v0}];` + postFilter += `[v${v0}]` + + if (transition) { + const v1 = i * 2 + 1 + cmd.addInput(transition) + cmd.inputOptions(inputOptions) + + postFilter += `[${v1}:v]` + } } + const chain = `${filter}${postFilter}concat=n=${n * 2 - 1}[outv]` + if (audio) cmd.addInput(audio) + + cmd.addOptions([ + '-filter_complex', chain, + '-map', '[outv]' + ].concat(audio + ? [ '-map', `${n * 2 - 1}` ] + : [ ] + )) + const outputOptions = [] // misc .concat([ diff --git a/package.json b/package.json index e285f62..328965a 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "repository": "transitive-bullshit/ffmpeg-concat", "author": "Travis Fischer ", "license": "MIT", + "reveal": true, "scripts": { "test": "ava -v && standard" },