Skip to content

Commit

Permalink
Merge pull request #7 from Mtillmann/shutter-edl
Browse files Browse the repository at this point in the history
Shutter edl
  • Loading branch information
Mtillmann authored Jan 30, 2024
2 parents 9393c69 + 4c69277 commit 053cc12
Show file tree
Hide file tree
Showing 10 changed files with 188 additions and 32 deletions.
5 changes: 3 additions & 2 deletions src/Formats/AutoFormat.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { PySceneDetect } from "./PySceneDetect.js";
import { VorbisComment } from "./VorbisComment.js";
import { WebVTT } from "./WebVTT.js";
import { Youtube } from "./Youtube.js";
import { ShutterEDL } from "./ShutterEDL.js";

export const AutoFormat = {
classMap: {
Expand All @@ -22,15 +23,15 @@ export const AutoFormat = {
ffmpeginfo: FFMpegInfo,
pyscenedetect: PySceneDetect,
vorbiscomment: VorbisComment,
applechapters: AppleChapters
applechapters: AppleChapters,
shutteredl: ShutterEDL
},

detect(inputString, returnWhat = 'instance') {
let detected = false;

Object.entries(this.classMap)
.forEach(([key, className]) => {

if (detected) {
return;
}
Expand Down
1 change: 1 addition & 0 deletions src/Formats/FFMpegInfo.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export class FFMpegInfo extends FormatBase {


toString() {
//why?
throw new Error(`this class won't generate actual output`)
}
}
6 changes: 3 additions & 3 deletions src/Formats/MKVMergeXML.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {MatroskaXML} from "./MatroskaXML.js";
import {secondsToTimestamp, timestampToSeconds} from "../util.js";
import { MatroskaXML } from "./MatroskaXML.js";
import { secondsToTimestamp, timestampToSeconds } from "../util.js";

export class MKVMergeXML extends MatroskaXML {

Expand All @@ -11,7 +11,7 @@ export class MKVMergeXML extends MatroskaXML {
super(input, {
chapterStringNodeName: 'ChapterString',
inputTimeToSeconds: string => timestampToSeconds(string),
secondsToOutputTime: seconds => secondsToTimestamp(seconds, {hours: true, milliseconds: true})
secondsToOutputTime: seconds => secondsToTimestamp(seconds, { hours: true, milliseconds: true })
});
}
}
12 changes: 6 additions & 6 deletions src/Formats/PySceneDetect.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {FormatBase} from "./FormatBase.js";
import {secondsToTimestamp, timestampToSeconds} from "../util.js";
import { FormatBase } from "./FormatBase.js";
import { secondsToTimestamp, timestampToSeconds } from "../util.js";

export class PySceneDetect extends FormatBase {

Expand Down Expand Up @@ -51,13 +51,13 @@ export class PySceneDetect extends FormatBase {
return [
index + 1,//Scene Number
Math.round(chapter.startTime * framerate) + 1,//Start Frame
secondsToTimestamp(chapter.startTime, {hours: true, milliseconds: true}),// Start Timecode
secondsToTimestamp(chapter.startTime, { hours: true, milliseconds: true }),// Start Timecode
parseInt(chapter.startTime * 1000),// Start Time (seconds)
Math.round(endTime * framerate),// End Frame
secondsToTimestamp(endTime, {hours: true, milliseconds: true}),// End Timecode
secondsToTimestamp(endTime, { hours: true, milliseconds: true }),// End Timecode
parseInt(endTime * 1000),// End Time (seconds)
Math.round((endTime - chapter.startTime) * framerate),// Length (frames)
secondsToTimestamp(l, {hours: true, milliseconds: true}),// Length (timecode)
secondsToTimestamp(l, { hours: true, milliseconds: true }),// Length (timecode)
parseInt(Math.ceil(l * 1000))// Length (seconds)
]

Expand All @@ -69,7 +69,7 @@ export class PySceneDetect extends FormatBase {

lines.unshift('Scene Number,Start Frame,Start Timecode,Start Time (seconds),End Frame,End Timecode,End Time (seconds),Length (frames),Length (timecode),Length (seconds)')

if(!omitTimecodes){
if (!omitTimecodes) {
lines.unshift(tl);
}

Expand Down
76 changes: 76 additions & 0 deletions src/Formats/ShutterEDL.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { secondsToTimestamp, timestampToSeconds } from "../util.js";
import { FormatBase } from "./FormatBase.js";

export class ShutterEDL extends FormatBase {

// this format is based on the shutter encoder edl format
// https://github.com/paulpacifico/shutter-encoder/blob/f3d6bb6dfcd629861a0b0a50113bf4b062e1ba17/src/application/SceneDetection.java

detect(inputString) {
return /^TITLE:\s.*\r?\n/.test(inputString.trim());
}

decodeTime(timeString) {
return timeString.replace(/:(\d+)$/,'.$10');
}

encodeTime(time) {
// since this format apparently expects the end time of the next item and the previous start time
// to be the same,
// I'll round them to look like they looked in my sample file when converting
// from shutter edl to shutter edl...

const string = secondsToTimestamp(time, {milliseconds: true});
const ms = String(Math.ceil(parseInt(string.split('.').pop()) * 0.1));
return string.replace(/\.(\d+)$/,`:${ms.padStart(2, '0')}`);
}

parse(input) {
if (!this.detect(input)) {
throw new Error('input must start with TITLE:')
}

const titleMatch = input.match(/^TITLE:\s(.*)\r?\n/);
this.meta.title = titleMatch?.[1] ?? 'Chapters';

this.chapters = Array.from(input.matchAll(/(?<index>\d{6})\s+(?<title>[^\s]+)\s+\w+\s+\w+\s+(?<startTime>\d\d:\d\d:\d\d:\d\d)\s+(?<endTime>\d\d:\d\d:\d\d:\d\d)/g))
.reduce((acc, match) => {
const startTime = timestampToSeconds(this.decodeTime(match.groups.startTime));
const endTime = timestampToSeconds(this.decodeTime(match.groups.endTime));
const title = match.groups.title;

if (acc.at(-1)?.startTime === startTime) {
return acc;
}

console.log(startTime, endTime, title);

acc.push({
startTime,
endTime,
title
});
return acc;
}, []);
}

toString() {
// this format is weird, it expects 3 tracks per chapter, i suspect it's
// V = video, A, A2 = stereo audio
const tracks = ['V', 'A', 'A2'];
const output = this.chapters.reduce((acc, chapter,i) => {

const index = i * 3 + 1;
const startTime = this.encodeTime(chapter.startTime);
const endTime = this.encodeTime(chapter.endTime);
for(let j = 0; j < 3; j++){
acc.push(`${(j + index).toString().padStart(6, '0')} ${chapter.title} ${tracks[j]}${" ".repeat(6 - tracks[j].length)}C ${startTime} ${endTime} ${startTime} ${endTime}`);
}

return acc;
}, []);

output.unshift('TITLE: ' + this.meta.title);
return output.join("\n");
}
}
14 changes: 7 additions & 7 deletions src/Formats/WebVTT.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {FormatBase} from "./FormatBase.js";
import {secondsToTimestamp, timestampToSeconds} from "../util.js";
import { FormatBase } from "./FormatBase.js";
import { secondsToTimestamp, timestampToSeconds } from "../util.js";

export class WebVTT extends FormatBase {

Expand Down Expand Up @@ -53,16 +53,16 @@ export class WebVTT extends FormatBase {
if (this.meta.title.trim().length > 0) {
output[0] += ' - ' + this.meta.title.trim();
}
const options = {hours: true, milliseconds: true};
const options = { hours: true, milliseconds: true };


this.chapters.forEach((chapter, index) => {
output.push('');
output.push(...[
index + 1,
secondsToTimestamp(chapter.startTime, options) + ' --> ' + secondsToTimestamp(chapter.endTime, options),
chapter.title || this.getChapterTitle(index)
].filter(line => String(line).trim().length > 0)
index + 1,
secondsToTimestamp(chapter.startTime, options) + ' --> ' + secondsToTimestamp(chapter.endTime, options),
chapter.title || this.getChapterTitle(index)
].filter(line => String(line).trim().length > 0)
);
});

Expand Down
4 changes: 2 additions & 2 deletions src/Formats/Youtube.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {FormatBase} from "./FormatBase.js";
import {secondsToTimestamp, timestampToSeconds} from "../util.js";
import { FormatBase } from "./FormatBase.js";
import { secondsToTimestamp, timestampToSeconds } from "../util.js";

export class Youtube extends FormatBase {

Expand Down
26 changes: 14 additions & 12 deletions tests/conversions.test.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import {ChaptersJson} from "../src/Formats/ChaptersJson.js";
import {WebVTT} from "../src/Formats/WebVTT.js";
import {Youtube} from "../src/Formats/Youtube.js";
import {FFMetadata} from "../src/Formats/FFMetadata.js";
import {MatroskaXML} from "../src/Formats/MatroskaXML.js";
import {MKVMergeXML} from "../src/Formats/MKVMergeXML.js";
import {MKVMergeSimple} from "../src/Formats/MKVMergeSimple.js";
import {PySceneDetect} from "../src/Formats/PySceneDetect.js";
import {readFileSync} from "fs";
import {sep} from "path";
import { ChaptersJson } from "../src/Formats/ChaptersJson.js";
import { WebVTT } from "../src/Formats/WebVTT.js";
import { Youtube } from "../src/Formats/Youtube.js";
import { FFMetadata } from "../src/Formats/FFMetadata.js";
import { MatroskaXML } from "../src/Formats/MatroskaXML.js";
import { MKVMergeXML } from "../src/Formats/MKVMergeXML.js";
import { MKVMergeSimple } from "../src/Formats/MKVMergeSimple.js";
import { PySceneDetect } from "../src/Formats/PySceneDetect.js";
import { AppleChapters } from "../src/Formats/AppleChapters.js";
import { ShutterEDL } from "../src/Formats/ShutterEDL.js";
import { readFileSync } from "fs";
import { sep } from "path";

describe('conversions from one format to any other', () => {
const formats = [ChaptersJson, WebVTT, Youtube, FFMetadata, MatroskaXML, MKVMergeXML, MKVMergeSimple, PySceneDetect];
const formats = [ChaptersJson, WebVTT, Youtube, FFMetadata, MatroskaXML, MKVMergeXML, MKVMergeSimple, PySceneDetect, AppleChapters, ShutterEDL];

const content = readFileSync(module.path + sep + 'samples' + sep + 'chapters.json', 'utf-8');

Expand All @@ -19,7 +21,7 @@ describe('conversions from one format to any other', () => {
formats.forEach(fromFormat => {
const from = initial.to(fromFormat);
formats.forEach(toFormat => {
const to = from.to(toFormat);
const to = from.to(toFormat);
it(`yields equal chapter count from ${fromFormat.name} to ${toFormat.name}`, () => {
expect(from.chapters.length).toEqual(to.chapters.length);
})
Expand Down
60 changes: 60 additions & 0 deletions tests/format_shutteredl.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@

import { readFileSync } from "fs";
import { sep } from "path";
import { ShutterEDL } from "../src/Formats/ShutterEDL.js";
import { Youtube } from "../src/Formats/Youtube.js";


describe('ShutterEDL Format Handler', () => {
it('accepts no arguments', () => {
expect(() => {
new ShutterEDL();
}).not.toThrowError(TypeError);
});


it('fails on malformed input', () => {
expect(() => {
new ShutterEDL('asdf');
}).toThrowError(Error);
});

const content = readFileSync(module.path + sep + 'samples' + sep + 'shutter.edl', 'utf-8');

it('parses well-formed input', () => {
expect(() => {
new ShutterEDL(content);
}).not.toThrow(Error);
});

const instance = new ShutterEDL(content);

it('has the correct number of chapters from content', () => {
expect(instance.chapters.length).toEqual(5);
});

it('has parsed the timestamps correctly', () => {
expect(instance.chapters[0].startTime).toBe(0)
});

it('has parsed the chapter titles correctly', () => {
expect(instance.chapters[1].title).toBe('BigBuckBunny_320x180_cut.mp4')
});

it('exports to correct format', () => {
expect(instance.toString().slice(0, 6)).toEqual('TITLE:');
});

it('export includes correct timestamp', () => {
expect(instance.toString()).toContain('00:00:47:17');
});

it('can import previously generated export', () => {
expect(new ShutterEDL(instance.toString()).chapters[3].startTime).toEqual(23.01);
});

it('can convert into other format', () => {
expect(instance.to(Youtube)).toBeInstanceOf(Youtube)
});

});
16 changes: 16 additions & 0 deletions tests/samples/shutter.edl
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
TITLE: bunny-dings
000001 BigBuckBunny_320x180.mp4 V C 00:00:00:00 00:00:11:21 00:00:00:00 00:00:11:21
000002 BigBuckBunny_320x180.mp4 A C 00:00:00:00 00:00:11:21 00:00:00:00 00:00:11:21
000003 BigBuckBunny_320x180.mp4 A2 C 00:00:00:00 00:00:11:21 00:00:00:00 00:00:11:21
000004 BigBuckBunny_320x180_cut.mp4 V C 00:00:11:21 00:00:15:18 00:00:11:21 00:00:15:18
000005 BigBuckBunny_320x180_cut.mp4 A C 00:00:11:21 00:00:15:18 00:00:11:21 00:00:15:18
000006 BigBuckBunny_320x180_cut.mp4 A2 C 00:00:11:21 00:00:15:18 00:00:11:21 00:00:15:18
000007 BigBuckBunny_320x180.mp4 V C 00:00:15:18 00:00:23:01 00:00:15:18 00:00:23:01
000008 BigBuckBunny_320x180.mp4 A C 00:00:15:18 00:00:23:01 00:00:15:18 00:00:23:01
000009 BigBuckBunny_320x180.mp4 A2 C 00:00:15:18 00:00:23:01 00:00:15:18 00:00:23:01
000010 BigBuckBunny_320x180_cut.mp4 V C 00:00:23:01 00:00:47:17 00:00:23:01 00:00:47:17
000011 BigBuckBunny_320x180_cut.mp4 A C 00:00:23:01 00:00:47:17 00:00:23:01 00:00:47:17
000012 BigBuckBunny_320x180_cut.mp4 A2 C 00:00:23:01 00:00:47:17 00:00:23:01 00:00:47:17
000013 BigBuckBunny_320x180.mp4 V C 00:00:47:17 00:00:56:02 00:00:47:17 00:00:56:02
000014 BigBuckBunny_320x180.mp4 A C 00:00:47:17 00:00:56:02 00:00:47:17 00:00:56:02
000015 BigBuckBunny_320x180.mp4 A2 C 00:00:47:17 00:00:56:02 00:00:47:17 00:00:56:02

0 comments on commit 053cc12

Please sign in to comment.