Skip to content

Commit

Permalink
Merge pull request #3 from Chaphasilor/dev
Browse files Browse the repository at this point in the history
Alternative algorithm for syncing
  • Loading branch information
Chaphasilor authored Jul 27, 2021
2 parents 6222147 + c1007d4 commit a3aebdf
Show file tree
Hide file tree
Showing 10 changed files with 867 additions and 142 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ node_modules
*.mp4
*.mkv
video-sync
batch.sh
*.csv
.vscode/
66 changes: 53 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,68 @@
# video-sync

---

<!-- toc -->
- [video-sync](#video-sync)
- [Usage](#usage)
- [Arguments](#arguments)
<!-- tocstop -->
A tool for automating the process of muxing additional audio tracks into videos

## Usage
<!-- usage -->

```sh-session
video-sync [DESTINATION] [SOURCE] <flags>
```

## Description

This tool requires the two input videos, the one where you want to add the additional tracks *to* (the destination video) and the one where you take the additional tracks *from* (the source video).
It then tries to find the exact same frame in both videos, in order to synchronize them (in case one of them is longer or shorter than the other).
It allows you to pick the audio and subtitle tracks you want to add to the destination and specify the output file.

There's an interactive mode (simply don't pass any arguments, flags work) and a CLI mode (pass the two arguments listed at the top).

## Examples

```sh-session
$ video-sync # interactive mode
...
$ video-sync video1 offset1 video2 offset2 -o output # CLI mode
$ video-sync video1 video2 -o output # CLI mode
...
$ video-sync -a 0,en -s 2,ger # sync the audio track with mkvmerge ID `0` and all additional english audio tracks, and also the subtitle track with ID `2` and all additional german subtitle tracks
...
$ video-sync -e 300 -f # don't sync the videos, instead use the offset estimate (source `300` ms ahead of destination) as the final/forced offset
...
$ video-sync -h # help page
...
```

<!-- usagestop -->

## Arguments
<!-- arguments -->

<!-- argumentsstop -->
- `DESTINATION` video where tracks should be added to
- `SOURCE` video where the tracks are copied from

## Options

- `-o, --output=<path>` output file path

- `-a, --audioTracks=<list>` audio tracks to sync over to the destination video. comma-separated list of mkvmerge IDs or ISO 639-2 language tags (track matching that language will be synced). if omitted, all audio tracks will be synced.

- `-s, --subsTracks=<list>` subtitle tracks to sync over to the destination video. comma-separated list of mkvmerge IDs or ISO 639-2 language tags (track matching that language will be synced). if omitted, all subtitle tracks will be synced

- `-e, --offsetEstimate=<number>` estimated offset between the two videos (in ms) for video syncing. positive values means that the source video is ahead of the destination video

- `-f, --forceOffset` use the estimated offset as the final offset, no synching

- `-x, --exclusiveDirection=<ahead|behind>` only search the matching frame offset in one direction. 'ahead' means that the source video scene comes *before* the destination video scene. (requires algorithm=matching-scene)

- `-g, --algorithm=<simple|matching-scene>` [default: matching-scene] search algorithm to use for video syncing

- `-m, --maxOffset=<number>` [default: 120] maximum considered offset between the videos (in seconds) for video syncing.

- `-r, --searchResolution=<number>` [default: 80] resolution of the search region (in frames) for video syncing. increases accuracy at the cost of longer runtime (requires algorithm=simple)
- `-i, --iterations=<number>` [default: 2] number of iterations to perform for video syncing (requires algorithm=simple)
- `-t, --threshold=<number>` [default: 0.6] minimum confidence threshold for video syncing. (requires algorithm=simple)
- `-w, --searchWidth=<number>` [default: 20] width of the search region (in seconds) for video syncing. the program will find the closest matching frame in this region, 'sourceOffset' being the center (requires algorithm=simple)

- `-y, --confirm` automatically confirm missing tracks, low confidence scores and overwrite prompts

- `-v, --verbose` output additional logs

- `-h, --help` show CLI help

- `--version` show CLI version
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,12 @@
"./src/**/*.js",
"./util/**/*.js"
],
"outputPath": "dist"
"outputPath": "dist",
"targets": [
"linux-x64",
"win-x64",
"macos-x64"
]
},
"homepage": "https://github.com/Chaphasilor/video-sync",
"keywords": [
Expand Down
180 changes: 98 additions & 82 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ const ora = require('ora');
const ms = require(`ms`)

const { ALGORITHMS, calcOffset } = require(`../util/calc-offset`)
const { calculateOffset } = require(`../util/find-offset-new`)
const merge = require(`../util/merge-tracks`)
const tracks = require(`../util/tracks`)
const { validateOffset } = require('../util/warping')

class VideoSyncCommand extends Command {
async run() {
Expand All @@ -26,20 +28,7 @@ class VideoSyncCommand extends Command {
// console.warn(`args:`, args)
// console.warn(`flags:`, flags)

let algorithm
switch (flags.algorithm) {
case `ssim`:
algorithm = ALGORITHMS.SSIM
break;
case `matching-pixels`:
algorithm = ALGORITHMS.MISMATCHED_PIXELS
break;
default:
algorithm = ALGORITHMS.SSIM
break;
}

let prompt = Object.values(args).filter(x => x !== undefined).length !== 4
let prompt = Object.values(args).filter(x => x !== undefined).length < 2
let answers

if (prompt) {
Expand All @@ -56,25 +45,6 @@ class VideoSyncCommand extends Command {
return true;
},
},
{
type: `input`,
message: `Enter the offset (in ms or with units) for the destination file (where to start looking for matching frames while synching)`,
name: `destinationOffset`,
validate: (formattedInput) => {
if (formattedInput === undefined) {
return `Didn't recognize that time string! Valid units: ms, s, m, h.`
} else if (formattedInput < 0) {
return `Only positive offsets are supported. '0' is the beginning of the video.`
} else {
return true
}
},
filter: (input) => {
let matches = input.match(/(\-\s*)\d/g)?.map(x => x.slice(0, -1)) || []
input = matches.reduce((sum, cur) => sum.replace(cur, `-`), input)
return input.split(` `).reduce((sum, cur) => sum + ms(cur), 0)
},
},
{
type: `input`,
message: `Enter the source file (that contains the new tracks to be synced over)`,
Expand All @@ -86,25 +56,6 @@ class VideoSyncCommand extends Command {
return true;
},
},
{
type: `input`,
message: `Enter the offset (in ms or with units) for the source file (where to start looking for matching frames while syncing)`,
name: `sourceOffset`,
validate: (formattedInput) => {
if (formattedInput === undefined || isNaN(formattedInput)) {
return `Didn't recognize that time string! Valid units: ms, s, m, h.`
} else if (formattedInput < 0) {
return `Only positive offsets are supported. '0' is the beginning of the video.`
} else {
return true
}
},
filter: (input) => {
let matches = input.match(/(\-\s*)\d/g)?.map(x => x.slice(0, -1)) || []
input = matches.reduce((sum, cur) => sum.replace(cur, `-`), input)
return input.split(` `).reduce((sum, cur) => sum + ms(cur), 0)
},
},
{
type: `input`,
message: `Specify the output file (where the synced and muxed video gets written to)`,
Expand Down Expand Up @@ -257,21 +208,67 @@ class VideoSyncCommand extends Command {
subs: selectedTracks.subs ?? [],
}

const { videoOffset, confidence } = await calcOffset(answers.destination, answers.source, answers.destinationOffset, answers.sourceOffset, {
algorithm,
iterations: flags.iterations,
searchWidth: flags.searchWidth,
searchResolution: flags.searchResolution,
})
// let videoOffset = 0, confidence = 1
let videoOffset
let confidence
if (flags.forceOffset) {
videoOffset = flags.offsetEstimate
confidence = 1
} else {
let result

if (flags.algorithm === `simple`) {
result = await calcOffset(answers.destination, answers.source, {
comparisonAlgorithm: ALGORITHMS.SSIM,
iterations: flags.iterations,
searchWidth: flags.searchWidth,
searchResolution: flags.searchResolution,
maxOffset: flags.maxOffset,
offsetEstimate: flags.offsetEstimate,
threshold: flags.threshold,
})
} else {
result = await calculateOffset(answers.destination, answers.source, {
maxOffset: flags.maxOffset * 1000,
offsetEstimate: flags.offsetEstimate,
})
}

videoOffset = result.videoOffset
confidence = result.confidence
}

// check if one of the videos is warped
let videoWarped = false
const offsetValidationSpinner = ora(`Checking if found offset applies to the whole video...`).start();
try {
videoWarped = ! await validateOffset(args.destination, args.source, videoOffset)
} catch (err) {
console.error(`Error while checking if found offset applies to the whole video:`, err)
}

// log warning about warped video
if (videoWarped && flags.confirm) {
offsetValidationSpinner.warn(`Syncing the tracks might not work well because one of the videos appears to be warped.`)
} else if (!videoWarped) {
offsetValidationSpinner.succeed(`Offset is valid.`)
} else {
offsetValidationSpinner.stop()
}

let continueWithMerging = answers.output !== undefined && (selectedTracks.audio.length > 0 || selectedTracks.subs.length > 0)

if (!flags.confirm && flags.algorithm === `ssim` && confidence < 0.5) {
if (continueWithMerging && (!flags.confirm && flags.algorithm === `ssim` && confidence < 0.6)) {
continueWithMerging = (await inquirer.prompt([{
type: `confirm`,
name: `continue`,
message: `Syncing confidence is very low (${confidence}). Do you want to continue?`,
message: `Syncing confidence is very low (${confidence}). Do you want to continue anyway?`,
}])).continue
} else if (continueWithMerging && videoWarped && !flags.confirm) {
continueWithMerging = (await inquirer.prompt([{
type: `confirm`,
name: `continue`,
message: `It seems like one of the videos might be warped (slightly sped up or slowed down). This might make synchronization impossible. Do you want to continue anyway?`,
default: false,
}])).continue
}

Expand All @@ -289,9 +286,11 @@ class VideoSyncCommand extends Command {
}
}

VideoSyncCommand.description = `Describe the command here
...
Extra documentation goes here
VideoSyncCommand.description = `video-sync - a tool for automating the process of muxing additional audio tracks into videos
This tool requires the two input videos, the one where you want to add the additional tracks *to* (the destination video) and the one where you take the additional tracks *from* (the source video).
It then tries to find the exact same frame in both videos, in order to synchronize them (in case one of them is longer or shorter than the other).
It allows you to pick the audio and subtitle tracks you want to add to the destination and specify the output file.
There's an interactive mode (simply don't pass any arguments, flags work) and a CLI mode (pass the two arguments listed at the top).
`

VideoSyncCommand.args = [
Expand All @@ -300,21 +299,11 @@ VideoSyncCommand.args = [
required: false,
description: `video where tracks should be added to`,
},
{
name: `destinationOffset`,
required: false,
description: `frame offset for the destination video`,
},
{
name: `source`,
required: false,
description: `video where the tracks are copied from`,
},
{
name: `sourceOffset`,
required: false,
description: `frame offset for the source video`,
},
]

VideoSyncCommand.flags = {
Expand Down Expand Up @@ -348,24 +337,51 @@ VideoSyncCommand.flags = {
}),
algorithm: flags.enum({
char: `g`,
description: `matching algorithm to use for video syncing`,
options: [`ssim`, `matching-pixels`],
default: `ssim`,
description: `search algorithm to use for video syncing`,
options: [`simple`, `matching-scene`],
default: `matching-scene`,
}),
iterations: flags.integer({
char: `i`,
description: `number of iterations to perform for video syncing`,
description: `number of iterations to perform for video syncing (requires algorithm=simple)`,
default: 2,
}),
searchWidth: flags.integer({
char: `w`,
description: `width of the search region (in seconds) for video syncing. the program will find the closest matching frame in this region, 'sourceOffset' being the center`,
default: 10,
description: `width of the search region (in seconds) for video syncing. the program will find the closest matching frame in this region, 'sourceOffset' being the center (requires algorithm=simple)`,
default: 20,
}),
maxOffset: flags.integer({
char: `m`,
description: `maximum considered offset between the videos (in seconds) for video syncing.`,
default: 120,
}),
offsetEstimate: flags.integer({
char: `e`,
description: `estimated offset between the two videos (in ms) for video syncing. positive values means that the source video is ahead of the destination video`,
default: 0,
}),
forceOffset: flags.boolean({
char: `f`,
description: `use the estimated offset as the final offset, no synching`,
default: false,
}),
exclusiveDirection: flags.string({
char: `x`,
description: `only search the matching frame offset in one direction. 'ahead' means that the source video scene comes *before* the destination video scene. (requires algorithm=matching-scene)`,
parse: (input) => input ? (input === `ahead` ? -1 : 1) : false,
default: undefined,
}),
threshold: flags.string({
char: `t`,
description: `minimum confidence threshold for video syncing. (requires algorithm=simple)`,
parse: (input) => parseFloat(input),
default: 0.6,
}),
searchResolution: flags.integer({
char: `r`,
description: `resolution of the search region (in frames) for video syncing. increases accuracy at the cost of longer runtime`,
default: 40,
description: `resolution of the search region (in frames) for video syncing. increases accuracy at the cost of longer runtime (requires algorithm=simple)`,
default: 80,
}),
verbose: flags.boolean({
char: `v`,
Expand Down
Loading

0 comments on commit a3aebdf

Please sign in to comment.