diff --git a/readme.md b/readme.md index 0db94ea..4a8c2de 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,6 @@ # chaptertool -Create and convert chapters for podcasts, youtube, matroska, mkvmerge/nero/vorbis, webvtt, ffmpeg and apple chapters. +Create and _convert_ chapters for podcasts, youtube, matroska, mkvmerge/nero/vorbis, webvtt, ffmpeginfo, ffmetadata, pyscenedetect, apple chapters, edl, podlove simple chapters (xml, json) and mp4chaps. The cli tools can automatically create chapters with images from videos using ffmpeg's scene detection. diff --git a/src/Formats/AutoFormat.js b/src/Formats/AutoFormat.js index 0d9a75f..3e5adad 100644 --- a/src/Formats/AutoFormat.js +++ b/src/Formats/AutoFormat.js @@ -11,6 +11,8 @@ import { WebVTT } from "./WebVTT.js"; import { Youtube } from "./Youtube.js"; import { ShutterEDL } from "./ShutterEDL.js"; import { PodloveSimpleChapters } from "./PodloveSimpleChapters.js"; +import { MP4Chaps } from "./MP4Chaps.js"; +import { PodloveJson } from "./PodloveJson.js"; export const AutoFormat = { classMap: { @@ -26,7 +28,9 @@ export const AutoFormat = { vorbiscomment: VorbisComment, applechapters: AppleChapters, shutteredl: ShutterEDL, - psc: PodloveSimpleChapters + psc: PodloveSimpleChapters, + mp4chaps: MP4Chaps, + podlovejson: PodloveJson }, detect(inputString, returnWhat = 'instance') { @@ -56,6 +60,8 @@ export const AutoFormat = { throw new Error('failed to detect type of given input :(') } + + return detected; }, diff --git a/src/Formats/MP4Chaps.js b/src/Formats/MP4Chaps.js new file mode 100644 index 0000000..0045968 --- /dev/null +++ b/src/Formats/MP4Chaps.js @@ -0,0 +1,46 @@ +import { FormatBase } from "./FormatBase.js"; +import { secondsToTimestamp, timestampToSeconds } from "../util.js"; + +export class MP4Chaps extends FormatBase { + + filename = 'mp4chaps.txt'; + mimeType = 'text/plain'; + + detect(inputString) { + return /^\d\d:\d\d:\d\d.\d\d?\d?\s/.test(inputString.trim()); + } + + parse(string) { + if (!this.detect(string)) { + throw new Error('MP4Chaps *MUST* begin with 00:00:00, received: ' + string.substr(0, 10) + '...'); + } + this.chapters = this.stringToLines(string).map(line => { + line = line.split(' '); + const startTime = timestampToSeconds(line.shift(line)); + const [title, href] = line.join(' ').split('<'); + const chapter = { + startTime, + title : title.trim() + } + + if(href){ + chapter.href = href.replace('>', ''); + } + + return chapter; + }); + } + + toString(){ + return this.chapters.map((chapter) => { + const line = []; + line.push(secondsToTimestamp(chapter.startTime, {milliseconds: true})); + line.push(chapter.title); + if(chapter.href){ + line.push(`<${chapter.href}>`); + } + return line.join(' '); + }).join("\n"); + } + +} \ No newline at end of file diff --git a/src/Formats/PodloveJson.js b/src/Formats/PodloveJson.js new file mode 100644 index 0000000..81f6ed9 --- /dev/null +++ b/src/Formats/PodloveJson.js @@ -0,0 +1,74 @@ +import { secondsToTimestamp, timestampToSeconds } from '../util.js'; +import { FormatBase } from './FormatBase.js'; + + +export class PodloveJson extends FormatBase{ + + filename = 'podlove-chapters.json'; + mimeType = 'application/json'; + + detect(inputString) { + try { + const data = JSON.parse(inputString); + const { errors } = this.test(data); + if (errors.length > 0) { + throw new Error('data test failed'); + } + } catch (e) { + return false; + } + return true; + } + + test(data) { + if (!Array.isArray(data)) { + return { errors: ['JSON Structure: must be an array'] }; + } + + if (data.length === 0) { + return { errors: ['JSON Structure: must not be empty'] }; + } + + if(!data.every(chapter => 'start' in chapter)){ + return { errors: ['JSON Structure: every chapter must have a start property'] }; + } + + return { errors: [] }; + } + + + parse(string) { + const data = JSON.parse(string); + const {errors} = this.test(data); + if (errors.length > 0) { + throw new Error(errors.join('')); + } + + this.chapters = data.map(raw => { + const {start, title, image, href} = raw; + const chapter = { + startTime: timestampToSeconds(start) + } + if(title){ + chapter.title = title; + } + if(image){ + chapter.img = image; + } + if(href){ + chapter.href = href; + } + return chapter; + }); + } + + toString(pretty = false){ + return JSON.stringify(this.chapters.map(chapter => ({ + start: secondsToTimestamp(chapter.startTime, {milliseconds: true}), + title: chapter.title || '', + image: chapter.img || '', + href: chapter.href || '' + })), null, pretty ? 2 : 0); + } + +} \ No newline at end of file diff --git a/src/Formats/Youtube.js b/src/Formats/Youtube.js index f018abd..ee58b14 100644 --- a/src/Formats/Youtube.js +++ b/src/Formats/Youtube.js @@ -7,12 +7,12 @@ export class Youtube extends FormatBase { mimeType = 'text/plain'; detect(inputString) { - return /^0?0:00/.test(inputString.trim()); + return /^0?0:00(:00)?\s/.test(inputString.trim()); } parse(string) { if (!this.detect(string)) { - throw new Error('Youtube Chapters *MUST* begin with (0)0:00'); + throw new Error('Youtube Chapters *MUST* begin with (0)0:00(:00), received: ' + string.substr(0, 10) + '...'); } this.chapters = this.stringToLines(string).map(line => { line = line.split(' '); diff --git a/tests/conversions.test.js b/tests/conversions.test.js index f92da5b..b8ab96a 100644 --- a/tests/conversions.test.js +++ b/tests/conversions.test.js @@ -10,11 +10,19 @@ import { AppleChapters } from "../src/Formats/AppleChapters.js"; import { ShutterEDL } from "../src/Formats/ShutterEDL.js"; import { VorbisComment } from "../src/Formats/VorbisComment.js"; import { PodloveSimpleChapters } from "../src/Formats/PodloveSimpleChapters.js"; +import { MP4Chaps } from "../src/Formats/MP4Chaps.js"; +import { PodloveJson } from "../src/Formats/PodloveJson.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, AppleChapters, ShutterEDL, VorbisComment, PodloveSimpleChapters]; + const formats = [ + ChaptersJson, WebVTT, Youtube, FFMetadata, + MatroskaXML, MKVMergeXML, MKVMergeSimple, + PySceneDetect, AppleChapters, ShutterEDL, + VorbisComment, PodloveSimpleChapters, MP4Chaps, + PodloveJson + ]; const content = readFileSync(module.path + sep + 'samples' + sep + 'chapters.json', 'utf-8'); diff --git a/tests/format_autodetection.test.js b/tests/format_autodetection.test.js index 3116bec..9423e59 100644 --- a/tests/format_autodetection.test.js +++ b/tests/format_autodetection.test.js @@ -1,27 +1,40 @@ -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 {readFileSync} from "fs"; -import {sep} from "path"; -import {FFMpegInfo} from "../src/Formats/FFMpegInfo.js"; -import {AutoFormat} from "../src/Formats/AutoFormat.js"; +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 { readFileSync } from "fs"; +import { sep } from "path"; +import { FFMpegInfo } from "../src/Formats/FFMpegInfo.js"; +import { AutoFormat } from "../src/Formats/AutoFormat.js"; +import { AppleChapters } from "../src/Formats/AppleChapters.js"; +import { MP4Chaps } from "../src/Formats/MP4Chaps.js"; +import { PodloveSimpleChapters } from "../src/Formats/PodloveSimpleChapters.js"; +import { PySceneDetect } from "../src/Formats/PySceneDetect.js"; +import { ShutterEDL } from "../src/Formats/ShutterEDL.js"; +import { VorbisComment } from "../src/Formats/VorbisComment.js"; +import { PodloveJson } from "../src/Formats/PodloveJson.js"; describe('autodetection of sample files', () => { - const filesAndKeysAndHandlers = [ + ['applechapters.xml', 'applechapters', AppleChapters], ['chapters.json', 'chaptersjson', ChaptersJson], ['FFMetadata.txt', 'ffmetadata', FFMetadata], ['ffmpeginfo.txt', 'ffmpeginfo', FFMpegInfo], ['matroska.xml', 'matroskaxml', MatroskaXML], ['mkvmerge.simple.txt', 'mkvmergesimple', MKVMergeSimple], ['mkvmerge.xml', 'mkvmergexml', MKVMergeXML], + ['mp4chaps.txt', 'mp4chaps', MP4Chaps], + ['podlove-simple-chapters.xml', 'psc', PodloveSimpleChapters], + ['podlove.json', 'podlovejson', PodloveJson], + ['pyscenedetect.csv', 'pyscenedetect', PySceneDetect], + ['shutter.edl', 'shutteredl', ShutterEDL], + ['vorbiscomment.txt', 'vorbiscomment', VorbisComment], ['webvtt.txt', 'webvtt', WebVTT], - ['youtube-chapters.txt', 'youtube', Youtube], + ['youtube-chapters.txt', 'youtube', Youtube] ]; filesAndKeysAndHandlers.forEach(item => { @@ -62,32 +75,4 @@ describe('autodetection of sample files', () => { }); - - return; - /* - Object.entries(filesAndHAndlers).forEach(pair => { - const [file, className] = pair; - const content = readFileSync(module.path + sep + 'samples' + sep + file, 'utf-8'); - it(`${className.name} detects ${file}`, () => { - expect(() => { - new className(content) - }).not.toThrow(Error); - }); - - Object.entries(filesAndHAndlers).forEach(pair => { - const className2 = pair[1]; - if (className2 === className) { - return; - } - - it(`${className2.name} rejects ${file}`, () => { - expect(() => { - new className2(content) - }).toThrow(Error); - }); - }) - }); - - - */ }); \ No newline at end of file diff --git a/tests/format_detection.test.js b/tests/format_detection.test.js index 4acd93c..1b1287a 100644 --- a/tests/format_detection.test.js +++ b/tests/format_detection.test.js @@ -1,15 +1,28 @@ -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 {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 { VorbisComment } from "../src/Formats/VorbisComment.js"; +import { PodloveSimpleChapters } from "../src/Formats/PodloveSimpleChapters.js"; +import { MP4Chaps } from "../src/Formats/MP4Chaps.js"; +import { PodloveJson } from "../src/Formats/PodloveJson.js"; +import { readFileSync } from "fs"; +import { sep } from "path"; describe('detection of input strings', () => { - const formats = [ChaptersJson, WebVTT, Youtube, FFMetadata, MatroskaXML, MKVMergeXML, MKVMergeSimple]; + const formats = [ + ChaptersJson, WebVTT, Youtube, FFMetadata, + MatroskaXML, MKVMergeXML, MKVMergeSimple, + PySceneDetect, AppleChapters, ShutterEDL, + VorbisComment, PodloveSimpleChapters, MP4Chaps, + PodloveJson + ]; const content = readFileSync(module.path + sep + 'samples' + sep + 'chapters.json', 'utf-8'); @@ -17,15 +30,17 @@ describe('detection of input strings', () => { formats.forEach(fromFormat => { const from = initial.to(fromFormat).toString(); + formats.forEach(toFormat => { - if(toFormat.name === fromFormat.name){ + if (toFormat.name === fromFormat.name) { it(`accepts output of ${fromFormat.name} given to ${toFormat.name}`, () => { + expect(() => { new toFormat(from); }).not.toThrow(Error); }); - }else{ + } else { it(`fails detection of ${fromFormat.name} output given to ${toFormat.name}`, () => { expect(() => { new toFormat(from); diff --git a/tests/format_mp4chaps.test.js b/tests/format_mp4chaps.test.js new file mode 100644 index 0000000..5f50268 --- /dev/null +++ b/tests/format_mp4chaps.test.js @@ -0,0 +1,60 @@ + +import {readFileSync} from "fs"; +import {sep} from "path"; +import {MP4Chaps} from "../src/Formats/MP4Chaps.js"; +import {PodloveSimpleChapters} from "../src/Formats/PodloveSimpleChapters.js"; + + +describe('MP4Chaps Format Handler', () => { + it('accepts no arguments', () => { + expect(() => { + new MP4Chaps(); + }).not.toThrowError(TypeError); + }); + + + it('fails on malformed input', () => { + expect(() => { + new MP4Chaps('asdf'); + }).toThrowError(Error); + }); + + const content = readFileSync(module.path + sep + 'samples' + sep + 'mp4chaps.txt', 'utf-8'); + + it('parses well-formed input', () => { + expect(() => { + new MP4Chaps(content); + }).not.toThrow(Error); + }); + + const instance = new MP4Chaps(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[0].title).toBe('Chapter 1') + }); + + it('exports to correct format',() => { + expect(instance.toString().slice(0,5)).toEqual('00:00'); + }); + + it('export includes correct timestamp',() => { + expect(instance.toString()).toContain('00:11:46.612'); + }); + + it('can import previously generated export',() => { + expect(new MP4Chaps(instance.toString()).chapters[3].startTime).toEqual(2482.67); + }); + + it('can convert into other format', () => { + expect(instance.to(PodloveSimpleChapters)).toBeInstanceOf(PodloveSimpleChapters) + }); + +}); diff --git a/tests/format_podlovejson.test.js b/tests/format_podlovejson.test.js new file mode 100644 index 0000000..643cd7e --- /dev/null +++ b/tests/format_podlovejson.test.js @@ -0,0 +1,60 @@ + +import { readFileSync } from "fs"; +import { sep } from "path"; +import { ShutterEDL } from "../src/Formats/ShutterEDL.js"; +import { PodloveJson } from "../src/Formats/PodloveJson.js"; + + +describe('PodloveJson Format Handler', () => { + it('accepts no arguments', () => { + expect(() => { + new PodloveJson(); + }).not.toThrowError(TypeError); + }); + + + it('fails on malformed input', () => { + expect(() => { + new PodloveJson('asdf'); + }).toThrowError(Error); + }); + + const content = readFileSync(module.path + sep + 'samples' + sep + 'podlove.json', 'utf-8'); + + it('parses well-formed input', () => { + expect(() => { + new PodloveJson(content); + }).not.toThrow(Error); + }); + + const instance = new PodloveJson(content); + + it('has the correct number of chapters from content', () => { + expect(instance.chapters.length).toEqual(3); + }); + + it('has parsed the timestamps correctly', () => { + expect(instance.chapters[1].startTime).toBe(754) + }); + + it('has parsed the chapter titles correctly', () => { + expect(instance.chapters[0].title).toBe('Intro') + }); + + it('exports to correct format', () => { + expect(instance.toString()).toContain('start":"'); + }); + + it('export includes correct timestamp', () => { + expect(instance.toString()).toContain('00:12:34.000'); + }); + + it('can import previously generated export', () => { + expect(new PodloveJson(instance.toString()).chapters[2].startTime).toEqual(3723); + }); + + it('can convert into other format', () => { + expect(instance.to(ShutterEDL)).toBeInstanceOf(ShutterEDL) + }); + +}); diff --git a/tests/samples/mp4chaps.txt b/tests/samples/mp4chaps.txt new file mode 100644 index 0000000..5be64c7 --- /dev/null +++ b/tests/samples/mp4chaps.txt @@ -0,0 +1,5 @@ +00:00:00.000 Chapter 1 +00:11:46.612 Chapter 2 +00:22:55.525 Chapter 3 +00:41:22.670 Chapter 4 +01:09:17.337 Chapter 5 \ No newline at end of file diff --git a/tests/samples/podlove.json b/tests/samples/podlove.json new file mode 100644 index 0000000..9531e0b --- /dev/null +++ b/tests/samples/podlove.json @@ -0,0 +1,5 @@ +[ + { "start": "00:00:01.234", "title": "Intro", "href": "http://example.com", "image": "" }, + { "start": "00:12:34.000", "title": "About us", "href": "", "image": "" }, + { "start": "01:02:03.000", "title": "Later", "href": "", "image": "http://example.com/foo.jpg" } +] \ No newline at end of file diff --git a/wtf.xml b/wtf.xml deleted file mode 100644 index dd4cfbb..0000000 --- a/wtf.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - -