Skip to content

Commit 053cc12

Browse files
authored
Merge pull request #7 from Mtillmann/shutter-edl
Shutter edl
2 parents 9393c69 + 4c69277 commit 053cc12

10 files changed

+188
-32
lines changed

src/Formats/AutoFormat.js

+3-2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { PySceneDetect } from "./PySceneDetect.js";
99
import { VorbisComment } from "./VorbisComment.js";
1010
import { WebVTT } from "./WebVTT.js";
1111
import { Youtube } from "./Youtube.js";
12+
import { ShutterEDL } from "./ShutterEDL.js";
1213

1314
export const AutoFormat = {
1415
classMap: {
@@ -22,15 +23,15 @@ export const AutoFormat = {
2223
ffmpeginfo: FFMpegInfo,
2324
pyscenedetect: PySceneDetect,
2425
vorbiscomment: VorbisComment,
25-
applechapters: AppleChapters
26+
applechapters: AppleChapters,
27+
shutteredl: ShutterEDL
2628
},
2729

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

3133
Object.entries(this.classMap)
3234
.forEach(([key, className]) => {
33-
3435
if (detected) {
3536
return;
3637
}

src/Formats/FFMpegInfo.js

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export class FFMpegInfo extends FormatBase {
2626

2727

2828
toString() {
29+
//why?
2930
throw new Error(`this class won't generate actual output`)
3031
}
3132
}

src/Formats/MKVMergeXML.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import {MatroskaXML} from "./MatroskaXML.js";
2-
import {secondsToTimestamp, timestampToSeconds} from "../util.js";
1+
import { MatroskaXML } from "./MatroskaXML.js";
2+
import { secondsToTimestamp, timestampToSeconds } from "../util.js";
33

44
export class MKVMergeXML extends MatroskaXML {
55

@@ -11,7 +11,7 @@ export class MKVMergeXML extends MatroskaXML {
1111
super(input, {
1212
chapterStringNodeName: 'ChapterString',
1313
inputTimeToSeconds: string => timestampToSeconds(string),
14-
secondsToOutputTime: seconds => secondsToTimestamp(seconds, {hours: true, milliseconds: true})
14+
secondsToOutputTime: seconds => secondsToTimestamp(seconds, { hours: true, milliseconds: true })
1515
});
1616
}
1717
}

src/Formats/PySceneDetect.js

+6-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import {FormatBase} from "./FormatBase.js";
2-
import {secondsToTimestamp, timestampToSeconds} from "../util.js";
1+
import { FormatBase } from "./FormatBase.js";
2+
import { secondsToTimestamp, timestampToSeconds } from "../util.js";
33

44
export class PySceneDetect extends FormatBase {
55

@@ -51,13 +51,13 @@ export class PySceneDetect extends FormatBase {
5151
return [
5252
index + 1,//Scene Number
5353
Math.round(chapter.startTime * framerate) + 1,//Start Frame
54-
secondsToTimestamp(chapter.startTime, {hours: true, milliseconds: true}),// Start Timecode
54+
secondsToTimestamp(chapter.startTime, { hours: true, milliseconds: true }),// Start Timecode
5555
parseInt(chapter.startTime * 1000),// Start Time (seconds)
5656
Math.round(endTime * framerate),// End Frame
57-
secondsToTimestamp(endTime, {hours: true, milliseconds: true}),// End Timecode
57+
secondsToTimestamp(endTime, { hours: true, milliseconds: true }),// End Timecode
5858
parseInt(endTime * 1000),// End Time (seconds)
5959
Math.round((endTime - chapter.startTime) * framerate),// Length (frames)
60-
secondsToTimestamp(l, {hours: true, milliseconds: true}),// Length (timecode)
60+
secondsToTimestamp(l, { hours: true, milliseconds: true }),// Length (timecode)
6161
parseInt(Math.ceil(l * 1000))// Length (seconds)
6262
]
6363

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

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

72-
if(!omitTimecodes){
72+
if (!omitTimecodes) {
7373
lines.unshift(tl);
7474
}
7575

src/Formats/ShutterEDL.js

+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { secondsToTimestamp, timestampToSeconds } from "../util.js";
2+
import { FormatBase } from "./FormatBase.js";
3+
4+
export class ShutterEDL extends FormatBase {
5+
6+
// this format is based on the shutter encoder edl format
7+
// https://github.com/paulpacifico/shutter-encoder/blob/f3d6bb6dfcd629861a0b0a50113bf4b062e1ba17/src/application/SceneDetection.java
8+
9+
detect(inputString) {
10+
return /^TITLE:\s.*\r?\n/.test(inputString.trim());
11+
}
12+
13+
decodeTime(timeString) {
14+
return timeString.replace(/:(\d+)$/,'.$10');
15+
}
16+
17+
encodeTime(time) {
18+
// since this format apparently expects the end time of the next item and the previous start time
19+
// to be the same,
20+
// I'll round them to look like they looked in my sample file when converting
21+
// from shutter edl to shutter edl...
22+
23+
const string = secondsToTimestamp(time, {milliseconds: true});
24+
const ms = String(Math.ceil(parseInt(string.split('.').pop()) * 0.1));
25+
return string.replace(/\.(\d+)$/,`:${ms.padStart(2, '0')}`);
26+
}
27+
28+
parse(input) {
29+
if (!this.detect(input)) {
30+
throw new Error('input must start with TITLE:')
31+
}
32+
33+
const titleMatch = input.match(/^TITLE:\s(.*)\r?\n/);
34+
this.meta.title = titleMatch?.[1] ?? 'Chapters';
35+
36+
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))
37+
.reduce((acc, match) => {
38+
const startTime = timestampToSeconds(this.decodeTime(match.groups.startTime));
39+
const endTime = timestampToSeconds(this.decodeTime(match.groups.endTime));
40+
const title = match.groups.title;
41+
42+
if (acc.at(-1)?.startTime === startTime) {
43+
return acc;
44+
}
45+
46+
console.log(startTime, endTime, title);
47+
48+
acc.push({
49+
startTime,
50+
endTime,
51+
title
52+
});
53+
return acc;
54+
}, []);
55+
}
56+
57+
toString() {
58+
// this format is weird, it expects 3 tracks per chapter, i suspect it's
59+
// V = video, A, A2 = stereo audio
60+
const tracks = ['V', 'A', 'A2'];
61+
const output = this.chapters.reduce((acc, chapter,i) => {
62+
63+
const index = i * 3 + 1;
64+
const startTime = this.encodeTime(chapter.startTime);
65+
const endTime = this.encodeTime(chapter.endTime);
66+
for(let j = 0; j < 3; j++){
67+
acc.push(`${(j + index).toString().padStart(6, '0')} ${chapter.title} ${tracks[j]}${" ".repeat(6 - tracks[j].length)}C ${startTime} ${endTime} ${startTime} ${endTime}`);
68+
}
69+
70+
return acc;
71+
}, []);
72+
73+
output.unshift('TITLE: ' + this.meta.title);
74+
return output.join("\n");
75+
}
76+
}

src/Formats/WebVTT.js

+7-7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import {FormatBase} from "./FormatBase.js";
2-
import {secondsToTimestamp, timestampToSeconds} from "../util.js";
1+
import { FormatBase } from "./FormatBase.js";
2+
import { secondsToTimestamp, timestampToSeconds } from "../util.js";
33

44
export class WebVTT extends FormatBase {
55

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

5858

5959
this.chapters.forEach((chapter, index) => {
6060
output.push('');
6161
output.push(...[
62-
index + 1,
63-
secondsToTimestamp(chapter.startTime, options) + ' --> ' + secondsToTimestamp(chapter.endTime, options),
64-
chapter.title || this.getChapterTitle(index)
65-
].filter(line => String(line).trim().length > 0)
62+
index + 1,
63+
secondsToTimestamp(chapter.startTime, options) + ' --> ' + secondsToTimestamp(chapter.endTime, options),
64+
chapter.title || this.getChapterTitle(index)
65+
].filter(line => String(line).trim().length > 0)
6666
);
6767
});
6868

src/Formats/Youtube.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import {FormatBase} from "./FormatBase.js";
2-
import {secondsToTimestamp, timestampToSeconds} from "../util.js";
1+
import { FormatBase } from "./FormatBase.js";
2+
import { secondsToTimestamp, timestampToSeconds } from "../util.js";
33

44
export class Youtube extends FormatBase {
55

tests/conversions.test.js

+14-12
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
1-
import {ChaptersJson} from "../src/Formats/ChaptersJson.js";
2-
import {WebVTT} from "../src/Formats/WebVTT.js";
3-
import {Youtube} from "../src/Formats/Youtube.js";
4-
import {FFMetadata} from "../src/Formats/FFMetadata.js";
5-
import {MatroskaXML} from "../src/Formats/MatroskaXML.js";
6-
import {MKVMergeXML} from "../src/Formats/MKVMergeXML.js";
7-
import {MKVMergeSimple} from "../src/Formats/MKVMergeSimple.js";
8-
import {PySceneDetect} from "../src/Formats/PySceneDetect.js";
9-
import {readFileSync} from "fs";
10-
import {sep} from "path";
1+
import { ChaptersJson } from "../src/Formats/ChaptersJson.js";
2+
import { WebVTT } from "../src/Formats/WebVTT.js";
3+
import { Youtube } from "../src/Formats/Youtube.js";
4+
import { FFMetadata } from "../src/Formats/FFMetadata.js";
5+
import { MatroskaXML } from "../src/Formats/MatroskaXML.js";
6+
import { MKVMergeXML } from "../src/Formats/MKVMergeXML.js";
7+
import { MKVMergeSimple } from "../src/Formats/MKVMergeSimple.js";
8+
import { PySceneDetect } from "../src/Formats/PySceneDetect.js";
9+
import { AppleChapters } from "../src/Formats/AppleChapters.js";
10+
import { ShutterEDL } from "../src/Formats/ShutterEDL.js";
11+
import { readFileSync } from "fs";
12+
import { sep } from "path";
1113

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

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

@@ -19,7 +21,7 @@ describe('conversions from one format to any other', () => {
1921
formats.forEach(fromFormat => {
2022
const from = initial.to(fromFormat);
2123
formats.forEach(toFormat => {
22-
const to = from.to(toFormat);
24+
const to = from.to(toFormat);
2325
it(`yields equal chapter count from ${fromFormat.name} to ${toFormat.name}`, () => {
2426
expect(from.chapters.length).toEqual(to.chapters.length);
2527
})

tests/format_shutteredl.test.js

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
2+
import { readFileSync } from "fs";
3+
import { sep } from "path";
4+
import { ShutterEDL } from "../src/Formats/ShutterEDL.js";
5+
import { Youtube } from "../src/Formats/Youtube.js";
6+
7+
8+
describe('ShutterEDL Format Handler', () => {
9+
it('accepts no arguments', () => {
10+
expect(() => {
11+
new ShutterEDL();
12+
}).not.toThrowError(TypeError);
13+
});
14+
15+
16+
it('fails on malformed input', () => {
17+
expect(() => {
18+
new ShutterEDL('asdf');
19+
}).toThrowError(Error);
20+
});
21+
22+
const content = readFileSync(module.path + sep + 'samples' + sep + 'shutter.edl', 'utf-8');
23+
24+
it('parses well-formed input', () => {
25+
expect(() => {
26+
new ShutterEDL(content);
27+
}).not.toThrow(Error);
28+
});
29+
30+
const instance = new ShutterEDL(content);
31+
32+
it('has the correct number of chapters from content', () => {
33+
expect(instance.chapters.length).toEqual(5);
34+
});
35+
36+
it('has parsed the timestamps correctly', () => {
37+
expect(instance.chapters[0].startTime).toBe(0)
38+
});
39+
40+
it('has parsed the chapter titles correctly', () => {
41+
expect(instance.chapters[1].title).toBe('BigBuckBunny_320x180_cut.mp4')
42+
});
43+
44+
it('exports to correct format', () => {
45+
expect(instance.toString().slice(0, 6)).toEqual('TITLE:');
46+
});
47+
48+
it('export includes correct timestamp', () => {
49+
expect(instance.toString()).toContain('00:00:47:17');
50+
});
51+
52+
it('can import previously generated export', () => {
53+
expect(new ShutterEDL(instance.toString()).chapters[3].startTime).toEqual(23.01);
54+
});
55+
56+
it('can convert into other format', () => {
57+
expect(instance.to(Youtube)).toBeInstanceOf(Youtube)
58+
});
59+
60+
});

tests/samples/shutter.edl

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
TITLE: bunny-dings
2+
000001 BigBuckBunny_320x180.mp4 V C 00:00:00:00 00:00:11:21 00:00:00:00 00:00:11:21
3+
000002 BigBuckBunny_320x180.mp4 A C 00:00:00:00 00:00:11:21 00:00:00:00 00:00:11:21
4+
000003 BigBuckBunny_320x180.mp4 A2 C 00:00:00:00 00:00:11:21 00:00:00:00 00:00:11:21
5+
000004 BigBuckBunny_320x180_cut.mp4 V C 00:00:11:21 00:00:15:18 00:00:11:21 00:00:15:18
6+
000005 BigBuckBunny_320x180_cut.mp4 A C 00:00:11:21 00:00:15:18 00:00:11:21 00:00:15:18
7+
000006 BigBuckBunny_320x180_cut.mp4 A2 C 00:00:11:21 00:00:15:18 00:00:11:21 00:00:15:18
8+
000007 BigBuckBunny_320x180.mp4 V C 00:00:15:18 00:00:23:01 00:00:15:18 00:00:23:01
9+
000008 BigBuckBunny_320x180.mp4 A C 00:00:15:18 00:00:23:01 00:00:15:18 00:00:23:01
10+
000009 BigBuckBunny_320x180.mp4 A2 C 00:00:15:18 00:00:23:01 00:00:15:18 00:00:23:01
11+
000010 BigBuckBunny_320x180_cut.mp4 V C 00:00:23:01 00:00:47:17 00:00:23:01 00:00:47:17
12+
000011 BigBuckBunny_320x180_cut.mp4 A C 00:00:23:01 00:00:47:17 00:00:23:01 00:00:47:17
13+
000012 BigBuckBunny_320x180_cut.mp4 A2 C 00:00:23:01 00:00:47:17 00:00:23:01 00:00:47:17
14+
000013 BigBuckBunny_320x180.mp4 V C 00:00:47:17 00:00:56:02 00:00:47:17 00:00:56:02
15+
000014 BigBuckBunny_320x180.mp4 A C 00:00:47:17 00:00:56:02 00:00:47:17 00:00:56:02
16+
000015 BigBuckBunny_320x180.mp4 A2 C 00:00:47:17 00:00:56:02 00:00:47:17 00:00:56:02

0 commit comments

Comments
 (0)