├── .dockerignore ├── .gitattributes ├── .github ├── readme.md └── workflows │ └── pages.yml ├── .gitignore ├── Dockerfile ├── changes.md ├── chaptertool.js ├── cli.md ├── eslint.config.js ├── examples.md ├── faq.md ├── package-lock.json ├── package.json ├── readme.md ├── rollup.config.js ├── scripts ├── empuggen.js └── writeversion.js ├── src ├── CLI │ ├── ArgumentParser.js │ ├── AudioExtractor.js │ ├── ChapterConverter.js │ └── ChapterGenerator.js ├── Frontend.js ├── Frontend │ ├── ExportFeatures.js │ ├── FileHandler.js │ ├── ImportDialog.js │ ├── MediaFeatures.js │ ├── MetaProperties.js │ ├── SWInclude.js │ ├── SettingsDialog.js │ ├── ShepherdTour.js │ └── Timeline.js ├── Server.js ├── cli_util.js ├── scss │ └── app.scss ├── util.js └── views │ ├── index.pug │ └── partials │ ├── analyticsOffcanvas.pug │ ├── chapterEditPanel.pug │ ├── chapterListItem.pug │ ├── exportDialog.pug │ ├── faq.pug │ ├── header.pug │ ├── importDialog.pug │ ├── mediaBox.pug │ ├── mediaExpandNotice.pug │ ├── meta.pug │ ├── metaDialog.pug │ ├── noChapterSelected.pug │ ├── offcanvasNavi.pug │ ├── settingsDialog.pug │ ├── timeline.pug │ ├── timelineControls.pug │ └── timestampDialog.pug └── static ├── app.css ├── app.js ├── fonts ├── bootstrap-icons.woff └── bootstrap-icons.woff2 ├── icons ├── Icon-16.png ├── Icon-180.png ├── Icon-192.png ├── Icon-32.png ├── Icon-512.png ├── Icon-64.png ├── icon-16.png ├── icon-180.png ├── icon-192.png ├── icon-32.png ├── icon-512.png └── icon-64.png ├── manifest.webmanifest ├── sw.js └── version /.dockerignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /node_modules -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | static/* linguist-generated 2 | static/app.js -linguist-detectable 3 | static/app.css -linguist-detectable 4 | static/index.html -linguist-detectable -------------------------------------------------------------------------------- /.github/readme.md: -------------------------------------------------------------------------------- 1 | ![chaptertool](../static/icons/icon-180.png) 2 | 3 | # chaptertool 4 | 5 | Create and _convert_ chapters for podcasts, youtube, matroska, mkvmerge/nero/vorbis, webvtt, ffmpeginfo, ffmetadata, pyscenedetect, apple chapters, edl, podlove simple chapters (xml, json), apple hls chapters and mp4chaps. 6 | 7 | > Build on [@mtillmann/chapters](https://github.com/Mtillmann/chapters) 8 | 9 | ## [Web App](https://mtillmann.github.io/chaptertool) 10 | 11 | [Click here to open the web app](https://mtillmann.github.io/chaptertool). 12 | 13 | ## Supported Formats 14 | 15 | | name | key | ext | info | 16 | | ---------------------------- | -------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 17 | | Podcasting 2.0 Chapters | chaptersjson | `json` | [spec](https://github.com/Podcastindex-org/podcast-namespace/blob/main/chapters/jsonChapters.md) | 18 | | FFMetadata | ffmpegdata | `txt` | [spec](https://ffmpeg.org/ffmpeg-formats.html#Metadata-1) | 19 | | Matroska XML chapters | matroskaxml | `xml` | [spec](https://www.matroska.org/technical/chapters.html) | 20 | | MKVToolNix mkvmerge XML | mkvmergexml | `xml` | [spec](https://mkvtoolnix.download/doc/mkvmerge.html#mkvmerge.chapters) | 21 | | MKVToolNix mkvmerge _simple_ | mkvmergesimple | `txt` | [spec](https://mkvtoolnix.download/doc/mkvmerge.html#mkvmerge.chapters) | 22 | | WebVTT Chapters | webvtt | `vtt` | [spec](https://developer.mozilla.org/en-US/docs/Web/API/WebVTT_API) | 23 | | Youtube Chapter Syntax | youtube | `txt` | | 24 | | FFMpegInfo | ffmpeginfo | `txt` | read only, used internally | 25 | | PySceneDetect | pyscenedetect | `csv` | [project home](https://github.com/Breakthrough/PySceneDetect) | 26 | | Vorbis Comment Format | vorbiscomment | `txt` | [spec](https://wiki.xiph.org/Chapter_Extension) | 27 | | "Apple Chapters" | applechapters | `xml` | [source](https://github.com/rigaya/NVEnc/blob/master/NVEncC_Options.en.md#--chapter-string:~:text=CHAPTER03NAME%3Dchapter%2D3-,apple%20format,-(should%20be%20in)) | 28 | | Shutter EDL | edl | `edl` | [source](https://github.com/paulpacifico/shutter-encoder/blob/f3d6bb6dfcd629861a0b0a50113bf4b062e1ba17/src/application/SceneDetection.java) | 29 | | Podigee Chapters/Chaptermarks | podigee | `json` | [spec](https://app.podigee.com/api-docs#!/ChapterMarks/updateChapterMark:~:text=Model-,Example%20Value,-%7B%0A%20%20%22title%22%3A%20%22string%22%2C%0A%20%20%22start_time) | 30 | | Podlove Simple Chapters | psc | `xml` | [spec](https://podlove.org/simple-chapters/) | 31 | | Podlove Simple Chapters JSON | podlovejson | `json` | [source](https://github.com/podlove/chapters#:~:text=org/%3E-,Encode%20to%20JSON,-iex%3E%20Chapters) | 32 | | MP4Chaps | mp4chaps | `txt` | [source](https://github.com/podlove/chapters#:~:text=%3Achapters%3E-,Encode%20to%20mp4chaps,-iex%3E%20Chapters) | 33 | | Apple HLS Chapters | applehls | `json` | [spec](https://developer.apple.com/documentation/http-live-streaming/providing-javascript-object-notation-json-chapters), partial support | 34 | | Scenecut format | scenecut | `json` | [source](https://github.com/slhck/scenecut-extractor#:~:text=cuts%20in%20JSON-,format,-%3A) | 35 | | Audible Chapter Format | audible | `json` | [source](./audible-chapter-spec.md) | 36 | | Spotify Formats A/B | spotifya\|spotifyb | `txt` | [see](https://github.com/Mtillmann/chapters/blob/main/misc-text-chapter-spec.md) | 37 | | Podcastpage Format | podcastpage | `txt` | [see](https://github.com/Mtillmann/chapters/blob/main/misc-text-chapter-spec.md) | 38 | | Podigee Text Format | podigeetext | `txt` | [see](https://github.com/Mtillmann/chapters/blob/main/misc-text-chapter-spec.md) | 39 | | TransistorFM Chapter Format | transistorfm | `txt` | [see](https://github.com/Mtillmann/chapters/blob/main/misc-text-chapter-spec.md) | 40 | | Unknown Shownotes Format | shownotes | `txt` | [see](https://github.com/Mtillmann/chapters/blob/main/misc-text-chapter-spec.md) | 41 | 42 | 43 | ## CLI 44 | 45 | An updated cli tool with better interface is available here: [chapconv](https://github.com/Mtillmann/chapconv) 46 | 47 | If you want to extract chapters from videos, rather use native ffmpeg or pyscenedetect commands and integrate chapconv in your pipeline. Here is the [old CLI Documentation](/cli.md). 48 | 49 | ## Examples & FAQ 50 | 51 | [examples.md](/examples.md), [FAQ](/faq.md) 52 | 53 | ## Docker 54 | 55 | Use docker to run the web GUI: 56 | 57 | ```shell 58 | docker build -t chaptertool-gui . 59 | docker run -p 8989:8989 chaptertool-gui 60 | # open http://localhost:8989 in your browser 61 | ``` 62 | 63 | or use the image from dockerhub: 64 | 65 | ```shell 66 | docker run -p 8989:8989 martintillmann/chaptertool-gui 67 | ``` 68 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow one concurrent deployment 19 | concurrency: 20 | group: "pages" 21 | cancel-in-progress: true 22 | 23 | jobs: 24 | # Single deploy job since we're just deploying 25 | deploy: 26 | environment: 27 | name: github-pages 28 | url: ${{ steps.deployment.outputs.page_url }} 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v4 33 | - run: npm install 34 | - run: npm run build 35 | - run: npm run createIndexHTML 36 | - name: inject analytics id 37 | env: 38 | GA_ID: ${{ secrets.GA_ID }} 39 | run: echo "$GA_ID" > static/ga-code 40 | - name: Ensure github-pages directory 41 | run: mv static github-pages 42 | - name: Setup Pages 43 | uses: actions/configure-pages@v3 44 | - name: Upload artifact 45 | uses: actions/upload-pages-artifact@v3 46 | with: 47 | path: "./github-pages" 48 | - name: Deploy to GitHub Pages 49 | id: deployment 50 | uses: actions/deploy-pages@v4 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /.idea 3 | /*.mp4 4 | /*.mp3 5 | /*.aac 6 | /*.m4a 7 | /*.jpg 8 | /.env 9 | /*.webm 10 | /coverage -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use a Node.js base image 2 | FROM node:20 3 | 4 | # Set the working directory 5 | WORKDIR /app 6 | 7 | # Copy package files and install dependencies 8 | COPY . . 9 | RUN npm install 10 | RUN npm run build 11 | 12 | # Expose port (change if needed) 13 | EXPOSE 8989 14 | 15 | # Run the app 16 | CMD ["node", "chaptertool.js", "serve"] -------------------------------------------------------------------------------- /changes.md: -------------------------------------------------------------------------------- 1 | # 0.4.3 2 | 3 | - fixed webvtt file input handling in webapp 4 | 5 | # 0.4.0 6 | 7 | - added vorbis comment support 8 | - added apple chapters support 9 | - improved onboarding 10 | - minor ui stuff 11 | - bumped bootstrap and icons 12 | - lots of bugfixes 13 | 14 | # 0.3.0 15 | 16 | - added PySceneDetect format 17 | - fixed testing 18 | - fixed minor ui bug 19 | - bumped bootstrap alpha 20 | 21 | # 0.2.0 22 | 23 | - initial release -------------------------------------------------------------------------------- /chaptertool.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { ArgumentParser } from './src/CLI/ArgumentParser.js' 3 | import { ChapterGenerator } from './src/CLI/ChapterGenerator.js' 4 | import { AudioExtractor } from './src/CLI/AudioExtractor.js' 5 | import * as dotenv from 'dotenv' 6 | import { Server } from './src/Server.js' 7 | import { ChapterConverter } from './src/CLI/ChapterConverter.js' 8 | 9 | dotenv.config() 10 | 11 | const CLIArgs = new ArgumentParser() 12 | 13 | const help = `chaptertool 14 | available commands: 15 | generate generate chapters from video file 16 | convert convert chapter files between different formats 17 | serve run the web ui 18 | ` 19 | 20 | switch (CLIArgs.action) { 21 | case 'generate' : 22 | new AudioExtractor(CLIArgs.options).extract() 23 | new ChapterGenerator(CLIArgs.options).generate() 24 | break 25 | case 'convert' : 26 | // eslint-disable-next-line no-new 27 | new ChapterConverter(CLIArgs.options) 28 | break 29 | case 'serve' : 30 | // eslint-disable-next-line no-new 31 | new Server(CLIArgs.options) 32 | break 33 | default: 34 | console.log(help) 35 | break 36 | } 37 | -------------------------------------------------------------------------------- /cli.md: -------------------------------------------------------------------------------- 1 | # CLI 2 | 3 | This will be removed from chaptertool at some point. Migrate to ffmpeg/pyscenedetect+chapconv! 4 | 5 | ## Prerequisite 6 | 7 | You need to install `node` and optionally `ffmpeg` on your system: 8 | 9 | Windows: [modern terminal](https://github.com/microsoft/terminal), [package manager](https://chocolatey.org/), [node](https://www.startpage.com/sp/search?q=windows%20install%20node), [ffmpeg](https://www.startpage.com/sp/search?q=windows%20install%20ffmpeg) 10 | macOS: [package manager](https://brew.sh/), [node](https://www.startpage.com/sp/search?q=macOS%20install%20node), [ffmpeg](https://www.startpage.com/sp/search?q=macOS%20install%20ffmpeg) 11 | linux: [node](https://www.startpage.com/sp/search?q=linux%20install%20node), [ffmpeg](https://www.startpage.com/sp/search?q=linux%20install%20ffmpeg) 12 | 13 | ## create chapters from video 14 | 15 | 16 | ```shell 17 | npx chaptertool@latest generate YOUR_FILE.mp4 18 | ``` 19 | Wait for the process to finish, afterwards a new folder called `YOUR_FILE_chapters` should be present. 20 | It contains the screenshots from the video and a `chapters.json`-file that contains the automatically generated chapters. 21 | 22 | ## commands 23 | 24 | > `npx chaptertool@latest --option-a --option-b=value` 25 | 26 | ### `serve` 27 | Run the http-server that hosts the web ui. 28 | 29 | | option | description | default | 30 | |-----------|----------------------------------------------------------------------------|---------| 31 | | `--port` | port for the http-server | `8989` | 32 | 33 | ### `generate` 34 | Generate raw chapters from video using ffmpeg. 35 | 36 | | option | description | default | 37 | |------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------| 38 | | `` | the video file that you want to process | | 39 | | `--y` | when set ffmpeg will _always_ overwrite existing output | | 40 | | `--n` | when set ffmpeg will _never_ overwrite existing output | | 41 | | `--output-format` | output format for the chapters, [see below](#convert) | `chaptersjson` | 42 | | `--output-folder` | image destination folder, `$filename` will be replaced with the input video filename minus the extension | `$filename_chapters` | 43 | | `--chapter-template` | template string for the chapter names. | `Chapter $chapter of $total` | 44 | | `--scene-value` | min value for ffmpeg's scene detection. If you only use a small portion of the screen, the value should be smaller. See the crop option | `0.1` | 45 | | `--scale` | when given, images will be scaled to given width while keeping original aspect ratio | | 46 | | `--force-dar` | when used, the display aspect ratio will be used for generated images. Useful for some videos. overwrites `--scale` | | 47 | | `--crop` | when set, it will apply the [crop filter](https://ffmpeg.org/ffmpeg-filters.html#crop) on the input to the given coordinates. General syntax is `w:h:x:y` | | 48 | | `--use-crossfade-fix` | when set, a special filter setup will be used to handle crossfade situations | | 49 | | `--crossfade-frames` | assuming your input video has a framerate of ~30fps and your average cross-fade transition is 2 seconds long, the amount of frame should be at least `FPS * CROSSFADE_DURATION * 2` | `120` | 50 | | `--silent` | suppress output | | 51 | | `--img-uri` | uri to prepend to the images in the json | | 52 | | `--pretty` | pretty-print the output, if supported | | 53 | | `--keep-info` | when set, info.txt will not be deleted | | 54 | | `--config` | point to a yaml file that may contain all options for a enhanced re/usability, see below | | 55 | | `--dump-ffmpeg` | echo the generated ffmpeg-command to stdout | | 56 | | `--ffmpeg-binary` | path to the ffmpeg binary, optional | `ffmpeg` | 57 | | `--ffprobe-binary` | path to the ffprobe binary, optional | `ffprobe` | 58 | | `--extract-audio` | extract the audio from video file | | 59 | | `--audio-filename` | filename for the audio file. $filename will be replaced with input filename, same as `--output-folder`. Extension controls the output format | `$filename.mp3` | 60 | | `--audio-options` | options for the ffmpeg command that extracts the audio | `-q:a 0 -map a` | 61 | | `--audio-copy-stream` | copy audio stream from video. Correct output file extension will be set automatically. `--audio-options` will be overwritten internally | | 62 | | `--audio-only` | create no chapters and images | | 63 | | `--input-chapters` | path to a chapters.json file. See below | | 64 | | `--dump-options` | dump final options object, for debugging only | | 65 | | `--min-chapter-length` | minimum chapter length. New chapters below that threshold are ignored | `10` | 66 | | `--no-end-times` | when set, no endTime-attributes are written on chapters.json | | 67 | 68 | ### `convert` 69 | 70 | Converts existing chapters between any of the supported formats: 71 | 72 | | option | description | default | 73 | |------------------------|-------------------------------------------------------------------------------------|---------| 74 | | `` | the file that you want to convert, format will be detected | | 75 | | `--output-format` | target format, one of those listed above. When omitted, detected input format is used | | 76 | | `--pretty` | some formats support pretty printing | | 77 | | `--img-uri` | see above, works only with `chaptersjson` | | 78 | | `--output-file` | file to write the output to. see below | | 79 | | `--psd-omit-timecodes` | When set, first line of _PySceneDetect_-CSV will not be written | | 80 | | `--psd-framerate` | set the framerate for _PySceneDetect_ output | | 81 | | `--ac-use-text-attr` | use the text-attribute for _Apple Chapters_ | | 82 | 83 | > use `--output-file` when using powershell, otherwise you'll have BOMs in your output 84 | 85 | ## config yaml and .env 86 | 87 | Use any option (except input) listed above in a config file passed via `--config`to create reusable configurations: 88 | 89 | ```yaml 90 | # my-config.yaml 91 | - --crop=616:410:554:46 92 | - --silent 93 | ``` 94 | 95 | Additionally you can create an `.env` file in any directory and put in options like this: 96 | 97 | ```dotenv 98 | # prefix with CT_, option name uppercase and replace all dashes with underscore 99 | CT_CROP="616:410:554:46" 100 | CT_SILENT=true 101 | CT_DUMP_FFMEG=true 102 | ``` 103 | 104 | You can combine config with regular cli options. Evaluation occurs in this order: 105 | 106 | 1. build-in default value 107 | 2. `.env` values 108 | 3. config yaml (if present) value 109 | 4. explicit cli value -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // this assumes that your package.json contains type: module 2 | import neostandard from 'neostandard' 3 | 4 | export default neostandard({ 5 | ignores: ['node_modules'], 6 | files: ['*.js'], 7 | globals: ['gtag', 'confirm', 'localStorage'] 8 | }) 9 | -------------------------------------------------------------------------------- /examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | 4 | ## generate 5 | 6 | Some of the examples require [yt-dlp](https://github.com/yt-dlp/yt-dlp). 7 | 8 | ### cropping 9 | 10 | [This video](https://www.youtube.com/watch?v=rpDWoshRnME) is a podcast with a slideshow version on youtube that 11 | only uses a small portion of the video: 12 | 13 | ```shell 14 | yt-dlp "https://www.youtube.com/watch?v=rpDWoshRnME" -o cropme.webm 15 | npx chaptertool@latest generate cropme.webm --crop="926:608:831:72" 16 | ``` 17 | 18 | ### handling cross-fade slideshows 19 | 20 | [This video](https://www.youtube.com/watch?v=EL9ftQJ3Yjw) is a podcast with a slideshow version on youtube that 21 | unfortunately uses cross-fade transitions between slides. Scene detection fails with those transitions 22 | because the difference between the frames during the the transition is very small. 23 | 24 | ```shell 25 | yt-dlp "https://www.youtube.com/watch?v=EL9ftQJ3Yjw" -o crossfaded.webm 26 | npx chaptertool@latest generate crossfaded.webm --use-crossfade-fix 27 | ``` 28 | 29 | ### Bad Display Aspect Ratio 30 | 31 | [This video](https://cdn.media.ccc.de/events/gpn/gpn16/h264-sd/gpn16-7623-deu-Wie_baut_man_eigentlich_Raumschiffe_sd.mp4) 32 | ([from here](https://media.ccc.de/v/gpn16-7623-wie_baut_man_eigentlich_raumschiffe)) has a display aspect ratio of 16:9 (1.77) 33 | but a natural resolution of 720x576 (1.25). Create images(1024x576) with square pixels like this: 34 | 35 | ```shell 36 | wget "https://cdn.media.ccc.de/events/gpn/gpn16/h264-sd/gpn16-7623-deu-Wie_baut_man_eigentlich_Raumschiffe_sd.mp4" -o baddar.mp4 37 | npx chaptertool@latest generate baddar.mp4 --force-dar 38 | ``` 39 | -------------------------------------------------------------------------------- /faq.md: -------------------------------------------------------------------------------- 1 | # chaptertool - frequently asked questions 2 | 3 | ## Are videos uploaded to a server? 4 | No. All processing is done on your device. No image, video, audio or chapters-file is ever uploaded. 5 | 6 | ## Where are the zips generated? 7 | All downloads, including zipfiles containing images, are generated on your device. 8 | 9 | ## Does automatic chapter generation work in the web app? 10 | No, at the moment chapter generation only works with the cli app. Although ffmpeg.js exists, I doubt that it'd be 11 | worth the effort to implement it in the browser. 12 | 13 | ## Can chaptertool publish or upload the chapters to my webserver? 14 | No, not at the moment but there are plans. 15 | 16 | ## Can I run the chaptertool web app on my own machine or locally? 17 | Yes, run `npx --yes chaptertool@latest serve`. 18 | 19 | ## Can I install chaptertool on my machine? 20 | Yes, chaptertool supports PWA functionality. If you use a chromium-based browser (chrome, brave, edge, vivaldi) 21 | the option to install the app locally should appear on the right in the address bar 22 | 23 | ## What data is tracked? 24 | When analytics is enabled, only superficial usage is tracked. No filenames, chapter titles or any other 25 | actual user input is ever transmitted to google. 26 | 27 | ## What are good scene detection values? 28 | Generally use half of the percentage of the video canvas that actually changes: 29 | 30 | For regular full motion videos, you'll have good results with a value of 0.5. A smaller value will create 31 | more stills, a higher value less. 32 | 33 | For slideshow videos that only use a portion of the screen for the actual content, the value must be smaller 34 | than fraction of the image that is used. 35 | 36 | For example, this ... uses only ~20-27% of the video canvas for the actual slideshow, so any value scene-value 37 | above 0.27 will produce no snapshots at all because 73% of any two frames is identical. A scene value of 0.2 will 38 | produce some output while 0.1 produces a good amount. 0.1 is approximately half of the size of the smallest 39 | images used inside the video. 40 | 41 | Likewise, for talks and lectures that show slides you should figure out how much of the 42 | screen a slide shown in the video takes up and use half of that percentage. 43 | 44 | ## How does min chapter length work? 45 | After the ffmpeg processing is done, the result is parsed and entries that are shorter that the given min 46 | chapter length are removed. Afterwards the images are deleted and renamed. 47 | 48 | ## Why use npx? 49 | `npx` is a good alternative to `npm i X -g`. Using `npx chaptertool@latest` makes sure that you always use 50 | the latest version without to remember to update your global npm install. 51 | 52 | ## How can I skip the npx install prompt? 53 | If you want to make sure that your process always uses the latest version without hanging on the install prompt, use 54 | `npx --yes chaptertool@latest` 55 | 56 | ## How to add ffmetadata chapters to video? 57 | ```shell 58 | ffmpeg -i INPUT.mp4 -i FFMETADATAFILE.txt -map_metadata 1 -codec copy OUTPUT.mp4 59 | ``` 60 | via [Kyle Howells' blog](https://ikyle.me/blog/2020/add-mp4-chapters-ffmpeg) 61 | 62 | ## How to add mkvmerge chapters to a mkv? 63 | ```yaml 64 | mkvmerge --chapters chapters.txt -o output.mkv input-file.mkv 65 | # xml and simple formats work the same way 66 | ``` 67 | via [programster's blog](https://blog.programster.org/add-chapters-to-mkv-file) 68 | 69 | ## How to add matroskaxml chapters to a mkv? 70 | I honestly have no idea, just use the mkvmerge chapters or let me know if you know. 71 | 72 | ## how to use youtube chapters? 73 | Paste the output in your video's description. Youtube will link the timestamps automatically. The first timestamp must 74 | be 00:00. 75 | 76 | ## how to use WebVTT chapters? 77 | [see MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/WebVTT_API) 78 | 79 | ## What's the difference between mkvmergesimple and vorbis comment? 80 | [mkvmergesimple](https://savvyadmin.com/adding-chapters-to-videos-using-mkv-containers/#:~:text=chapter.txt.-,CHAPTER01,-%3D00%3A00%3A00.000) apparently pads the chapter index to 2 digits, while [vorbis comment](https://wiki.xiph.org/Chapter_Extension#:~:text=two%20sequential%20chapters%3A-,CHAPTER001,-%3D00%3A00%3A00.000) uses 3 digits. 81 | 82 | ## How to use the apple chapters? 83 | I honestly don't know as the only reference I could find is [NVEnc's documentation](https://github.com/rigaya/NVEnc/blob/master/NVEncC_Options.en.md#--chapter-string:~:text=apple%20format%20(should%20be%20in%20utf%2D8)) 84 | 85 | ## Which EDL format is used? 86 | I used the EDL format that is yielded by [shutter encoder](https://www.shutterencoder.com/en/). I hope the format is compatible with other software, but I haven't tested it. 87 | 88 | ## How to use the podlove simple chapters? 89 | The podlove simple chapters are used in the podlove web player. The format is described in the [podlove documentation](https://podlove.org/simple-chapters/). 90 | 91 | ## How to use the apple hls chapters? 92 | I have no idea. Also the format is only partially supported as it has more features than `chaptersjson` (which is the internal format used by chaptertool) can express. 93 | 94 | ## How to use the mp4chaps chapters? 95 | No clue, hopefully you can figure it out. 96 | 97 | 98 | ## What does "chapter/timeline lock" do? 99 | When enabled, clicking anywhere on the timeline will select the chapter below. When disabled you can click on the timeline without the chapter below being selected and scrolled into view. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chaptertool", 3 | "version": "0.7.0", 4 | "description": "Manage and generate chapters for podcasts and other media via cli or web", 5 | "keywords": [ 6 | "podcast", 7 | "ffmpeg", 8 | "chapters", 9 | "youtube", 10 | "mkvmerge", 11 | "matroska", 12 | "webvtt", 13 | "pyscenedetect", 14 | "vorbis", 15 | "apple-chapters", 16 | "podlove", 17 | "podcast-chapters", 18 | "edl", 19 | "hls", 20 | "scenecut" 21 | ], 22 | "type": "module", 23 | "main": "chaptertool.js", 24 | "bin": "chaptertool.js", 25 | "homepage": "https://github.com/Mtillmann/chaptertool", 26 | "scripts": { 27 | "sass-dev": "node-sass --recursive --watch src/scss --output static", 28 | "build": "rollup -c && terser static/app.js --compress ecma=2021 --output static/app.js && node-sass --recursive --output-style compressed src/scss --output static && node scripts/writeversion.js", 29 | "createIndexHTML": "node scripts/empuggen.js", 30 | "dev": "rollup -c -w", 31 | "lint": "eslint", 32 | "watch": "concurrently \"node-sass --recursive --watch src/scss --output static\" \"npm run dev\" \"nodemon --watch src/Server.js --exec node chaptertool.js serve\"" 33 | }, 34 | "author": "Martin Tillmann", 35 | "bugs": { 36 | "url": "https://github.com/Mtillmann/chaptertool/issues" 37 | }, 38 | "files": [ 39 | "src", 40 | "scripts", 41 | "static" 42 | ], 43 | "license": "MIT", 44 | "dependencies": { 45 | "@mtillmann/chapters": "^0.1.8", 46 | "@zip.js/zip.js": "^2.7.32", 47 | "bootstrap": "^5.3.2", 48 | "bootstrap-icons": "^1.11.3", 49 | "dotenv": "^16.3.2", 50 | "escape-string-regexp": "^5.0.0", 51 | "express": "^4.18.2", 52 | "filenamify": "^6.0.0", 53 | "jsdom": "^24.0.0", 54 | "node-fetch-native": "^1.6.1", 55 | "pug": "^3.0.2", 56 | "shepherd.js": "^11.2.0", 57 | "yaml": "^2.3.4" 58 | }, 59 | "devDependencies": { 60 | "@babel/preset-env": "^7.23.8", 61 | "@rollup/plugin-node-resolve": "^15.2.3", 62 | "@rollup/plugin-replace": "^5.0.5", 63 | "alpinejs": "^3.13.4", 64 | "concurrently": "^8.2.2", 65 | "neostandard": "^0.12.1", 66 | "node-sass": "^9.0.0", 67 | "nodemon": "^3.0.3", 68 | "rollup": "^4.9.6", 69 | "rollup-plugin-copy": "^3.5.0", 70 | "terser": "^5.27.0" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # chaptertool 2 | 3 | Create and _convert_ chapters for podcasts, youtube, matroska, mkvmerge/nero/vorbis, webvtt, ffmpeginfo, ffmetadata, pyscenedetect, apple chapters, edl, podlove simple chapters (xml, json), apple hls chapters, mp4chaps and scenecut. 4 | 5 | The cli tools can automatically create chapters with images from videos using ffmpeg's scene detection. 6 | 7 | [Click here to open the web GUI](https://mtillmann.github.io/chaptertool) or go to 8 | the [full documentation on github.com](https://github.com/Mtillmann/chaptertool). 9 | 10 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import nodeResolve from '@rollup/plugin-node-resolve' 2 | import copy from 'rollup-plugin-copy' 3 | import replace from '@rollup/plugin-replace' 4 | 5 | export default { 6 | input: 'src/Frontend.js', 7 | output: { 8 | file: 'static/app.js', 9 | format: 'umd' 10 | }, 11 | plugins: [ 12 | nodeResolve({ 13 | browser: true, 14 | modulesOnly: true 15 | }), 16 | copy({ 17 | targets: [ 18 | { src: 'node_modules/bootstrap-icons/font/fonts', dest: 'static' } 19 | ] 20 | }), 21 | replace({ // this fixes the annoying popper bug 22 | preventAssignment: true, 23 | 'process.env.NODE_ENV': JSON.stringify('production') 24 | }) 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /scripts/empuggen.js: -------------------------------------------------------------------------------- 1 | import pug from 'pug' 2 | import { dirname, resolve, sep } from 'path' 3 | import { writeFileSync } from 'fs' 4 | 5 | const dir = dirname(process.argv[1]) 6 | 7 | writeFileSync(resolve(`${dir}${sep}..${sep}static${sep}index.html`), pug.renderFile(resolve(`${dir}${sep}..${sep}src${sep}views${sep}index.pug`))) 8 | -------------------------------------------------------------------------------- /scripts/writeversion.js: -------------------------------------------------------------------------------- 1 | import pckg from "../package.json" assert {type: 'json'}; 2 | import {dirname, resolve, sep} from 'path'; 3 | import {writeFileSync} from "fs"; 4 | 5 | const dir = dirname(process.argv[1]); 6 | writeFileSync(resolve(`${dir}${sep}..${sep}static${sep}version`), pckg.version); -------------------------------------------------------------------------------- /src/CLI/ArgumentParser.js: -------------------------------------------------------------------------------- 1 | import { existsSync, readFileSync } from 'fs' 2 | import { parse } from 'yaml' 3 | 4 | export class ArgumentParser { 5 | optionDefinition = { 6 | y: {}, 7 | n: {}, 8 | outputFormat: { 9 | default: 'chaptersjson' 10 | }, 11 | outputFolder: { 12 | default: '$filename_chapters' 13 | }, 14 | chapterTemplate: { 15 | default: 'Chapter $chapter of $total' 16 | }, 17 | sceneValue: { 18 | default: 0.1, cast: 'float' 19 | }, 20 | scale: { 21 | cast: 'int' 22 | }, 23 | forceDar: {}, 24 | crop: {}, 25 | useCrossfadeFix: {}, 26 | crossfadeFrames: { 27 | default: 120, 28 | cast: 'int' 29 | }, 30 | serveAfterRun: {}, 31 | serve: {}, 32 | generate: {}, 33 | port: { 34 | default: 8989, 35 | cast: 'int' 36 | }, 37 | silent: { 38 | default: false 39 | }, 40 | pretty: { 41 | default: false 42 | }, 43 | imgUri: { 44 | default: '' 45 | }, 46 | replace: {}, 47 | keepInfo: {}, 48 | dumpFfmpeg: {}, 49 | // config : {}, 50 | // config is special 51 | ffmpegBinary: { 52 | default: 'ffmpeg' 53 | }, 54 | ffprobeBinary: { 55 | default: 'ffprobe' 56 | }, 57 | extractAudio: {}, 58 | audioFilename: { 59 | default: '$filename.mp3' 60 | }, 61 | audioOptions: { 62 | default: '-q:a 0 -map a' 63 | }, 64 | audioCopyStream: { 65 | default: true 66 | }, 67 | audioOnly: {}, 68 | overwriteMode: { 69 | // this is special and set by either --y or --n. Setting it directly will have no effect 70 | default: '-n' 71 | }, 72 | inputChapters: {}, 73 | dumpOptions: {}, 74 | minChapterLength: { 75 | default: 10 76 | }, 77 | noEndTimes: { 78 | default: false 79 | }, 80 | outputFile: { 81 | default: false 82 | }, 83 | psdFramerate: { 84 | default: 23.976, 85 | cast: 'float' 86 | }, 87 | psdOmitTimecodes: { 88 | default: false 89 | }, 90 | acUseTextAttr: { 91 | default: false 92 | } 93 | } 94 | 95 | options = {} 96 | action = null 97 | 98 | constructor () { 99 | for (const arg in this.optionDefinition) { 100 | if ('default' in this.optionDefinition[arg]) { 101 | this.options[arg] = this.optionDefinition[arg].default 102 | } 103 | } 104 | 105 | for (const key in process.env) { 106 | if (key.slice(0, 3).toUpperCase() === 'CT_') { 107 | const actualKey = key.toLowerCase().slice(3).replace(/_(\w)/g, m => m[1].toUpperCase()) 108 | this.options[actualKey] = this.prepareValue(actualKey, process.env[key]) 109 | } 110 | } 111 | 112 | process.argv.forEach(arg => { 113 | if (arg.slice(0, 9) === '--config=') { 114 | const filename = arg.slice(9) 115 | if (existsSync(filename)) { 116 | const content = readFileSync(filename, 'utf-8') 117 | const parsed = parse(content) 118 | if (parsed && 'forEach' in parsed) { 119 | parsed.forEach(arg => { 120 | const { key, value, isOption } = this.keyAndValueFromOption(arg) 121 | if (isOption && key in this.optionDefinition) { 122 | this.options[key] = this.prepareValue(key, value) 123 | } 124 | }) 125 | } 126 | } 127 | } 128 | }) 129 | 130 | let expectsInput = false 131 | process.argv.forEach(arg => { 132 | const { key, value } = this.keyAndValueFromOption(arg) 133 | 134 | if (['serve', 'generate', 'convert'].includes(key)) { 135 | this.action = key 136 | this.options['@action'] = key 137 | if (['generate', 'convert'].includes(key)) { 138 | expectsInput = true 139 | } 140 | return true 141 | } 142 | 143 | if (expectsInput) { 144 | if (!existsSync(key)) { 145 | throw new Error(`input file ${key} doesn't exist`) 146 | } 147 | this.options.input = key 148 | expectsInput = false 149 | return 150 | } 151 | 152 | if (key in this.optionDefinition) { 153 | this.options[key] = this.prepareValue(key, value) 154 | } 155 | }) 156 | 157 | if ('y' in this.options && !('n' in this.options)) { 158 | this.options.overwriteMode = '-y' 159 | } 160 | if (!('y' in this.options) && 'n' in this.options) { 161 | this.options.overwriteMode = '-n' 162 | } 163 | 164 | if ('forceDar' in this.options) { 165 | this.options.scale = 'scale=\'max(iw,iw*sar)\':\'max(ih,ih/sar)\'' 166 | } 167 | 168 | if ('dumpOptions' in this.options) { 169 | console.log(this.options) 170 | } 171 | } 172 | 173 | keyAndValueFromOption (arg) { 174 | arg = arg.split('=') 175 | 176 | let key = arg.shift() 177 | const isOption = key.slice(0, 2) === '--' 178 | key = key.replace(/^--/, '') 179 | 180 | if (isOption) { 181 | key = key.replace(/-\w/g, m => m.slice(1).toUpperCase()) 182 | } 183 | 184 | const value = arg ? arg.join('=') : null 185 | return { key, value, isOption } 186 | } 187 | 188 | prepareValue (key, value) { 189 | if (this.optionDefinition[key]?.cast === 'float') { 190 | value = parseFloat(value) 191 | } 192 | if (this.optionDefinition[key]?.cast === 'int') { 193 | value = parseInt(value) 194 | } 195 | return value || true 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/CLI/AudioExtractor.js: -------------------------------------------------------------------------------- 1 | import { execSync, spawn } from 'child_process' 2 | import { basename, extname } from 'path' 3 | import { addSuffixToPath } from '../cli_util.js' 4 | 5 | export class AudioExtractor { 6 | options = null 7 | 8 | constructor (options) { 9 | this.options = options 10 | } 11 | 12 | extract () { 13 | if (!('extractAudio' in this.options) && !('audioOnly' in this.options)) { 14 | return true 15 | } 16 | 17 | if ('audioCopyStream' in this.options) { 18 | const ffProbeArgs = [`-i "${this.options.input}"`, '-v 0 -select_streams a -show_entries "stream=codec_name"'] 19 | const ffprobeCallString = [this.options.ffprobeBinary, ...ffProbeArgs].join(' ') 20 | const ffProbeOutput = execSync(ffprobeCallString, { shell: true }) 21 | if ('dumpFfmpeg' in this.options) { 22 | console.log(ffprobeCallString) 23 | } 24 | 25 | const codec = /codec_name=(.*)/.exec(ffProbeOutput.toString())[1] 26 | const codecToExtensionMap = { 27 | aac: 'm4a', 28 | mp3: 'mp3', 29 | opus: 'opus', 30 | vorbis: 'ogg', 31 | } 32 | 33 | if (!(codec in codecToExtensionMap)) { 34 | throw new Error(`no file extension for codec ${codec}`) 35 | } 36 | 37 | const fileExtension = codecToExtensionMap[codec] 38 | delete this.options.audioCopyStream 39 | this.options.audioFilename = this.options.audioFilename.replace(/\.[\w\d]{2,}$/, `.${fileExtension}`) 40 | this.options.audioOptions = '-map a -acodec copy' 41 | 42 | this.extract() 43 | } else { 44 | let filename = basename(this.options.input) 45 | const extension = extname(filename) 46 | filename = filename.replace(new RegExp(`${extension}$`), '') 47 | 48 | this.options.audioFilename = this.options.audioFilename.replace('$filename', filename) 49 | if (this.options.overwriteMode === '-n') { 50 | const outputExtension = extname(this.options.audioFilename) 51 | this.options.audioFilename = addSuffixToPath(this.options.audioFilename.replace(new RegExp(`${outputExtension}$`), ''), outputExtension) 52 | } 53 | 54 | const ffmpegArgs = [`-i "${this.options.input}"`, this.options.audioOptions, this.options.overwriteMode, `"${this.options.audioFilename}"`] 55 | 56 | if ('dumpFfmpeg' in this.options) { 57 | console.log(`${this.options.ffmpegBinary} ${ffmpegArgs.join(' ')}`) 58 | return true 59 | } 60 | 61 | const ffmpegCall = spawn(this.options.ffmpegBinary, ffmpegArgs, { shell: true }) 62 | 63 | if (!this.options.silent) { 64 | ffmpegCall.stdout.on('data', d => console.log(d.toString())) 65 | ffmpegCall.stderr.on('data', d => console.log(d.toString())) 66 | } 67 | 68 | return true 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/CLI/ChapterConverter.js: -------------------------------------------------------------------------------- 1 | import { lstatSync, readFileSync, writeFileSync } from 'fs' 2 | import { AutoFormat } from '@mtillmann/chapters' 3 | 4 | export class ChapterConverter { 5 | constructor (options) { 6 | const inputStats = lstatSync(options.input) 7 | if (inputStats.size > 1e6) { 8 | throw new Error('input filesize exceeds 1Mb') 9 | } 10 | 11 | const chapters = AutoFormat.from(readFileSync(options.input, 'utf-8')) 12 | if (options.imgUri) { 13 | chapters.applyImgUri(options.imgUri) 14 | } 15 | 16 | if (options.outputFile) { 17 | writeFileSync(options.outputFile, AutoFormat.as(options.outputFormat, chapters).toString(options.pretty, { 18 | imagePrefix: options.imgUri, 19 | writeEndTimes: !options.noEndTimes, 20 | psdFramerate: options.psdFramerate, 21 | psdOmitTimecodes: options.psdOmitTimecodes 22 | })) 23 | return 24 | } 25 | 26 | console.log(AutoFormat.as(options.outputFormat, chapters).toString(options.pretty, { 27 | imagePrefix: options.imgUri, 28 | writeEndTimes: !options.noEndTimes, 29 | psdFramerate: options.psdFramerate, 30 | psdOmitTimecodes: options.psdOmitTimecodes 31 | })) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/CLI/ChapterGenerator.js: -------------------------------------------------------------------------------- 1 | import { basename, extname, sep } from 'path' 2 | import { lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs' 3 | import { execSync, spawn } from 'child_process' 4 | import { secondsToTimestamp, zeroPad } from '../util.js' 5 | import { addSuffixToPath } from '../cli_util.js' 6 | import { FFMpegInfo, ChaptersJson, AutoFormat } from '@mtillmann/chapters' 7 | 8 | export class ChapterGenerator { 9 | options = null 10 | 11 | constructor (options) { 12 | this.options = options 13 | } 14 | 15 | infoTxtToChaptersJson (infoTxt) { 16 | const chapters = new FFMpegInfo(infoTxt) 17 | 18 | if (!this.options.noEndTimes) { 19 | if (chapters.chapters.at(-1)) { 20 | chapters.chapters.at(-1).endTime = this.getVideoLength() 21 | } else { 22 | chapters.addChapterAt(0) 23 | } 24 | chapters.bump() 25 | } 26 | 27 | const changes = chapters.applyChapterMinLength(this.options.minChapterLength) 28 | chapters.rebuildChapterTitles(this.options.chapterTemplate) 29 | Object.entries(changes).forEach(([index, newIndex]) => { 30 | index = parseInt(index) 31 | if (newIndex === 'deleted') { 32 | try { 33 | unlinkSync(`${this.options.outputFolder}/chapter_${zeroPad(index + 1, 5)}.jpg`) 34 | } catch (e) { 35 | // may fail when first is removed... 36 | } 37 | } else { 38 | newIndex = parseInt(newIndex) 39 | if (index === newIndex) { 40 | return 41 | } 42 | try { 43 | renameSync( 44 | `${this.options.outputFolder}/chapter_${zeroPad(index + 1, 5)}.jpg`, 45 | `${this.options.outputFolder}/chapter_${zeroPad(newIndex + 1, 5)}.jpg` 46 | ) 47 | } catch (e) { 48 | // might fail when no image is present 49 | } 50 | } 51 | }) 52 | 53 | chapters.chapters.forEach((chapter, i) => { 54 | chapters.chapters[i].img = `chapter_${zeroPad(i + 1, 5)}.jpg` 55 | }) 56 | 57 | if (this.options.imgUri) { 58 | chapters.applyImgUri(this.options.imgUri) 59 | } 60 | 61 | return chapters.to(ChaptersJson) 62 | } 63 | 64 | async generate () { 65 | if (this.options.audioOnly) { 66 | return true 67 | } 68 | 69 | let filename = basename(this.options.input) 70 | const extension = extname(filename) 71 | filename = filename.replace(new RegExp(`${extension}$`), '') 72 | this.options.outputFolder = this.options.outputFolder.replace('$filename', filename) 73 | 74 | this.options.outputFolder = addSuffixToPath(this.options.outputFolder) 75 | 76 | if (!('dumpFfmpeg' in this.options)) { 77 | mkdirSync(this.options.outputFolder) 78 | } 79 | 80 | if ('inputChapters' in this.options) { 81 | this.snapshotsFromChapters() 82 | return true 83 | } 84 | 85 | const ffmpegArgs = [`-i "${this.options.input}"`] 86 | 87 | const filters = [] 88 | 89 | if ('crop' in this.options) { 90 | filters.push(`crop=${this.options.crop}`) 91 | } 92 | if ('scale' in this.options) { 93 | if (typeof this.options.scale === 'number') { 94 | filters.push(`scale=${this.options.scale}:-2`) 95 | } else { 96 | filters.push(this.options.scale) 97 | } 98 | } 99 | 100 | if (this.options.useCrossfadeFix) { 101 | filters.push(`select='not(mod(n,${this.options.crossfadeFrames}))'`) 102 | } 103 | 104 | filters.push(`select='gt(scene,${this.options.sceneValue})'`) 105 | filters.push(`metadata=print:file=${this.options.outputFolder}/info.txt`) 106 | 107 | if (this.options.useCrossfadeFix) { 108 | ffmpegArgs.push(`-filter_complex "${filters.join(',')}"`) 109 | } else { 110 | ffmpegArgs.push(`-vf "${filters.join(',')}"`) 111 | } 112 | 113 | ffmpegArgs.push(this.options.overwriteMode) 114 | 115 | ffmpegArgs.push(`-vsync vfr "${this.options.outputFolder + sep}chapter_%05d.jpg"`) 116 | 117 | await this.ffmpegCall(ffmpegArgs, false, () => { 118 | const info = readFileSync(`${this.options.outputFolder}/info.txt`, 'utf-8') 119 | const chapters = AutoFormat.as(this.options.outputFormat, this.infoTxtToChaptersJson(info)) 120 | writeFileSync(`${this.options.outputFolder}/${chapters.filename}`, chapters.toString(this.options.pretty, { 121 | imagePrefix: this.options.imgUri, 122 | writeEndTimes: !this.options.noEndTimes 123 | })) 124 | if (!('keepInfo' in this.options)) { 125 | unlinkSync(`${this.options.outputFolder}/info.txt`) 126 | } 127 | }) 128 | } 129 | 130 | snapshotsFromChapters () { 131 | lstatSync(this.options.inputChapters) 132 | // todo make this accept any supported format 133 | const json = JSON.parse(readFileSync(this.options.inputChapters, 'utf-8')) 134 | 135 | const filters = [] 136 | 137 | if ('crop' in this.options) { 138 | filters.push(`crop=${this.options.crop}`) 139 | } 140 | if ('scale' in this.options) { 141 | if (typeof this.options.scale === 'number') { 142 | filters.push(`scale=${this.options.scale}:-2`) 143 | } else { 144 | filters.push(this.options.scale) 145 | } 146 | } 147 | 148 | json.chapters.forEach(chapter => { 149 | const ffmpegArgs = [`-ss ${secondsToTimestamp(chapter.startTime)}`, `-i "${this.options.input}"`] 150 | if (filters.length > 0) { 151 | ffmpegArgs.push(`-vf "${filters.join(',')}"`) 152 | } 153 | ffmpegArgs.push(`-vframes 1 "${this.options.outputFolder + sep}${basename(chapter.img)}"`) 154 | this.ffmpegCall(ffmpegArgs, true) 155 | }) 156 | } 157 | 158 | async ffmpegCall (args, sync = false, onExit) { 159 | if ('dumpFfmpeg' in this.options) { 160 | console.log(`${this.options.ffmpegBinary} ${args.join(' ')}`) 161 | return true 162 | } 163 | 164 | if (sync) { 165 | execSync(`${this.options.ffmpegBinary} ${args.join(' ')}`, { 166 | shell: true, 167 | [this.options.silent ? 'stdio' : 'whatever']: 'pipe' 168 | }) 169 | } else { 170 | const ffmpegCall = spawn(this.options.ffmpegBinary, args, { shell: true }) 171 | if (!this.options.silent) { 172 | ffmpegCall.stdout.on('data', d => console.log(d.toString())) 173 | ffmpegCall.stderr.on('data', d => console.log(d.toString())) 174 | } 175 | 176 | if (onExit) { 177 | ffmpegCall.on('exit', onExit) 178 | } 179 | } 180 | } 181 | 182 | getVideoLength () { 183 | const ffProbeArgs = ['-v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1', `-i "${this.options.input}"`] 184 | const ffprobeCallString = [this.options.ffprobeBinary, ...ffProbeArgs].join(' ') 185 | const ffProbeOutput = execSync(ffprobeCallString, { shell: true }) 186 | 187 | return parseFloat(ffProbeOutput.toString()) 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/Frontend.js: -------------------------------------------------------------------------------- 1 | import Alpine from 'alpinejs' 2 | import { Offcanvas, Toast, Tooltip } from 'bootstrap' 3 | import { ChaptersJson } from '@mtillmann/chapters' 4 | import ExportFeatures from './Frontend/ExportFeatures.js' 5 | import { FileHandler } from './Frontend/FileHandler.js' 6 | import ImportDialog from './Frontend/ImportDialog.js' 7 | import MediaFeatures from './Frontend/MediaFeatures.js' 8 | import MetaProperties from './Frontend/MetaProperties.js' 9 | import { SWInclude } from './Frontend/SWInclude.js' 10 | import { ShepherdTour } from './Frontend/ShepherdTour.js' 11 | import Timeline from './Frontend/Timeline.js' 12 | import { secondsToTimestamp, timestampToSeconds } from './util.js' 13 | import SettingsDialog from './Frontend/SettingsDialog.js' 14 | 15 | window.Alpine = Alpine 16 | 17 | SWInclude() 18 | 19 | window.GAIsDeployed = false 20 | window.deployGA = () => { 21 | if (window.GAIsDeployed) { 22 | return 23 | } 24 | 25 | const script = document.createElement('script'); 26 | [ 27 | ['async', true], 28 | ['src', `https://www.googletagmanager.com/gtag/js?id=${window.GACODE}`] 29 | ].forEach(([key, value]) => script.setAttribute(key, value)) 30 | 31 | gtag('config', window.GACODE) 32 | 33 | document.body.insertAdjacentElement('beforeend', script) 34 | window.GAIsDeployed = true 35 | } 36 | 37 | window.dataLayer = window.dataLayer || [] 38 | 39 | window.gtag = function () { 40 | window.dataLayer.push(arguments) 41 | } 42 | 43 | gtag('js', new Date()) 44 | gtag('set', { 45 | page_title: 'chaptertool' 46 | }) 47 | 48 | window.addEventListener('DOMContentLoaded', () => { 49 | document.documentElement.dataset.bsTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' 50 | window.timeline = new Timeline(3600, [], document.querySelector('.timeline')) 51 | Alpine.start() 52 | 53 | window.st = new ShepherdTour() 54 | 55 | fetch('ga-code') 56 | .then(e => { 57 | if (e.ok) { 58 | return e.text() 59 | } else { 60 | console.log('No Analytics Code found. Place a file called "ga-code" in the root of the webserver (/static).') 61 | } 62 | }) 63 | .then(code => { 64 | window.GACODE = code 65 | if (!localStorage.getItem('ct-analytics-state')) { 66 | (new Offcanvas(document.querySelector('#analyticsDialog'), { 67 | keyboard: false, 68 | backdrop: 'static' 69 | })).show() 70 | } 71 | if (localStorage.getItem('ct-analytics-state') === 'enabled') { 72 | window.deployGA() 73 | window.st.show() 74 | } 75 | }) 76 | }) 77 | 78 | window.APP = { 79 | ...{ 80 | chapters: [], 81 | data: new ChaptersJson(), 82 | editTimestampLabel: '', 83 | editTimestampTimestamp: [0, 0], 84 | editTimestampCallback: null, 85 | editTimestampBounds: { min: 0, max: '10:10:10' }, 86 | editTimestampChapter: 0, 87 | currentChapterIndex: null, 88 | fileHandler: false, 89 | editTab: 'info', 90 | chapterBelowIndex: false, 91 | chapterLock: true, 92 | offcanvasNavi: null, 93 | analyticsEnabled: false, 94 | analyticsIsAvailable: false, 95 | versionString: '', 96 | usesAMPM: false, 97 | 98 | init () { 99 | this.usesAMPM = /[A|P]M$/.test(new Intl.DateTimeFormat(undefined, { hour: 'numeric' }).format()) 100 | 101 | fetch('version').then(r => r.text()) 102 | .then(version => this.versionString = `Version ${version}`) 103 | 104 | this.offcanvasNavi = new Offcanvas(this.$refs.navi) 105 | this.$refs.navi.addEventListener('show.bs.offcanvas', () => { 106 | gtag('event', 'ui', { action: 'navi', mode: 'show' }) 107 | }) 108 | 109 | this.tooltip = new Tooltip(document.body, { 110 | selector: '.has-tooltip', 111 | animation: false, 112 | // placement: 'aut', 113 | trigger: 'hover', 114 | html: true, 115 | customClass: 'small' 116 | }) 117 | 118 | this.analyticsEnabled = localStorage.getItem('ct-analytics-state') === 'enabled' 119 | 120 | setTimeout(() => { 121 | this.analyticsIsAvailable = !!window.GACODE 122 | }, 1000) 123 | 124 | this.fileHandler = new FileHandler() 125 | 126 | this.timestampOffcanvas = new Offcanvas(this.$refs.timestampedit) 127 | this.$refs.timestampedit.addEventListener('shown.bs.offcanvas', () => { 128 | this.$refs.timestampedit.querySelector('[type=time]').focus() 129 | }) 130 | 131 | this.$refs.timestampedit.querySelector('form').addEventListener('submit', e => { 132 | e.preventDefault() 133 | this.editTimestampCallback(Array.from(e.target.querySelectorAll('input')).map(i => i.value).join('.')) 134 | this.timestampOffcanvas.hide() 135 | }) 136 | 137 | window.addEventListener('timeline:add', e => { 138 | this.addChapterAtTime(e.detail.startTime, {}, 'timeline') 139 | }) 140 | 141 | window.addEventListener('timeline:move', e => { 142 | this.updateChapterStartTime(parseInt(e.detail.index), secondsToTimestamp(e.detail.startTime), true, 'dragdrop') 143 | }) 144 | 145 | window.addEventListener('timeline:scrollintoview', e => { 146 | this.editChapter(e.detail.index) 147 | }) 148 | 149 | window.addEventListener('dragndrop:video', e => { 150 | if (this.data.chapters.length > 0 || this.hasVideo || this.hasAudio) { 151 | this.showImportDialog({ 152 | mode: 'video', 153 | video: e.detail.video, 154 | name: e.detail.name 155 | }) 156 | return 157 | } 158 | 159 | this.attachVideo(e.detail.video) 160 | }) 161 | 162 | window.addEventListener('dragndrop:audio', e => { 163 | this.attachAudio(e.detail.audio) 164 | }) 165 | 166 | window.addEventListener('generic:reset', () => { 167 | this.reset() 168 | }) 169 | 170 | window.addEventListener('dragndrop:image', e => { 171 | if (this.currentChapterIndex !== null) { 172 | this.data.chapters[this.currentChapterIndex].img_type = e.detail.type || 'blob' 173 | this.data.chapters[this.currentChapterIndex].img = e.detail.image 174 | this.data.chapters[this.currentChapterIndex].img_filename = e.detail.name 175 | 176 | this.getImageInfo(this.currentChapterIndex) 177 | } 178 | }) 179 | 180 | window.addEventListener('timeline:marker-set', e => { 181 | if (!this.chapterLock) { 182 | return 183 | } 184 | 185 | const index = this.data.chapterIndexFromTime(e.detail.time) 186 | if (index !== false) { 187 | this.editChapter(index) 188 | } else { 189 | this.closeChapter() 190 | } 191 | }) 192 | 193 | window.addEventListener('dragndrop:jsonfail', () => { 194 | this.toast('file could not be processed :/') 195 | }) 196 | 197 | window.addEventListener('dragndrop:json', e => { 198 | if (this.data.chapters.length > 0 || this.hasVideo || this.hasAudio) { 199 | this.showImportDialog({ 200 | mode: 'data', 201 | data: e.detail.data, 202 | name: e.detail.name 203 | }) 204 | return 205 | } 206 | 207 | this.newProject(e.detail.data) 208 | }) 209 | 210 | this.initExportDialog() 211 | this.initMetaPropertiesDialog() 212 | this.initImportDialog() 213 | this.initSettingsDialog() 214 | }, 215 | 216 | toggleGA (state /* undefined|enabled|disabled */) { 217 | let hasState = true 218 | const currentState = localStorage.getItem('ct-analytics-state') 219 | if (!currentState) { 220 | hasState = false 221 | window.st.show() 222 | } 223 | if (!state) { 224 | state = currentState === 'disabled' ? 'enabled' : 'disabled' 225 | } 226 | 227 | localStorage.setItem('ct-analytics-state', state) 228 | this.analyticsEnabled = state === 'enabled' 229 | 230 | if (state === 'enabled') { 231 | window.deployGA() 232 | } 233 | 234 | Offcanvas.getInstance(document.querySelector('#analyticsDialog'))?.hide() 235 | 236 | if (!hasState) { 237 | this.toast(`analytics ${state}`) 238 | } else { 239 | this.toast(`analytics ${state} - reload page for it to take effect`) 240 | } 241 | }, 242 | 243 | askForNewProject () { 244 | if (this.data.chapters.length > 0 && !confirm('discard current project?')) { 245 | gtag('event', 'ui', { action: 'askForNew', answer: 'reject' }) 246 | return 247 | } 248 | gtag('event', 'ui', { action: 'askForNew', answer: 'confirm' }) 249 | this.newProject() 250 | }, 251 | 252 | newProject (data) { 253 | gtag('event', 'ui', { action: 'newproject' }) 254 | this.reset() 255 | this.$nextTick(() => { 256 | this.data = data || new ChaptersJson() 257 | this.updateTimeline() 258 | this.importModal.hide() 259 | }) 260 | }, 261 | 262 | scrollChapterIntoView (index = 0) { 263 | this.$refs.chapterList.querySelectorAll('.list-chapter')?.[index]?.scrollIntoView({ block: 'center' }) 264 | }, 265 | 266 | editChapter (index) { 267 | this.$nextTick(() => { 268 | index = index ?? 0 269 | this.scrollChapterIntoView(index) 270 | this.currentChapterIndex = index 271 | window.timeline.setActive(index) 272 | }) 273 | }, 274 | 275 | toast (message, options = {}) { 276 | [...this.$refs.toasts.querySelectorAll('.toast.show')].slice(1).forEach(node => { 277 | node.classList.remove('show') 278 | node.classList.add('hide') 279 | }) 280 | 281 | this.$refs.toasts.insertAdjacentHTML('afterbegin', ` 282 | 285 | `); 286 | (new Toast(this.$refs.toasts.querySelector('.toast'), { 287 | ...{ 288 | delay: 1666 289 | }, 290 | ...options 291 | })).show() 292 | }, 293 | 294 | changeDuration () { 295 | this.editTimestamp( 296 | 'Edit Duration', 297 | this.data.duration, 298 | { 299 | max: '23:59:59', 300 | min: secondsToTimestamp(this.data.chapters.at(-1) ? this.data.chapters.at(-1).startTime : 0).slice(0, 8) 301 | }, 302 | (newTimestamp) => { 303 | gtag('event', 'meta', { action: 'durationChange' }) 304 | this.data.duration = timestampToSeconds(newTimestamp) 305 | this.data.bump(true) 306 | this.updateTimeline() 307 | } 308 | ) 309 | }, 310 | editStartTime (chapterIndex) { 311 | this.editTimestampChapter = chapterIndex 312 | this.editTimestamp( 313 | `Set chapter ${chapterIndex + 1} startTime`, 314 | this.data.chapters[chapterIndex].startTime, 315 | { max: '23:59:59', min: 0 }, 316 | newTimestamp => this.updateChapterStartTime(this.editTimestampChapter, newTimestamp, false) 317 | ) 318 | }, 319 | 320 | updateChapterStartTime (index, startTime, forceEdit = false, origin = 'dialog') { 321 | gtag('event', 'chapter', { action: 'startTimeChange', origin }) 322 | 323 | const result = this.data.updateChapterStartTime(index, startTime) 324 | if (result === 'timeInUse') { 325 | this.toast('Given start time already in use') 326 | return 327 | } 328 | this.updateTimeline() 329 | const newIndex = this.data.chapterIndexFromStartTime(result) 330 | if (forceEdit) { 331 | this.editChapter(newIndex) 332 | } else { 333 | if (this.currentChapterIndex && this.currentChapterIndex === index && newIndex !== index) { 334 | this.editChapter(newIndex) 335 | } 336 | } 337 | if (newIndex !== index) { 338 | this.toast(`moved chapter ${index + 1} to posiiton ${newIndex + 1}, and set start time to ${startTime}`) 339 | } else { 340 | this.toast(`changed chapter #${index + 1} start time to ${startTime}`) 341 | } 342 | }, 343 | 344 | chapterImage (index) { 345 | if (!this.data.chapters[index] || !this.data.chapters[index].img) { 346 | return false 347 | } 348 | 349 | try { 350 | new URL(this.data.chapters[index].img) 351 | } catch (e) { 352 | return false 353 | } 354 | 355 | return this.data.chapters[index].img 356 | }, 357 | 358 | deleteChapter (index) { 359 | this.currentChapterIndex = null 360 | gtag('event', 'chapter', { action: 'delete' }) 361 | this.$nextTick(() => { 362 | this.data.remove(index) 363 | this.updateTimeline() 364 | document.querySelector('.tooltip')?.remove() 365 | this.toast(`deleted chapter #${index + 1}`) 366 | }) 367 | }, 368 | 369 | addChapterAtTime (startTime, options = {}, origin) { 370 | gtag('event', 'chapter', { action: 'add', where: 'atTime', origin }) 371 | const chapter = {} 372 | if (options.title?.length > 0) { 373 | chapter.title = options.title 374 | } 375 | 376 | if ('image' in options) { 377 | chapter.img = options.image 378 | chapter.img_type = 'blob' 379 | chapter.img_filename = new URL(options.image).pathname + '.jpg' 380 | } 381 | 382 | const result = this.data.addChapterAtTime(startTime, chapter) 383 | if (!result) { 384 | this.toast(`a chapter already exists at ${secondsToTimestamp(startTime)}`) 385 | return 386 | } 387 | 388 | this.$nextTick(() => { 389 | this.updateTimeline() 390 | this.currentChapterIndex = this.data.chapterIndexFromStartTime(startTime) 391 | this.editChapter(this.currentChapterIndex) 392 | 393 | if (!('image' in options) && this.hasVideo) { 394 | // this depends on currentChapterIndex being set by editChapter 395 | this.fetchVideoSnapshot(startTime) 396 | } 397 | 398 | this.toast(`added chapter at ${secondsToTimestamp(startTime)}`) 399 | }) 400 | }, 401 | 402 | addChapter (index) { 403 | if (index === 0 && this.data.chapters[0] && this.data.chapters[0].startTime === 0) { 404 | this.toast(`a chapter already exists at ${secondsToTimestamp(0)}`) 405 | return 406 | } 407 | 408 | gtag('event', 'chapter', { action: 'add', where: 'atIndex' }) 409 | const startTime = this.data.addChapterAt(index) 410 | this.updateTimeline() 411 | this.toast(`added chapter at position ${index + 1} (${secondsToTimestamp(startTime)})`) 412 | 413 | this.$nextTick(() => { 414 | this.editChapter(index === Infinity ? this.data.chapters.length - 1 : index) 415 | if (this.hasVideo) { 416 | // this depends on currentChapterIndex being set by editChapter 417 | this.fetchVideoSnapshot(startTime) 418 | } 419 | }) 420 | }, 421 | 422 | // wraps util feature to expose to alpine template 423 | secondsToTimestamp (seconds) { 424 | return secondsToTimestamp(seconds) 425 | }, 426 | 427 | updateTimeline () { 428 | this.fileHandler.editorHasProject = this.data.chapters.length > 0 429 | window.timeline.setDuration(this.data.duration) 430 | window.timeline.setChapters(JSON.parse(JSON.stringify(this.data.chapters))) 431 | }, 432 | 433 | editTimestamp (label, timestamp, bounds, callback) { 434 | if (parseFloat(timestamp) === timestamp) { 435 | timestamp = secondsToTimestamp(timestamp, { milliseconds: true }) 436 | } 437 | 438 | this.editTimestampLabel = label 439 | this.editTimestampBounds = bounds 440 | this.editTimestampTimestamp = timestamp.split('.') 441 | 442 | this.editTimestampCallback = callback 443 | this.timestampOffcanvas.show() 444 | }, 445 | 446 | reset () { 447 | this.offcanvasNavi.hide() 448 | this.metaPropertiesDialog.hide() 449 | this.exportOffcanvas.hide() 450 | this.timestampOffcanvas.hide() 451 | this.importModal.hide() 452 | 453 | this.data.chapters.forEach(chapter => { 454 | if (chapter.img && chapter.img.slice(0, 5) === 'blob:') { 455 | URL.revokeObjectURL(chapter.img) 456 | } 457 | }) 458 | 459 | document.querySelectorAll('[src^=blob]').forEach(node => { 460 | console.log('revoking url...') 461 | URL.revokeObjectURL(node.getAttribute('src')) 462 | }) 463 | 464 | this.actualMediaDuration = null 465 | this.chapterLock = true 466 | this.currentChapterIndex = null 467 | this.data = new ChaptersJson() 468 | this.hasVideo = false 469 | this.hasAudio = false 470 | this.mediaIsCollapsed = false 471 | this.fileHandler.editorHasProject = false 472 | window.timeline.setMarkerAt(0) 473 | window.timeline.node.classList.remove('clicked') 474 | }, 475 | 476 | closeChapter () { 477 | gtag('event', 'chapter', { action: 'close' }) 478 | this.$nextTick(() => { 479 | this.currentChapterIndex = null 480 | window.timeline.setActive(false) 481 | }) 482 | }, 483 | 484 | expandFirstToStart () { 485 | gtag('event', 'chapter', { action: 'startTimeChange', origin: 'expand' }) 486 | this.data.expandFirstToStart() 487 | this.updateTimeline() 488 | }, 489 | 490 | addChapterFromTime () { 491 | this.editTimestamp( 492 | 'Add chapter at time', 493 | this.data.duration * 0.5, 494 | { max: '23:59:59', min: 0 }, 495 | newTimestamp => this.addChapterAtTime(timestampToSeconds(newTimestamp), {}, 'dialog') 496 | ) 497 | }, 498 | adaptDuration () { 499 | gtag('event', 'meta', { action: 'adaptDuration' }) 500 | this.data.duration = this.actualMediaDuration 501 | this.data.bump(true) 502 | this.updateTimeline() 503 | this.toast(`duration set to (${secondsToTimestamp(this.actualMediaDuration)})`) 504 | this.actualMediaDuration = null 505 | }, 506 | toggleChapterLock () { 507 | this.chapterLock = !this.chapterLock 508 | gtag('event', 'ui', { action: 'toggleChapterLock', mode: this.chapterLock ? 'locked' : 'unlocked' }) 509 | }, 510 | showTourAgain () { 511 | if (this.data.chapters.length === 0 || (this.data.chapters.length > 0 && confirm('abandon current project?'))) { 512 | const url = new URL(window.location) 513 | url.hash = 'show-tour' 514 | window.location = url.toString() 515 | location.reload() 516 | } 517 | } 518 | }, 519 | ...MediaFeatures, 520 | ...ExportFeatures, 521 | ...MetaProperties, 522 | ...ImportDialog, 523 | ...SettingsDialog 524 | } 525 | -------------------------------------------------------------------------------- /src/Frontend/ExportFeatures.js: -------------------------------------------------------------------------------- 1 | import * as zip from '@zip.js/zip.js' 2 | import { Offcanvas } from 'bootstrap' 3 | import { AutoFormat } from '@mtillmann/chapters' 4 | 5 | export default { 6 | exportOffcanvas: null, 7 | exportSettings: { 8 | type: 'chaptersjson', 9 | supportsPretty: false, 10 | pretty: true, 11 | hasImages: false, 12 | canUseImagePrefix: false, 13 | imagePrefix: '', 14 | writeRedundantToc: false, 15 | writeEndTimes: false, 16 | psdFramerate: 23.976, 17 | psdOmitTimecodes: false, 18 | acUseTextAttr: false, 19 | miscTextType: 'spotifya' 20 | }, 21 | exportContent: '', 22 | exportData: null, 23 | miscTextTypes: [ 24 | 'spotifya', 25 | 'spotifyb', 26 | 'podcastpage', 27 | 'transistorfm', 28 | 'podigeetext', 29 | 'shownotes' 30 | ], 31 | initExportDialog () { 32 | for (const type of this.miscTextTypes) { 33 | if (this.selectedFormats.includes(type)) { 34 | this.exportSettings.miscTextType = type 35 | break 36 | } 37 | } 38 | 39 | this.exportOffcanvas = new Offcanvas(this.$refs.exportDialog) 40 | this.$refs.exportDialog.addEventListener('show.bs.offcanvas', () => { 41 | this.updateExportContent() 42 | }) 43 | }, 44 | updateExportContent (type) { 45 | if (type) { 46 | this.exportSettings.type = type 47 | } 48 | 49 | const actualType = this.exportSettings.type === 'misctext' ? this.exportSettings.miscTextType : this.exportSettings.type 50 | 51 | this.data.ensureUniqueFilenames() 52 | this.exportData = AutoFormat.as(actualType, this.data) 53 | this.exportSettings.hasImages = this.data.chapters.some(item => item.img && item.img_type === 'blob') 54 | this.exportSettings.canUseImagePrefix = this.data.chapters.some(item => item.img && ['blob', 'relative'].includes('blob')) 55 | 56 | this.exportSettings.supportsPretty = this.exportData.supportsPrettyPrint 57 | this.exportContent = this.exportData.toString(this.exportSettings.pretty, { 58 | imagePrefix: this.exportSettings.imagePrefix, 59 | writeRedundantToc: this.exportSettings.writeRedundantToc, 60 | writeEndTimes: this.exportSettings.writeEndTimes, 61 | psdFramerate: this.exportSettings.psdFramerate, 62 | psdOmitTimecodes: this.exportSettings.psdOmitTimecodes, 63 | acUseTextAttr: this.exportSettings.acUseTextAttr 64 | }) 65 | }, 66 | 67 | download () { 68 | gtag('event', 'ui', { action: 'download', format: this.exportData.constructor.name }) 69 | 70 | this.triggerDownload(({ 71 | url: URL.createObjectURL(new Blob([this.exportContent], { type: this.exportData.mimeType })), 72 | name: this.exportData.filename 73 | })) 74 | }, 75 | 76 | triggerDownload (options) { 77 | const a = document.createElement('a') 78 | a.setAttribute('href', options.url) 79 | a.setAttribute('download', options.name) 80 | a.click() 81 | }, 82 | 83 | copyToClipboard () { 84 | this.$refs.outputTextarea.select() 85 | document.execCommand('copy') 86 | window.getSelection()?.removeAllRanges() 87 | 88 | gtag('event', 'ui', { action: 'copy', format: this.exportData.constructor.name }) 89 | 90 | this.toast('copied to clipboard') 91 | }, 92 | 93 | async downloadZip () { 94 | gtag('event', 'ui', { action: 'downloadZip', format: this.exportData.constructor.name }) 95 | 96 | let zipWriter = new zip.ZipWriter(new zip.BlobWriter('application/zip'), { bufferedWrite: true }) 97 | 98 | await zipWriter.add('chapters.json', new zip.BlobReader(new Blob([this.exportContent], { type: 'text/plain' })), { level: 0 }) 99 | 100 | for (const chapter of this.data.chapters) { 101 | const response = await fetch(chapter.img) 102 | const blob = await response.blob() 103 | 104 | await zipWriter.add(chapter.img_filename, new zip.BlobReader(blob), { level: 0 }) 105 | } 106 | 107 | const closed = await zipWriter.close() 108 | this.triggerDownload(({ 109 | url: URL.createObjectURL(closed), name: 'chapters.zip' 110 | })) 111 | 112 | zipWriter = null 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Frontend/FileHandler.js: -------------------------------------------------------------------------------- 1 | import { AutoFormat, ChaptersJson } from '@mtillmann/chapters' 2 | 3 | export class FileHandler { 4 | editorHasProject = false 5 | 6 | constructor () { 7 | document.documentElement.addEventListener('paste', e => { 8 | if (e.target.matches('input')) { 9 | return 10 | } 11 | 12 | const text = (e.clipboardData || window.clipboardData).getData('text') 13 | const files = [...(e.clipboardData || e.originalEvent.clipboardData).items] 14 | .filter(item => item.kind === 'file') 15 | .map(item => item.getAsFile()) 16 | 17 | if (files[0]) { 18 | return this.handleFile(files[0], 'paste') 19 | } 20 | 21 | try { 22 | const url = new URL(text) 23 | if (/(jpg|png|jpeg|webm|gif)$/.test(url.pathname)) { 24 | gtag('event', 'ui', { action: 'external', origin: 'paste', type: 'image-url' }) 25 | return window.dispatchEvent(new CustomEvent('dragndrop:image', { 26 | detail: { 27 | image: url.toString(), 28 | type: 'absolute', 29 | name: url.toString() 30 | } 31 | })) 32 | } 33 | } catch (e) { 34 | // do nothing 35 | } 36 | 37 | try { 38 | const detected = AutoFormat.from(text) 39 | const data = new ChaptersJson(detected) 40 | gtag('event', 'ui', { 41 | action: 'external', 42 | origin: 'paste', 43 | type: 'data', 44 | format: detected.constructor.name 45 | }) 46 | return window.dispatchEvent(new CustomEvent('dragndrop:json', { 47 | detail: { 48 | data, 49 | name: 'clipboard paste' 50 | } 51 | })) 52 | } catch (e) { 53 | return window.dispatchEvent(new CustomEvent('dragndrop:jsonfail', { detail: {} })) 54 | } 55 | }) 56 | 57 | document.getElementById('app').addEventListener('dragover', e => { 58 | e.preventDefault() 59 | }) 60 | 61 | document.getElementById('app').addEventListener('drop', e => { 62 | // Prevent default behavior (Prevent file from being opened) 63 | e.preventDefault() 64 | 65 | if (e.dataTransfer.items) { 66 | // Use DataTransferItemList interface to access the file(s) 67 | [...e.dataTransfer.items].forEach((item, i) => { 68 | if (item.kind === 'file' && i === 0) { 69 | const file = item.getAsFile() 70 | this.handleFile(file, 'dragdrop') 71 | } else if (item.kind === 'file' && i > 1) { 72 | window.dispatchEvent(new CustomEvent('dragndrop:ignoredfile', { detail: { filename: '...' } })) 73 | } 74 | }) 75 | } else { 76 | [...e.dataTransfer.files].forEach((file, i) => { 77 | if (i === 0) { 78 | return this.handleFile(file, 'dragdrop') 79 | } 80 | window.dispatchEvent(new CustomEvent('dragndrop:ignoredfile', { detail: { filename: file.name } })) 81 | }) 82 | } 83 | }) 84 | } 85 | 86 | askForNewProject () { 87 | if (!this.editorHasProject) { 88 | return true 89 | } 90 | return confirm('Do you want to discard the current project and start a new one?') 91 | } 92 | 93 | handleFile (file, origin = 'osDialog') { 94 | if (['text/plain', 'text/xml', 'application/json', 'text/csv', 'text/vtt'].includes(file.type)) { 95 | fetch(URL.createObjectURL(file)) 96 | .then(r => r.text()) 97 | .then(text => { 98 | try { 99 | const detected = AutoFormat.from(text) 100 | 101 | const data = ChaptersJson.create(detected) 102 | 103 | gtag('event', 'ui', { 104 | action: 'external', 105 | origin, 106 | type: 'data', 107 | format: detected.constructor.name 108 | }) 109 | return window.dispatchEvent(new CustomEvent('dragndrop:json', { 110 | detail: { 111 | data, 112 | name: file.name 113 | } 114 | })) 115 | } catch (e) { 116 | // do nothing 117 | return window.dispatchEvent(new CustomEvent('dragndrop:jsonfail', { detail: {} })) 118 | } 119 | }) 120 | } 121 | 122 | if (file.type.slice(0, 5) === 'video') { 123 | gtag('event', 'ui', { action: 'external', origin, type: 'video' }) 124 | 125 | window.dispatchEvent(new CustomEvent('dragndrop:video', { 126 | detail: { 127 | video: URL.createObjectURL(file), 128 | name: file.name 129 | } 130 | })) 131 | return 132 | } 133 | 134 | if (file.type.slice(0, 5) === 'audio' && this.askForNewProject()) { 135 | gtag('event', 'ui', { action: 'external', origin, type: 'audio' }) 136 | 137 | window.dispatchEvent(new CustomEvent('dragndrop:audio', { 138 | detail: { 139 | audio: URL.createObjectURL(file), 140 | name: file.name 141 | } 142 | })) 143 | return 144 | } 145 | 146 | if (file.type.slice(0, 5) === 'image') { 147 | gtag('event', 'ui', { action: 'external', origin, type: 'image' }) 148 | 149 | window.dispatchEvent(new CustomEvent('dragndrop:image', { 150 | detail: { 151 | image: URL.createObjectURL(file), 152 | name: file.name 153 | } 154 | })) 155 | return 156 | } 157 | 158 | window.dispatchEvent(new CustomEvent('dragndrop:ignoredfile', { detail: { filename: file.name } })) 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/Frontend/ImportDialog.js: -------------------------------------------------------------------------------- 1 | import { Modal } from 'bootstrap' 2 | 3 | export default { 4 | 5 | importState: { 6 | mode: null 7 | }, 8 | importModal: null, 9 | initImportDialog () { 10 | this.importModal = new Modal(this.$refs.importDialog) 11 | }, 12 | showImportDialog (state) { 13 | this.importState = state 14 | this.importModal.show() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Frontend/MediaFeatures.js: -------------------------------------------------------------------------------- 1 | import { formatBytes } from '../util.js' 2 | 3 | export default { 4 | videoHandlersAttached: false, 5 | audioHandlersAttached: false, 6 | hasVideo: false, 7 | hasAudio: false, 8 | insertFrameOnSeek: false, 9 | ignoreNextSeekEvent: false, 10 | mediaIsCollapsed: false, 11 | actualMediaDuration: null, 12 | 13 | getVideoCanvas (callback) { 14 | const canvas = document.createElement('canvas') 15 | canvas.setAttribute('width', this.$refs.video.videoWidth) 16 | canvas.setAttribute('height', this.$refs.video.videoHeight) 17 | const context = canvas.getContext('2d') 18 | 19 | context.drawImage(this.$refs.video, 0, 0) 20 | canvas.toBlob(blob => { 21 | callback(URL.createObjectURL(blob)) 22 | }) 23 | }, 24 | 25 | attachVideo (video, keepChapters = false) { 26 | if (!keepChapters) { 27 | this.reset() 28 | } 29 | 30 | this.importModal.hide() 31 | 32 | if (!this.videoHandlersAttached) { 33 | this.videoHandlersAttached = true 34 | this.$refs.video.addEventListener('loadedmetadata', e => { 35 | const videoDuration = e.target.duration 36 | if (keepChapters) { 37 | this.actualMediaDuration = videoDuration 38 | console.log(this.actualMediaDuration) 39 | } else { 40 | this.data.duration = videoDuration 41 | this.actualMediaDuration = null 42 | } 43 | this.currentChapterIndex = null 44 | this.data.bump() 45 | this.updateTimeline() 46 | }) 47 | 48 | this.$refs.video.addEventListener('seeked', e => { 49 | if (this.insertFrameOnSeek) { 50 | this.addImageFromVideoToChapter() 51 | this.insertFrameOnSeek = false 52 | 53 | if (this.$refs.video.dataset.returnToTime) { 54 | this.ignoreNextSeekEvent = true 55 | const seekTo = parseFloat(this.$refs.video.dataset.returnToTime) 56 | delete this.$refs.video.dataset.returnToTime 57 | this.$refs.video.currentTime = seekTo 58 | } 59 | 60 | if (this.$refs.video.dataset.resumeOnSeek === 'true') { 61 | this.$refs.video.play() 62 | delete this.$refs.video.dataset.resumeOnSeek 63 | } 64 | } else { 65 | if (this.ignoreNextSeekEvent) { 66 | this.ignoreNextSeekEvent = false 67 | } else { 68 | window.timeline.setMarkerAt(e.target.currentTime) 69 | } 70 | } 71 | }) 72 | 73 | window.addEventListener('timeline:marker-set', e => { 74 | this.ignoreNextSeekEvent = true 75 | this.$refs.video.currentTime = e.detail.time 76 | }) 77 | } 78 | 79 | this.hasVideo = true 80 | this.mediaIsCollapsed = false 81 | this.$refs.video.setAttribute('src', video) 82 | this.$refs.video.play() 83 | }, 84 | 85 | fetchVideoSnapshot (startTime = false) { 86 | if (startTime === false) { 87 | startTime = this.$refs.video.currentTime 88 | } 89 | this.insertFrameOnSeek = true 90 | if (startTime !== this.$refs.video.currentTime) { 91 | this.$refs.video.dataset.returnToTime = this.$refs.video.currentTime 92 | } 93 | 94 | if (this.$refs.video.paused === false) { 95 | this.$refs.video.dataset.resumeOnSeek = 'true' 96 | this.$refs.video.pause() 97 | } 98 | this.$refs.video.currentTime = startTime 99 | }, 100 | 101 | addImageFromVideoToChapter (index) { 102 | index = index || this.currentChapterIndex 103 | 104 | gtag('event', 'chapter', { action: 'videoStillToChapter' }) 105 | 106 | this.getVideoCanvas(url => { 107 | this.data.chapters[index].img = url 108 | this.data.chapters[index].img_type = 'blob' 109 | this.data.chapters[index].img_filename = (new URL(url.slice(5)).pathname).slice(1) + '.png' 110 | this.getImageInfo(index) 111 | }) 112 | }, 113 | 114 | attachAudio (audio) { 115 | this.reset() 116 | if (!this.audioHandlersAttached) { 117 | this.audioHandlersAttached = true 118 | this.$refs.audio.addEventListener('loadedmetadata', e => { 119 | this.data.duration = e.target.duration 120 | this.updateTimeline() 121 | }) 122 | } 123 | this.hasAudio = true 124 | this.$refs.audio.setAttribute('src', audio) 125 | this.$refs.audio.play() 126 | }, 127 | 128 | deleteImage (index) { 129 | if (this.data.chapters[index].img.slice(0, 5) === 'blob:') { 130 | URL.revokeObjectURL(this.data.chapters[index].img) 131 | } 132 | 133 | gtag('event', 'chapter', { action: 'removeImage' }) 134 | 135 | delete this.data.chapters[index].img 136 | delete this.data.chapters[index].img_type 137 | delete this.data.chapters[index].img_filename 138 | }, 139 | 140 | getImageInfo (index) { 141 | const img = document.createElement('img') 142 | img.dataset.index = index 143 | img.addEventListener('load', e => { 144 | this.data.chapters[e.target.dataset.index].img_dims = `${e.target.naturalWidth}x${e.target.naturalHeight}` 145 | }) 146 | img.setAttribute('src', this.data.chapters[index].img) 147 | 148 | const initObject = { index } 149 | fetch(this.data.chapters[index].img, initObject) 150 | .then(((initObject) => { 151 | return r => { 152 | const l = r.headers.get('content-length') 153 | this.data.chapters[initObject.index].img_size = formatBytes(l) + ` (${l} Bytes)` 154 | } 155 | })(initObject)) 156 | }, 157 | toggleMedia () { 158 | this.mediaIsCollapsed = !this.mediaIsCollapsed 159 | gtag('event', 'ui', { action: 'mediatoggle', mode: this.mediaIsCollapsed ? 'collapsed' : 'visible' }) 160 | this.$refs.video.pause() 161 | } 162 | 163 | } 164 | -------------------------------------------------------------------------------- /src/Frontend/MetaProperties.js: -------------------------------------------------------------------------------- 1 | import { Offcanvas } from 'bootstrap' 2 | 3 | export default { 4 | 5 | metaPropertiesDialog: null, 6 | initMetaPropertiesDialog () { 7 | this.metaPropertiesDialog = new Offcanvas(this.$refs.metaPropertiesDialog) 8 | this.$refs.metaPropertiesDialog.addEventListener('shown.bs.offcanvas', () => { 9 | gtag('event', 'meta', { action: 'attributeDialog' }) 10 | }) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Frontend/SWInclude.js: -------------------------------------------------------------------------------- 1 | export function SWInclude () { 2 | if (/localhost/.test(new URL(window.location).host)) { 3 | console.log('skipping service worker for localhost...') 4 | return 5 | } 6 | 7 | (async () => { 8 | if ('serviceWorker' in navigator) { 9 | try { 10 | const registration = await navigator.serviceWorker.register('/chaptertool/sw.js', { 11 | scope: '/chaptertool/', 12 | }) 13 | if (registration.installing) { 14 | console.log('Service worker installing') 15 | } else if (registration.waiting) { 16 | console.log('Service worker installed') 17 | } else if (registration.active) { 18 | console.log('Service worker active') 19 | } 20 | } catch (error) { 21 | console.error(`Registration failed with ${error}`) 22 | } 23 | } 24 | })() 25 | } 26 | -------------------------------------------------------------------------------- /src/Frontend/SettingsDialog.js: -------------------------------------------------------------------------------- 1 | import { Modal } from 'bootstrap' 2 | import { AutoFormat } from '@mtillmann/chapters' 3 | 4 | export default { 5 | 6 | availableFormats: [], 7 | selectedFormats: [], 8 | settingsModal: null, 9 | initSettingsDialog () { 10 | this.selectedFormats = JSON.parse(localStorage.getItem('ct-selectedFormats') ?? '[]') 11 | if (this.selectedFormats.length === 0) { 12 | this.selectedFormats = Object.keys(AutoFormat.classMap).filter(key => key !== 'ffmpeginfo') 13 | } 14 | 15 | this.availableFormats = Object.entries(AutoFormat.classMap).reduce((acc, entry) => { 16 | const [key, value] = entry 17 | if (key !== 'ffmpeginfo') { 18 | acc.push({ 19 | key, 20 | name: value.name 21 | }) 22 | } 23 | return acc 24 | }, []).sort((a, b) => a.name.localeCompare(b.name)) 25 | 26 | this.settingsModal = new Modal(this.$refs.settingsDialog) 27 | }, 28 | 29 | toggleFormat (key) { 30 | if (this.selectedFormats.includes(key)) { 31 | this.selectedFormats = this.selectedFormats.filter(item => item !== key) 32 | } else { 33 | this.selectedFormats.push(key) 34 | } 35 | 36 | localStorage.setItem('ct-selectedFormats', JSON.stringify(this.selectedFormats)) 37 | }, 38 | 39 | showSettingsDialog () { 40 | this.settingsModal.show() 41 | }, 42 | isFormatSelectedClass (key, key2 = null) { 43 | const keySelected = !(this.availableFormats.find(item => item.key === key).selected) 44 | const key2Selected = !(key2 ? this.availableFormats.find(item => item.key === key2).selected : true) 45 | 46 | console.log({ 47 | key, 48 | key2, 49 | keySelected, 50 | key2Selected, 51 | x: !keySelected && !key2Selected 52 | }) 53 | 54 | // return an object that contains d-none when both keySelected and key2Selected are false 55 | return { 'd-none': !keySelected && !key2Selected } 56 | 57 | // return { xxx:true, 'd-none': !keySelected && !key2Selected } 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /src/Frontend/ShepherdTour.js: -------------------------------------------------------------------------------- 1 | import Shepherd from 'shepherd.js' 2 | 3 | export class ShepherdTour { 4 | tour = null 5 | 6 | constructor () { 7 | this.tour = new Shepherd.Tour({ 8 | useModalOverlay: true, 9 | keyboardNavigation: false, 10 | 11 | defaultStepOptions: { 12 | classes: 'shadow-md bg-purple-dark', 13 | scrollTo: true, 14 | canClickTarget: false, 15 | cancelIcon: { 16 | enabled: true 17 | }, 18 | buttons: [ 19 | { 20 | text: 'Next', 21 | action () { 22 | return this.next() 23 | } 24 | } 25 | ] 26 | } 27 | }); 28 | 29 | ['cancel', 'complete'].forEach(eventName => this.tour.on(eventName, () => { 30 | localStorage.setItem('ct-tour-seen', 'true') 31 | if (document.querySelectorAll('.list-chapter').length > 0 && confirm('reset app?')) { 32 | window.dispatchEvent(new CustomEvent('generic:reset')) 33 | } 34 | })) 35 | 36 | window.addEventListener('keyup', e => { 37 | if (e.key === 'ArrowRight' && this.tour.isActive()) { 38 | this.tour.next() 39 | } 40 | }) 41 | 42 | this.tour.addSteps([ 43 | 44 | { 45 | id: 'describe-timeline', 46 | text: 'Clicking anywhere on the timeline brings up the marker.', 47 | attachTo: { 48 | element: '.timeline', 49 | on: 'bottom' 50 | }, 51 | }, { 52 | id: 'describe-timeline-insert-button', 53 | text: 'The button creates a new chapter at the selected time.', 54 | attachTo: { 55 | element: function () { 56 | return '.timeline .marker .btn-group .insert' 57 | }, 58 | on: 'bottom' 59 | }, 60 | 61 | beforeShowPromise () { 62 | return new Promise(function (resolve) { 63 | window.timeline.updateMarker(document.querySelector('.timeline').getBoundingClientRect().width * 0.5, 0.5) 64 | setTimeout(() => { 65 | resolve() 66 | }, 120) 67 | }) 68 | }, 69 | 70 | when: { 71 | hide () { 72 | document.querySelector('.timeline').classList.remove('clicked') 73 | } 74 | } 75 | }, { 76 | id: 'show-new-chapter-in-timeline', 77 | text: 'The new chapter has been added below the timeline as a segment…', 78 | attachTo: { 79 | element: function () { 80 | return '.chapters .chapter' 81 | }, 82 | on: 'bottom' 83 | }, 84 | beforeShowPromise () { 85 | return new Promise(function (resolve) { 86 | window.dispatchEvent(new CustomEvent('timeline:add', { detail: { startTime: 1800 } })) 87 | setTimeout(() => { 88 | resolve() 89 | }, 120) 90 | }) 91 | } 92 | }, { 93 | id: 'show-new-chapter-in-list', 94 | text: '… and is also rendered in the chapter list on the left.', 95 | attachTo: { 96 | element: function () { 97 | return '.list-chapter' 98 | }, 99 | on: 'bottom' 100 | } 101 | }, { 102 | id: 'show-lower-add-button', 103 | text: 'You can add chapters from the chapter list before and after existing chapters.', 104 | attachTo: { 105 | element: function () { 106 | return [...document.querySelectorAll('.add-chapter-in-list-btn')].pop() 107 | }, 108 | on: 'bottom' 109 | } 110 | }, { 111 | id: 'show-new-chapters-in-timeline', 112 | text: 'All chapters are shown as segments below the timeline. Clicking a segment selects a chapter.', 113 | attachTo: { 114 | element: function () { 115 | return '.chapters' 116 | }, 117 | on: 'bottom' 118 | }, 119 | beforeShowPromise () { 120 | return new Promise(function (resolve) { 121 | window.dispatchEvent(new CustomEvent('timeline:add', { detail: { startTime: 0 } })) 122 | window.dispatchEvent(new CustomEvent('timeline:add', { detail: { startTime: 3600 * 0.75 } })) 123 | setTimeout(() => { 124 | resolve() 125 | }, 120) 126 | }) 127 | } 128 | }, { 129 | id: 'show-edit-box', 130 | text: 'Once selected, you can edit a chapter\'s attributes here.', 131 | attachTo: { 132 | element: function () { 133 | return '[x-ref="chapterList"]+div' 134 | }, 135 | on: 'left' 136 | } 137 | }, { 138 | id: 'show-timestampedit-button', 139 | text: 'Edit a chapter\'s timestamp by either clicking this button, the chapter\'s timestamp link in the list or by dragging the chapter segment\'s left edge.', 140 | attachTo: { 141 | element: function () { 142 | return '#chapterStartTime' 143 | }, 144 | on: 'left' 145 | } 146 | }, { 147 | id: 'show-timestampedit-dialog', 148 | text: 'Once the chapter\'s timestamp is updated, the timeline and chapter list are also updated. Expanding a chapters timestamp beyond the current duration will expand the duration.', 149 | attachTo: { 150 | element: function () { 151 | return '#timestampEditDialog' 152 | }, 153 | on: 'top' 154 | }, 155 | beforeShowPromise () { 156 | return new Promise(function (resolve) { 157 | document.querySelector('#timestampEditDialog').addEventListener('shown.bs.offcanvas', function () { 158 | resolve() 159 | }) 160 | document.querySelector('#chapterStartTime').click() 161 | }) 162 | }, 163 | 164 | }, { 165 | id: 'show-close-button', 166 | text: 'Close the chapter edit dialog here to return to the main menu.', 167 | attachTo: { 168 | element: function () { 169 | return '[x-ref="chapterList"]+div .nav-item.ms-auto' 170 | }, 171 | on: 'left' 172 | }, 173 | 174 | beforeShowPromise () { 175 | document.querySelector('#chapterStartTime').click() 176 | return new Promise(function (resolve) { 177 | document.querySelector('#timestampEditDialog input').value = '00:50:00' 178 | document.querySelector('#timestampEditDialog .offcanvas-body button').click() 179 | 180 | resolve() 181 | }) 182 | } 183 | }, { 184 | id: 'explain-main-menu', 185 | text: 'Access features that are not related to single chapters from here.', 186 | attachTo: { 187 | element: '[x-ref="chapterList"]+div .text-center', 188 | on: 'left' 189 | }, 190 | beforeShowPromise: function () { 191 | return new Promise(function (resolve) { 192 | document.querySelector('[x-ref="chapterList"]+div .nav-item.ms-auto a').click() 193 | setTimeout(() => { 194 | resolve() 195 | }, 120) 196 | }) 197 | } 198 | }, { 199 | id: 'explain-navi-toggle', 200 | text: 'The same features can be accessed at any time from the offcanvas menu.', 201 | attachTo: { 202 | element: 'header .flex-column#offcanvasNaviToggle', 203 | on: 'left' 204 | } 205 | }, 206 | 207 | { 208 | id: 'show-open-export-button', 209 | text: 'Let\'s focus on the export feature.', 210 | attachTo: { 211 | element: '#headerExportButton', 212 | on: 'bottom' 213 | } 214 | }, { 215 | id: 'show-format-tabs', 216 | text: 'Select an export format.', 217 | attachTo: { 218 | element: '#exportDialog .nav', 219 | on: 'top' 220 | }, 221 | beforeShowPromise: function () { 222 | return new Promise(function (resolve) { 223 | document.querySelector('#exportDialog').addEventListener('shown.bs.offcanvas', function () { 224 | resolve() 225 | }) 226 | document.querySelector('#navi .offcanvas-export-link').click() 227 | }) 228 | } 229 | }, { 230 | id: 'show-export-options', 231 | text: 'Toggle export settings if needed. The output will be updated immediately.', 232 | attachTo: { 233 | element: '#exportDialog .col-4', 234 | on: 'left' 235 | } 236 | }, { 237 | id: 'show-export-options', 238 | text: 'Finally, download the data or copy it to the clipboard.', 239 | attachTo: { 240 | element: '#exportDialog .col-8 > div', 241 | on: 'top' 242 | } 243 | }, { 244 | id: 'done', 245 | text: 'That\s it. You can run this tour again from the offcanvas navigation at any time. Have fun.', 246 | attachTo: { 247 | on: 'center' 248 | }, 249 | buttons: [ 250 | { 251 | text: 'Done', 252 | action () { 253 | return this.next() 254 | } 255 | } 256 | ] 257 | } 258 | ]) 259 | 260 | if (/show-tour/.test(window.location.hash)) { 261 | window.location.hash = '' 262 | this.show(true) 263 | } 264 | } 265 | 266 | show (force = false) { 267 | if (!localStorage.getItem('ct-tour-seen') || force) { 268 | this.tour.start() 269 | } 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /src/Frontend/Timeline.js: -------------------------------------------------------------------------------- 1 | import { secondsToTimestamp, timestampToSeconds } from '../util.js' 2 | 3 | export default class Timeline { 4 | isInitRun = true 5 | hoverPosition = 0 6 | clickPosition = 0 7 | dragNode = false 8 | dragNodeCoords = {} 9 | dragHandle = null 10 | timecodeNode = null 11 | currentBlobURL = null 12 | color = null 13 | 14 | constructor (duration, chapters = [], node, options = {}) { 15 | this.node = node 16 | 17 | // eslint-disable-next-line no-undef 18 | this.color = getComputedStyle(document.documentElement).getPropertyValue('--ct-fg-full') 19 | // eslint-disable-next-line no-undef 20 | const ratio = parseFloat(getComputedStyle(this.node.querySelector('.ratio')).getPropertyValue('--bs-aspect-ratio')) * 0.01 21 | 22 | this.id = ((Math.random() * 10e16).toString(16)).split('.').shift() 23 | 24 | this.dragHandle = this.node.querySelector('.drag-handle') 25 | this.timecodeNode = this.node.querySelector('.timecode') 26 | 27 | this.options = { 28 | ...{ 29 | backgroundWidth: 2560, 30 | backgroundHeight: 2560 * ratio, 31 | secondSnap: 1 32 | }, 33 | ...options 34 | } 35 | 36 | this.setDuration(duration) 37 | this.setChapters(chapters) 38 | this.render() 39 | this.isInitRun = false 40 | 41 | this.node.addEventListener('mousemove', this.mouseMoveHandler.bind(this)) 42 | 43 | this.node.addEventListener('mouseout', () => { 44 | this.node.style.setProperty('--hover-display', 'none') 45 | }) 46 | 47 | this.node.addEventListener('click', e => { 48 | e.preventDefault() 49 | 50 | const link = e.target.closest('a') 51 | if (link) { 52 | if (link.matches('.insert')) { 53 | window.dispatchEvent(new CustomEvent('timeline:add', { detail: { startTime: this.clickPosition } })) 54 | } 55 | this.node.classList.remove('clicked') 56 | return 57 | } 58 | 59 | const chapter = e.target.closest('.chapter') 60 | if (chapter) { 61 | const payload = { detail: { index: parseInt(chapter.dataset.index) } } 62 | window.dispatchEvent(new CustomEvent('timeline:scrollintoview', payload)) 63 | } 64 | 65 | if (e.target.matches('.backdrop, .chapters')) { 66 | const rect = e.currentTarget.getBoundingClientRect() 67 | const x = e.clientX - rect.left 68 | const progress = (x / rect.width) 69 | this.clickPosition = this.duration * progress 70 | 71 | this.updateMarker(x, progress) 72 | } 73 | }) 74 | 75 | this.node.querySelector('.chapters').addEventListener('mousedown', e => { 76 | if (e.target.matches('.bar')) { 77 | e.preventDefault() 78 | this.dragNode = e.target.closest('.chapter') 79 | 80 | const nodeBounds = this.node.getBoundingClientRect() 81 | const dragNodeBounds = this.dragNode.getBoundingClientRect() 82 | this.dragNodeCoords = { 83 | left: dragNodeBounds.left - nodeBounds.left, 84 | right: dragNodeBounds.right - nodeBounds.left 85 | } 86 | 87 | this.mouseMoveHandler(e) 88 | 89 | this.dragNode.classList.add('is-dragged') 90 | this.node.classList.add('dragging') 91 | } 92 | }) 93 | 94 | document.body.addEventListener('mouseup', () => { 95 | this.node.classList.remove('dragging') 96 | if (this.dragNode) { 97 | this.dragNode.classList.remove('is-dragged') 98 | window.dispatchEvent(new CustomEvent('timeline:move', { 99 | detail: { 100 | index: this.dragNode.dataset.index, 101 | startTime: this.hoverPosition 102 | } 103 | })) 104 | } 105 | this.dragNode = false 106 | }) 107 | } 108 | 109 | setDuration (duration) { 110 | if (typeof duration !== 'number') { 111 | duration = timestampToSeconds(duration) 112 | } 113 | 114 | this.duration = duration 115 | if (!this.isInitRun) { 116 | this.render() 117 | } 118 | } 119 | 120 | render () { 121 | this.createBackground() 122 | this.renderChapters() 123 | } 124 | 125 | createBackground () { 126 | const canvas = document.createElement('canvas') 127 | const context = canvas.getContext('2d') 128 | 129 | canvas.setAttribute('width', this.options.backgroundWidth) 130 | canvas.setAttribute('height', this.options.backgroundHeight) 131 | 132 | context.fillStyle = this.color 133 | context.strokeStyle = this.color 134 | 135 | const draw = (context, x, h, label) => { 136 | const y = (1 - (h * 0.75)) * this.options.backgroundHeight 137 | x = x * this.options.backgroundWidth 138 | 139 | context.moveTo(x, y) 140 | context.lineTo(x, this.options.backgroundHeight) 141 | 142 | if (label) { 143 | context.font = (this.options.backgroundHeight * 0.3 * (label.scale || 1)) + 'px sans-serif' 144 | context.fillText(label.text, x, y) 145 | } 146 | } 147 | 148 | context.textAlign = 'center' 149 | context.textBaseline = 'bottom' 150 | context.beginPath() 151 | for (let s = 0; s <= this.duration; s += 5) { 152 | if (s % 3600 === 0) { 153 | draw(context, s / this.duration, 0.8, s === 0 154 | ? null 155 | : { 156 | text: secondsToTimestamp(s).slice(0, 8) 157 | }) 158 | continue 159 | } 160 | 161 | if (s % 600 === 0 && this.duration <= 7200) { 162 | draw(context, s / this.duration, 0.5, { 163 | text: secondsToTimestamp(s).slice(0, 8), 164 | scale: 0.7 165 | }) 166 | continue 167 | } 168 | 169 | if (s % 900 === 0 && this.duration > 7200 && this.duration <= 14400) { 170 | draw(context, s / this.duration, 0.5, { 171 | text: secondsToTimestamp(s).slice(0, 8), 172 | scale: 0.7 173 | }) 174 | continue 175 | } 176 | 177 | if (s % 1800 === 0 && this.duration > 7200) { 178 | draw(context, s / this.duration, 0.5, { 179 | text: secondsToTimestamp(s).slice(0, 8), 180 | scale: 0.7 181 | }) 182 | continue 183 | } 184 | 185 | if (s % 60 === 0 && this.duration <= 7200) { 186 | draw(context, s / this.duration, 0.25) 187 | } 188 | 189 | if (s % 300 === 0 && this.duration > 7200) { 190 | draw(context, s / this.duration, 0.25) 191 | } 192 | } 193 | context.stroke() 194 | context.closePath() 195 | 196 | canvas.toBlob(blob => { 197 | const url = URL.createObjectURL(blob) 198 | 199 | this.node.querySelector('.backdrop .ratio').style.setProperty( 200 | 'background-image', 201 | `url(${url})` 202 | ) 203 | 204 | if (this.currentBlobURL) { 205 | URL.revokeObjectURL(this.currentBlobURL) 206 | } 207 | this.currentBlobURL = url 208 | }) 209 | } 210 | 211 | setChapters (chapters) { 212 | this.chapters = chapters 213 | 214 | if (!this.isInitRun) { 215 | this.render() 216 | } 217 | } 218 | 219 | renderChapters () { 220 | this.node.querySelectorAll('.chapter').forEach(node => node.remove()) 221 | 222 | const parentNodes = this.node.querySelector('.chapters') 223 | this.chapters.forEach((chapter, i) => { 224 | const nextStart = this.chapters[i + 1] ? this.chapters[i + 1].startTime : this.duration 225 | const width = ((((nextStart - chapter.startTime) / this.duration) * 100)) + '%' 226 | const node = document.createElement('div') 227 | const left = (chapter.startTime / this.duration) * 100 228 | node.setAttribute('href', `#chapter_${i}`) 229 | node.classList.add('chapter', 'cursor-pointer') 230 | node.dataset.index = i 231 | node.style.setProperty('left', left + '%') 232 | node.style.setProperty('width', width) 233 | 234 | const bar = document.createElement('div') 235 | bar.classList.add('bar') 236 | node.appendChild(bar) 237 | 238 | parentNodes.appendChild(node) 239 | }) 240 | } 241 | 242 | setActive (index) { 243 | const active = this.node.querySelector('.chapter.active') 244 | if (active) { 245 | active.classList.remove('active') 246 | } 247 | 248 | if (index === false) { 249 | return 250 | } 251 | this.node.querySelectorAll('.chapter')?.[index ?? 0]?.classList.add('active') 252 | } 253 | 254 | mouseMoveHandler (e) { 255 | const rect = e.currentTarget.getBoundingClientRect() 256 | const x = e.clientX - rect.left 257 | const y = e.clientY - rect.top 258 | const progress = (x / rect.width) 259 | this.hoverPosition = this.duration * progress 260 | 261 | this.node.style.setProperty('--x', x + 'px') 262 | this.node.style.setProperty('--progress', progress) 263 | this.node.style.setProperty('--y', y + 'px') 264 | this.node.style.setProperty('--hover-display', 'block') 265 | this.timecodeNode.dataset.text = secondsToTimestamp(this.hoverPosition).slice(0, 8) 266 | 267 | if (this.dragNode) { 268 | this.dragHandle.style.setProperty('left', x + 'px') 269 | 270 | let left = 0 271 | let width = (this.dragNodeCoords.right - this.dragNodeCoords.left) - (x - this.dragNodeCoords.left) 272 | if (x >= this.dragNodeCoords.right) { 273 | left = width 274 | width = width * -1 275 | } 276 | 277 | this.dragHandle.style.setProperty('--width', width + 'px') 278 | this.dragHandle.style.setProperty('--left', left + 'px') 279 | } 280 | } 281 | 282 | setMarkerAt (timestamp) { 283 | const progress = timestamp / this.duration 284 | const x = this.node.getBoundingClientRect().width * progress 285 | this.clickPosition = timestamp 286 | this.updateMarker(x, progress) 287 | } 288 | 289 | updateMarker (x, progress) { 290 | this.node.style.setProperty('--click-x', x + 'px') 291 | this.node.style.setProperty('--click-progress', progress) 292 | const insert = this.node.querySelector('.marker a.insert') 293 | const string = 'insert chapter at ' + secondsToTimestamp(this.clickPosition).slice(0, 8) 294 | insert.setAttribute('title', string) 295 | insert.dataset.bsOriginalTitle = string 296 | this.node.classList.add('clicked') 297 | 298 | window.dispatchEvent(new CustomEvent('timeline:marker-set', { detail: { time: this.clickPosition } })) 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /src/Server.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { dirname } from 'path' 3 | 4 | export class Server { 5 | options = null 6 | 7 | constructor (options) { 8 | this.options = options 9 | 10 | const app = express() 11 | const port = options.port 12 | 13 | // dir must be set absolute, otherwise npx calls fail to resolve 14 | const dir = dirname(process.argv[1]) 15 | 16 | app.set('view engine', 'pug') 17 | app.set('views', dir + '/src/views') 18 | app.use(express.static(dir + '/static')) 19 | 20 | // this fixes the issue on localhost with webmanifest being broken and requested again every 5 21 | // seconds by chrome.. 22 | app.use('/chaptertool/', express.static(dir + '/static')) 23 | app.locals.pretty = true 24 | app.get('/', (req, res) => { 25 | res.render('index') 26 | }) 27 | 28 | app.listen(port, () => { 29 | console.log(`open http://localhost:${port} in your browser`) 30 | }) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/cli_util.js: -------------------------------------------------------------------------------- 1 | import { existsSync } from 'fs' 2 | 3 | export function addSuffixToPath (path, extension = null) { 4 | let suffix = 0 5 | // check if this runs with previously borked condition below 6 | // if (extension && !extension.slice(0, 1) === '.') { 7 | if (extension && extension.slice(0, 1) !== '.') { 8 | extension = '.' + extension 9 | } 10 | while (existsSync(path + (suffix < 1 ? '' : '_' + suffix) + (extension || ''))) { 11 | suffix++ 12 | } 13 | 14 | return path + (suffix < 1 ? '' : '_' + suffix) + (extension || '') 15 | } 16 | -------------------------------------------------------------------------------- /src/scss/app.scss: -------------------------------------------------------------------------------- 1 | $bootstrap-icons-font: bootstrap-icons; 2 | 3 | @import "../../node_modules/shepherd.js/dist/css/shepherd"; 4 | @import "../../node_modules/bootstrap/scss/bootstrap"; 5 | @import "../../node_modules/bootstrap-icons/font/bootstrap-icons.scss"; 6 | 7 | 8 | .shepherd-element { 9 | 10 | &, & .shepherd-arrow::before { 11 | background-color: var(--bs-body-bg); 12 | } 13 | 14 | &[data-popper-placement="bottom"] { 15 | margin-top: 16px; 16 | } 17 | 18 | --padding: 0.45rem; 19 | 20 | .shepherd-header { 21 | justify-content: space-between; 22 | padding: var(--padding) var(--padding) 0; 23 | 24 | &::before { 25 | content: "chaptertool" 26 | } 27 | } 28 | 29 | .shepherd-text { 30 | padding: var(--padding); 31 | color:var(--bs-body-color); 32 | } 33 | 34 | .shepherd-footer { 35 | 36 | padding: 0 var(--padding) var(--padding); 37 | 38 | &::before { 39 | padding: calc(var(--padding) * 0.5) calc(var(--padding) * 1.5); 40 | content: "use right arrow key or "; 41 | margin-right: calc(var(--padding) * .5); 42 | opacity: 0.5; 43 | } 44 | } 45 | 46 | .shepherd-button { 47 | padding: calc(var(--padding) * 0.5) calc(var(--padding) * 1.5); 48 | } 49 | } 50 | 51 | .btn-xs, .btn-group-xs > .btn { 52 | --scale-factor: 0.8; 53 | --bs-btn-padding-y: calc(0.25rem * var(--scale-factor)); 54 | --bs-btn-padding-x: calc(0.5rem * var(--scale-factor)); 55 | --bs-btn-font-size: calc(0.875rem * var(--scale-factor)); 56 | --bs-btn-border-radius: calc(0.25rem * var(--scale-factor)); 57 | } 58 | 59 | .btn-micro, .btn-group-micro > .btn { 60 | --scale-factor: 0.5; 61 | --bs-btn-padding-y: calc(0.25rem * var(--scale-factor)); 62 | --bs-btn-padding-x: calc(0.5rem * var(--scale-factor)); 63 | --bs-btn-font-size: calc(0.875rem * var(--scale-factor) * 2); 64 | --bs-btn-border-radius: calc(0.25rem * var(--scale-factor)); 65 | } 66 | 67 | :root { 68 | --ct-fg-full: rgba(var(--bs-body-color-rgb), 1); 69 | --ct-fg-light: rgba(var(--bs-body-color-rgb), 0.5); 70 | --ct-fg-minimal: rgba(var(--bs-body-color-rgb), 0.125); 71 | } 72 | 73 | .border-body-color-light { 74 | 75 | --bs-border-color: var(--ct-fg-light); 76 | } 77 | 78 | 79 | .border-body-color { 80 | 81 | --bs-border-color: var(--ct-fg-full); 82 | } 83 | 84 | header { 85 | a { 86 | text-decoration: none; 87 | } 88 | 89 | a.has-tooltip { 90 | transition: opacity 200ms; 91 | 92 | &:hover { 93 | opacity: 0.8; 94 | } 95 | } 96 | 97 | img { 98 | height: 32px; 99 | } 100 | } 101 | 102 | #image-container { 103 | &::before { 104 | content: ""; 105 | background-repeat: no-repeat; 106 | background-size: cover; 107 | background-image: var(--bg); 108 | background-position: center; 109 | filter: blur(10px); 110 | position: absolute; 111 | bottom: 0; 112 | left: 0; 113 | right: 0; 114 | top: 0; 115 | transform: scale(1.2); 116 | opacity: 0.3; 117 | } 118 | 119 | overflow: hidden; 120 | 121 | img { 122 | background-color: #fff; 123 | max-width: 90%; 124 | max-height: 90%; 125 | object-fit: contain; 126 | } 127 | 128 | } 129 | 130 | .container-fluid { 131 | max-width: 1920px; 132 | } 133 | 134 | .cursor-pointer { 135 | cursor: pointer; 136 | } 137 | 138 | .list-chapter:hover { 139 | background-color: var(--ct-fg-minimal); 140 | border-color: var(--ct-fg-full) !important; 141 | } 142 | 143 | 144 | body { 145 | scroll-behavior: smooth; 146 | } 147 | 148 | .overflow-auto { 149 | scroll-behavior: smooth; 150 | } 151 | 152 | .add-chapter-in-list-btn { 153 | top: 100%; 154 | width: auto; 155 | } 156 | 157 | video { 158 | max-height: 33vh; 159 | @include media-breakpoint-up(xl) { 160 | max-height: 50vh; 161 | } 162 | } 163 | 164 | .timeline { 165 | position: relative; 166 | 167 | --backdrop-bg: var(--ct-fg-light); 168 | //--backdrop-fg: var(--bs-body-color); 169 | --backdrop-fg: var(--ct-fg-full); 170 | 171 | //--marker-bg: var(--bs-gray-500); 172 | --marker-bg: var(--ct-fg-light); 173 | //--marker-fg: var(--bs-body-color); 174 | --marker-fg: var(--ct-fg-full); 175 | 176 | .chapters { 177 | position: relative; 178 | z-index: 16; 179 | height: 20px; 180 | 181 | --inactive-height: 50%; 182 | --inactive-opacity: 0.375; 183 | --active-opacity: 0.625; 184 | 185 | .chapter { 186 | position: absolute; 187 | top: 50%; 188 | height: 100%; 189 | transform: translateY(-50%); 190 | 191 | 192 | &::after { 193 | position: absolute; 194 | background-color: var(--marker-fg); 195 | opacity: var(--inactive-opacity); 196 | content: ""; 197 | left: 0; 198 | right: 2px; 199 | transform: translateY(-50%); 200 | top: 50%; 201 | height: var(--inactive-height); 202 | transition: height 60ms; 203 | } 204 | 205 | &:last-child::after { 206 | right: 0; 207 | } 208 | 209 | 210 | .bar { 211 | position: absolute; 212 | left: 0; 213 | width: 5px; 214 | top: 0; 215 | bottom: 0; 216 | cursor: ew-resize; 217 | z-index: 8; 218 | } 219 | } 220 | 221 | .drag-handle { 222 | position: absolute; 223 | top: 0; 224 | bottom: 0; 225 | width: 5px; 226 | background: var(--ct-fg-full); 227 | left: 0; 228 | z-index: 16; 229 | cursor: ew-resize; 230 | display: none; 231 | 232 | &::after { 233 | content: ""; 234 | position: absolute; 235 | width: var(--width); 236 | left: var(--left); 237 | top: 50%; 238 | background-color: var(--ct-fg-full); 239 | opacity: var(--active-opacity); 240 | height: var(--inactive-height); 241 | transform: translateY(-50%); 242 | } 243 | } 244 | } 245 | 246 | &.no-chapters { 247 | .chapters { 248 | &::after { 249 | content: ""; 250 | position: absolute; 251 | top: 50%; 252 | height: var(--inactive-height); 253 | left: 0; 254 | right: 0; 255 | background-color: var(--ct-fg-full); 256 | opacity: calc(var(--inactive-opacity) * .5); 257 | transform: translateY(-50%); 258 | } 259 | } 260 | } 261 | 262 | .backdrop { 263 | position: relative; 264 | 265 | &::after { 266 | content: ""; 267 | z-index: 32; 268 | position: absolute; 269 | top: 0; 270 | left: 0; 271 | right: 0; 272 | bottom: 0; 273 | } 274 | 275 | .ratio { 276 | --bs-aspect-ratio: 20%; 277 | @include media-breakpoint-up(lg) { 278 | --bs-aspect-ratio: 5%; 279 | } 280 | background-position: center; 281 | background-size: contain; 282 | background-repeat: no-repeat; 283 | } 284 | 285 | //background-color:var(--backdrop-bg); 286 | 287 | .timecode { 288 | 289 | 290 | position: absolute; 291 | left: var(--x); 292 | border-left: 1px dashed var(--ct-fg-full); 293 | top: 0; 294 | bottom: 0; 295 | display: var(--hover-display); 296 | 297 | &::after { 298 | content: attr(data-text); 299 | position: absolute; 300 | transform: translateX(calc(var(--progress) * -100%)); 301 | left: 0; 302 | bottom: 0; 303 | line-height: 0.8; 304 | background-color: var(--bs-body-bg); 305 | color: var(--color); 306 | } 307 | } 308 | 309 | .marker { 310 | z-index: 64; 311 | position: absolute; 312 | top: 0; 313 | bottom: 0; 314 | border-left: 1px solid var(--ct-fg-full); 315 | display: none; 316 | 317 | div { 318 | position: absolute; 319 | transform: translateX(calc(var(--click-progress) * -100%)); 320 | left: 0; 321 | top: 0; 322 | background: var(--bs-body-bg); 323 | 324 | a { 325 | } 326 | 327 | } 328 | } 329 | } 330 | 331 | &.clicked { 332 | .backdrop .marker { 333 | display: block; 334 | left: var(--click-x); 335 | } 336 | } 337 | 338 | 339 | &.dragging { 340 | .chapters { 341 | &::after { 342 | position: absolute; 343 | top: 0; 344 | bottom: 0; 345 | left: 0; 346 | right: 0; 347 | background-color: var(--bs-body-bg); 348 | opacity: 0.5; 349 | content: ""; 350 | } 351 | 352 | .drag-handle { 353 | display: block; 354 | 355 | } 356 | } 357 | } 358 | 359 | &:not(.dragging) { 360 | .chapters .chapter { 361 | &.active::after { 362 | opacity: var(--active-opacity); 363 | } 364 | 365 | &:hover { 366 | height: 100%; 367 | 368 | &::after { 369 | opacity: var(--active-opacity); 370 | height: 100%; 371 | } 372 | } 373 | } 374 | } 375 | } 376 | 377 | 378 | a.icon { 379 | display: inline-block; 380 | text-decoration: none; 381 | overflow: hidden; 382 | margin-bottom: -5px; 383 | 384 | } 385 | 386 | #appWrap { 387 | @include media-breakpoint-up(lg) { 388 | overflow: hidden; 389 | height: 100vh; 390 | } 391 | } 392 | 393 | #lower { 394 | @include media-breakpoint-up(lg) { 395 | overflow: hidden; 396 | & > .row { 397 | height: 100%; 398 | } 399 | #chapterList { 400 | height: 100%; 401 | } 402 | } 403 | } -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | export function zeroPad (num, len = 3) { 2 | return String(num).padStart(len, '0') 3 | } 4 | 5 | export function secondsToTimestamp (s, options = {}) { 6 | options = { ...{ hours: true, milliseconds: false }, ...options } 7 | 8 | const date = new Date(parseInt(s) * 1000).toISOString() 9 | 10 | if (date.slice(11, 13) !== '00') { 11 | options.hours = true 12 | } 13 | const hms = date.slice(options.hours ? 11 : 14, 19) 14 | 15 | if (options.milliseconds) { 16 | let fraction = '000' 17 | if (s.toString().indexOf('.') > -1) { 18 | fraction = (String(s).split('.').pop() + '000').slice(0, 3) 19 | } 20 | return hms + '.' + fraction 21 | } 22 | return hms 23 | } 24 | 25 | /** 26 | * Converts a NPT (normal play time) to seconds, used by podlove simple chapters 27 | */ 28 | export function NPTToSeconds (npt) { 29 | let [parts, ms] = npt.split('.') 30 | ms = parseInt(ms || 0) 31 | parts = parts.split(':') 32 | 33 | while (parts.length < 3) { 34 | parts.unshift(0) 35 | } 36 | 37 | const [hours, minutes, seconds] = parts.map(i => parseInt(i)) 38 | 39 | return timestampToSeconds(`${zeroPad(hours, 2)}:${zeroPad(minutes, 2)}:${zeroPad(seconds, 2)}.${zeroPad(ms, 3)}`) 40 | } 41 | 42 | export function secondsToNPT (seconds) { 43 | if (seconds === 0) { 44 | return '0' 45 | } 46 | 47 | const regularTimestamp = secondsToTimestamp(seconds, { milliseconds: true }) 48 | let [hoursAndMinutesAndSeconds, milliseconds] = regularTimestamp.split('.') 49 | let [hours, minutes, secondsOnly] = hoursAndMinutesAndSeconds.split(':').map(i => parseInt(i)) 50 | 51 | if (milliseconds === '000') { 52 | milliseconds = '' 53 | } else { 54 | milliseconds = '.' + milliseconds 55 | } 56 | 57 | if (hours === 0 && minutes === 0) { 58 | return `${secondsOnly}${milliseconds}` 59 | } 60 | 61 | secondsOnly = zeroPad(secondsOnly, 2) 62 | 63 | if (hours === 0) { 64 | return `${minutes}:${secondsOnly}${milliseconds}` 65 | } 66 | 67 | minutes = zeroPad(minutes, 2) 68 | 69 | return `${hours}:${minutes}:${secondsOnly}${milliseconds}` 70 | } 71 | 72 | export function timestampToSeconds (timestamp, fixedString = false) { 73 | let [seconds, minutes, hours] = timestamp.split(':').reverse() 74 | let milliseconds = 0 75 | if (seconds.indexOf('.') > -1) { 76 | [seconds, milliseconds] = seconds.split('.') 77 | } 78 | 79 | hours = parseInt(hours || 0) 80 | minutes = parseInt(minutes || 0) 81 | seconds = parseInt(seconds || 0) 82 | milliseconds = parseInt(milliseconds) / 1000 83 | 84 | if (seconds > 59) { 85 | const extraMinutes = Math.floor(seconds / 60) 86 | minutes += extraMinutes 87 | seconds -= extraMinutes * 60 88 | } 89 | 90 | if (minutes > 59) { 91 | const extraHours = Math.floor(minutes / 60) 92 | hours += extraHours 93 | minutes -= extraHours * 60 94 | } 95 | 96 | if (fixedString) { 97 | return parseFloat((hours * 3600 + minutes * 60 + seconds + milliseconds).toFixed(3)) 98 | } 99 | return hours * 3600 + minutes * 60 + seconds + milliseconds 100 | } 101 | 102 | export function hash () { 103 | return (Math.random() + 1).toString(16).substring(7) 104 | } 105 | 106 | export function escapeRegExpCharacters (text) { 107 | return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') 108 | } 109 | 110 | export function enforceMilliseconds (seconds) { 111 | return parseFloat(seconds.toFixed(3)) 112 | } 113 | 114 | export function formatBytes (bytes, decimals = 2, format = 'KB') { 115 | if (bytes < 1) { 116 | return '0 B' 117 | } 118 | const k = format === 'kB' ? 1000 : 1024 119 | const i = Math.floor(Math.log(bytes) / Math.log(k)) 120 | const sizes = ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'] 121 | const suffix = [format === 'kB' ? sizes[i].toLowerCase() : sizes[i], 'B'] 122 | if (format === 'KiB') { 123 | suffix.splice(1, 0, 'i') 124 | } 125 | return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + suffix.join('') 126 | } 127 | -------------------------------------------------------------------------------- /src/views/index.pug: -------------------------------------------------------------------------------- 1 | html(data-bs-theme="auto", lang="en") 2 | head 3 | link(rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined") 4 | link(rel='stylesheet' href='app.css') 5 | script(src='app.js') 6 | include partials/meta 7 | body 8 | div#app(x-data="APP") 9 | div#appWrap.d-flex.flex-column 10 | include partials/header 11 | div.container-xl 12 | div.row 13 | include partials/mediaBox 14 | include partials/timelineControls 15 | include partials/timeline 16 | div.col-12.d-flex.justify-content-center.d-none 17 | a.btn.btn-sm.btn-primary('@click.stop.prevent'="toggleSettings()")="settings" 18 | div#lower.container-xl.flex-grow-1 19 | div.row 20 | div#chapterList.overflow-auto.pt-4.col-12.col-lg-6(x-ref="chapterList",:class="{'d-none' : data.chapters.length === 0}") 21 | template(x-for="(chapter, index) in data.chapters", :key="chapter.id") 22 | include partials/chapterListItem 23 | div.col-12.col-lg-6.overflow-auto.h-100.p-4(:class="{'offset-lg-3' : data.chapters.length === 0}") 24 | include partials/noChapterSelected 25 | include partials/chapterEditPanel 26 | include partials/exportDialog 27 | include partials/importDialog 28 | include partials/metaDialog 29 | include partials/timestampDialog 30 | include partials/offcanvasNavi 31 | include partials/analyticsOffcanvas 32 | include partials/settingsDialog 33 | input#importFileInput.d-none(type="file", accept="audio/*,video/*,.json,.webvtt,.vtt,.txt,.xml,.csv,image/*", @change="fileHandler.handleFile($event.target.files[0])") 34 | div.toast-container.position-fixed.bottom-0.start-50.translate-middle-x(x-ref="toasts") -------------------------------------------------------------------------------- /src/views/partials/analyticsOffcanvas.pug: -------------------------------------------------------------------------------- 1 | div.offcanvas.offcanvas-bottom#analyticsDialog 2 | div.offcanvas-header 3 | h5.offcanvas-title Analytics 4 | div.offcanvas-body 5 | div.align-items-center.h-100.d-flex 6 | div.container-xl 7 | div.row 8 | div.col-12 9 | p.text-center Enable Google Analytics to collect usage information and help me improve the app. 10 | | You can change the option in the main navigation ( 11 | i.bi.bi-list 12 | | ) at any time. 13 | div.col-12.d-flex.justify-content-evenly 14 | button.btn.btn-secondary(@click="toggleGA('enabled')") Disable Analytics 15 | button.btn.btn-primary(@click="toggleGA('enabled')") Enable Analytics 16 | 17 | -------------------------------------------------------------------------------- /src/views/partials/chapterEditPanel.pug: -------------------------------------------------------------------------------- 1 | template(x-if="currentChapterIndex !== null") 2 | div 3 | ul.nav.nav-tabs 4 | li.nav-item 5 | a.nav-link(href="#", :class="{active : editTab === 'info'}", @click.stop.prevent="editTab='info'") Info 6 | li.nav-item 7 | a.nav-link(href="#", :class="{active : editTab === 'img'}", @click.stop.prevent="editTab='img'") Image 8 | li.nav-item 9 | a.nav-link(href="#", :class="{active : editTab === 'geo'}", @click.stop.prevent="editTab='geo'") Location 10 | li.nav-item.ms-auto 11 | a.nav-link(href="#", @click.stop.prevent="closeChapter")   12 | i.bi.bi-x-lg 13 | div(x-show="editTab === 'info'") 14 | div.row.p-1.mb-1 15 | label.col-3.col-form-label(for="chapterTitle") Title 16 | div.col-9 17 | input.form-control#chapterTitle(x-model="data.chapters[currentChapterIndex].title", :placeholder="`Untitled Chapter ${currentChapterIndex + 1}`") 18 | div.row(x-show="currentChapterIndex === 0 && data.chapters[currentChapterIndex].startTime !== 0") 19 | div.col 20 | p.small.alert.alert-info This is the first chapter but it doesn't start at 00:00: 21 | a.ms-1(href="#",@click.stop.prevent="expandFirstToStart") set start time to 00:00 22 | div.row.p-1.mb-1 23 | label.col-3.col-form-label(for="chapterStartTime") Start time 24 | div.col-9 25 | button.form-control#chapterStartTime(x-text="secondsToTimestamp(data.chapters[currentChapterIndex].startTime,{hours : true, milliseconds : true, wtf : true})",@click="editStartTime(currentChapterIndex)") 26 | div.row.p-1.mb-1 27 | label.col-3.col-form-label(for="chapterURL") URL 28 | div.col-9 29 | input.form-control#chapterURL(placeholder="optional", x-model="data.chapters[currentChapterIndex].url") 30 | div.row.p-1.mb-1 31 | label.col-3.col-form-label(for="chapterTOC") TOC 32 | div.col-9.pt-2 33 | div.form-check 34 | input.form-check-input#chapterTOC(type="checkbox",x-model="data.chapters[currentChapterIndex].toc") 35 | label.form-check-label(for="chapterTOC") show chapter in  36 | abbr(title="table of contents") TOC 37 | 38 | div(x-show="editTab === 'img'") 39 | div.row.p-1.mb-1(x-show="data.chapters[currentChapterIndex].img_type === 'relative'") 40 | p.small.alert.alert-info 41 | i.fs-1.float-start.bi.bi-info-circle.me-2 42 | | The image uses a relative path and can't be displayed. 43 | | You can set an URL prefix in the export dialog. 44 | div.row.p-1.mb-1 45 | div.col-9.offset-3 46 | div.ratio.ratio-16x9 47 | div.p-2#image-container(:style="`--bg:url(${chapterImage(currentChapterIndex)})`") 48 | img.shadow.position-absolute.translate-middle.start-50.top-50(style="object-fit:contain",:src="chapterImage(currentChapterIndex)") 49 | span.small.position-absolute.translate-middle.start-50.top-50.text-muted(x-show="!chapterImage(currentChapterIndex)") 50 | | drop or paste an image or image URL here 51 | template(x-if="hasVideo") 52 | span 53 | | or fetch video snapshot from 54 | | 55 | a(href="#", @click.stop.prevent="fetchVideoSnapshot(data.chapters[currentChapterIndex].startTime)") chapter start 56 | | 57 | | / 58 | | 59 | a(href="#", @click.stop.prevent="fetchVideoSnapshot()") current position 60 | button.position-absolute.end-0.top-0.btn.btn-sm.btn-secondary(@click="deleteImage(currentChapterIndex)", x-show="chapterImage(currentChapterIndex)") 61 | i.bi.bi-trash 62 | | remove 63 | div.row.p-1.mb-1 64 | label.col-3.col-form-label(for="chapterImage") Image 65 | div.col-9 66 | input.form-control#chapterImage(type="file", accept="image/*", @change="fileHandler.handleFile($event.target.files[0])") 67 | template(x-if="chapterImage(currentChapterIndex)") 68 | div 69 | div.row.p-1.mb-1 70 | label.col-3.col-form-label( 71 | for="imageFilename" 72 | x-text="data.chapters[currentChapterIndex].img_type === 'absolute' ? 'URL':'Filename'" 73 | ) 74 | div.col-9 75 | input.form-control#imageFilename( 76 | :readonly="data.chapters[currentChapterIndex].img_type === 'absolute'" 77 | x-model="data.chapters[currentChapterIndex].img_filename" 78 | ) 79 | div.row.p-1.mb-1 80 | label.col-3.col-form-label Type 81 | div.col-9 82 | input.form-control(x-show="data.chapters[currentChapterIndex].img_type === 'blob'",disabled, value="in-memory (blob)") 83 | input.form-control(x-show="data.chapters[currentChapterIndex].img_type === 'relative'",disabled, value="relative path") 84 | input.form-control(x-show="data.chapters[currentChapterIndex].img_type === 'absolute'",disabled, value="URL") 85 | div.row.p-1.mb-1 86 | label.col-3.col-form-label Dims 87 | div.col-9 88 | input.form-control(disabled,:value="data.chapters[currentChapterIndex].img_dims") 89 | div.row.p-1.mb-1 90 | label.col-3.col-form-label Size 91 | div.col-9 92 | input.form-control(disabled,:value="data.chapters[currentChapterIndex].img_size") 93 | 94 | div(x-show="editTab === 'geo'") 95 | div.row.p-1.mb-1 96 | label.col-3.col-form-label(for="chapterGeoName") Name 97 | div.col-9 98 | input.form-control#chapterGeoName(x-model="data.chapters[currentChapterIndex].geo_name") 99 | div.row.p-1.mb-1 100 | label.col-3.col-form-label(for="chapterGeoLL") Geo 101 | div.col-9 102 | input.form-control#chapterGeoLL(x-model="data.chapters[currentChapterIndex].geo_name", placeholder="geo:lat,lon") -------------------------------------------------------------------------------- /src/views/partials/chapterListItem.pug: -------------------------------------------------------------------------------- 1 | div 2 | template(x-if="index === 0 && chapter.startTime > 0") 3 | div.d-flex.my-1.justify-content-center 4 | button.btn.btn-outline-primary.btn-xs.add-chapter-in-list-btn(@click="addChapter(index)") 5 | i.bi.bi-bookmark-plus 6 | | add chapter 7 | div.list-chapter.rounded.shadow-sm.border.border-body-color-light(:id="`chapter_${index}`",@click.stop.prevent="editChapter(index)",:class="{'border-body-color':currentChapterIndex === index}") 8 | div.row.gx-0.cursor-pointer 9 | div.p-2.col-2.col-xl-1.d-flex.align-items-center.justify-content-center 10 | div.fs-5(x-text="`${index + 1}`") 11 | div.p-2.col-2.position-relative 12 | template(x-if="chapterImage(index)") 13 | img.position-absolute.top-50.start-50.translate-middle.mw-100(style="max-height:80%",:src="chapterImage(index)") 14 | template(x-if="!chapterImage(index)") 15 | i.fs-3.text-muted.bi.bi-image.opacity-25.position-absolute.top-50.start-50.translate-middle 16 | template(x-if="chapter.img_type === 'relative'") 17 | i.fs-6.text-info.bi.bi-info-circle-fill.position-absolute.end-0.top-25 18 | div.p-2.col-7.col-xl-8.d-flex.align-items-center 19 | div.text-truncate.px-2 20 | span(x-text="chapter.title || `Chapter ${index + 1}`", :class="{'fst-italic text-muted opacity-50' : !chapter.title}") 21 | br 22 | span.small 23 | a.has-tooltip( 24 | @click.stop.prevent="editStartTime(index)" 25 | href="#" x-text="chapter.startTime_hr" 26 | title="change chapter start time" 27 | ) 28 | span  –  29 | span(x-text="chapter.endTime_hr") 30 | | 31 | span(x-text="`(${chapter.duration_hr})`") 32 | div.p-2.col-1.d-flex.align-items-center.justify-content-center.border-start.border-body-color-light 33 | a.w-100.h-100.d-flex.has-tooltip.delete-button.justify-content-center.align-items-center( 34 | title="delete chapter" 35 | href="#" 36 | @click.stop.prevent="deleteChapter(index)" 37 | ) 38 | i.bi.fs-4.bi-trash 39 | div.d-flex.my-1.justify-content-center 40 | button.btn.btn-outline-primary.btn-xs.add-chapter-in-list-btn(@click="addChapter(index + 1)") 41 | i.bi.bi-bookmark-plus 42 | | add chapter -------------------------------------------------------------------------------- /src/views/partials/exportDialog.pug: -------------------------------------------------------------------------------- 1 | div.offcanvas.offcanvas-bottom#exportDialog(x-ref="exportDialog", style="--bs-offcanvas-height:initial;;min-height:50vh;max-height:100vh") 2 | div.offcanvas-header 3 | h5.offcanvas-title Export Chapters 4 | button.btn-close(data-bs-dismiss="offcanvas") 5 | div.offcanvas-body.pt-0.d-flex.flex-column 6 | div.container-xl.flex-grow-1.d-flex.flex-column 7 | div.row.flex-grow-1 8 | div.col-12.d-flex.flex-column.flex-grow-1 9 | ul.nav.nav-tabs.mb-2(style="--bs-nav-link-padding-x:0.5rem") 10 | li.nav-item(x-show="selectedFormats.includes('chaptersjson') || selectedFormats.length === 0") 11 | a.nav-link(:class="{active : exportSettings.type === 'chaptersjson'}", href="#", @click.stop.prevent="updateExportContent('chaptersjson')") 12 | i.bi.bi-filetype-json 13 | | 14 | | chapters.json 15 | li.nav-item(x-show="selectedFormats.includes('webvtt')") 16 | a.nav-link(:class="{active : exportSettings.type === 'webvtt'}", href="#", @click.stop.prevent="updateExportContent('webvtt')") 17 | i.bi.bi-filetype-txt 18 | | 19 | | WebVTT 20 | li.nav-item(x-show="selectedFormats.includes('youtube')") 21 | a.nav-link(:class="{active : exportSettings.type === 'youtube'}", href="#", @click.stop.prevent="updateExportContent('youtube')") 22 | i.bi.bi-youtube 23 | | 24 | | Youtube 25 | li.nav-item(x-show="selectedFormats.includes('matroskaxml')") 26 | a.nav-link(:class="{active : exportSettings.type === 'matroskaxml'}", href="#", @click.stop.prevent="updateExportContent('matroskaxml')") 27 | i.bi.bi-filetype-xml 28 | | 29 | | Matroska 30 | li.nav-item(x-show="selectedFormats.includes('mkvmergexml') || selectedFormats.includes('mkvmersimple') || selectedFormats.includes('vorbiscomment')") 31 | a.nav-link(:class="{active : /^(mkvmerge|vorbis)/.test(exportSettings.type)}", href="#", @click.stop.prevent="updateExportContent('mkvmergexml')") 32 | i.bi.bi-filetype-xml 33 | | 34 | | MKVMerge/Nero/Vorbis 35 | li.nav-item(x-show="selectedFormats.includes('ffmetadata')") 36 | a.nav-link(:class="{active : exportSettings.type === 'ffmetadata'}", href="#", @click.stop.prevent="updateExportContent('ffmetadata')") 37 | i.bi.bi-file-binary 38 | | 39 | | FFMetadata 40 | li.nav-item(x-show="selectedFormats.includes('pyscenedetect')") 41 | a.nav-link(:class="{active : exportSettings.type === 'pyscenedetect'}", href="#", @click.stop.prevent="updateExportContent('pyscenedetect')") 42 | i.bi.bi-filetype-py 43 | | 44 | | PySceneDetect 45 | li.nav-item(x-show="selectedFormats.includes('applechapters')") 46 | a.nav-link(:class="{active : exportSettings.type === 'applechapters'}", href="#", @click.stop.prevent="updateExportContent('applechapters')") 47 | i.bi.bi-filetype-xml 48 | | 49 | | Apple Chapters 50 | li.nav-item(x-show="selectedFormats.includes('psc') || selectedFormats.includes('podlovejson')") 51 | a.nav-link(:class="{active : /^(psc|podlovejson)/.test(exportSettings.type)}", href="#", @click.stop.prevent="updateExportContent('psc')") 52 | i.bi.bi-filetype-xml 53 | | 54 | | Podlove 55 | li.nav-item(x-show="selectedFormats.includes('mp4chaps')") 56 | a.nav-link(:class="{active : exportSettings.type === 'mp4chaps'}", href="#", @click.stop.prevent="updateExportContent('mp4chaps')") 57 | i.bi.bi-filetype-txt 58 | | 59 | | MP4Chaps 60 | li.nav-item(x-show="selectedFormats.includes('shutteredl')") 61 | a.nav-link(:class="{active : exportSettings.type === 'shutteredl'}", href="#", @click.stop.prevent="updateExportContent('shutteredl')") 62 | i.bi.bi-filetype-txt 63 | | 64 | | EDL 65 | li.nav-item(x-show="selectedFormats.includes('applehls')") 66 | a.nav-link(:class="{active : exportSettings.type === 'applehls'}", href="#", @click.stop.prevent="updateExportContent('applehls')") 67 | i.bi.bi-filetype-json 68 | | 69 | | Apple HLS chapters 70 | li.nav-item(x-show="selectedFormats.includes('scenecut')") 71 | a.nav-link(:class="{active : exportSettings.type === 'scenecut'}", href="#", @click.stop.prevent="updateExportContent('scenecut')") 72 | i.bi.bi-filetype-json 73 | | 74 | | Scenecut 75 | li.nav-item(x-show="selectedFormats.includes('podigee')") 76 | a.nav-link(:class="{active : exportSettings.type === 'podigee'}", href="#", @click.stop.prevent="updateExportContent('podigee')") 77 | i.bi.bi-filetype-json 78 | | 79 | | Podigee 80 | li.nav-item(x-show="/podcastpage|podigeetext|spofitfya|spotifyb|shownotes|transistorfm/.test(selectedFormats.join(','))") 81 | a.nav-link(:class="{active : exportSettings.type === 'misctext'}", href="#", @click.stop.prevent="updateExportContent('misctext')") 82 | i.bi.bi-filetype-txt 83 | | 84 | | Misc Text 85 | div.row.flex-grow-1 86 | div.col-8.d-flex.flex-column 87 | textarea.flex-grow-1.overflow-visible.form-control(:value="exportContent", readonly, x-ref="outputTextarea") 88 | div.mt-2.d-flex.justify-content-evenly 89 | button.btn.btn-outline-primary(@click="download") 90 | i.bi.bi-download 91 | | 92 | | Download 93 | button.btn.btn-outline-primary( 94 | @click="downloadZip" 95 | x-show="exportSettings.type === 'chaptersjson' && exportSettings.hasImages" 96 | ) 97 | i.bi.bi-file-zip 98 | | 99 | | Zip w/ Images 100 | button.btn.btn-outline-primary(@click="copyToClipboard") 101 | i.bi.bi-clipboard 102 | | 103 | | Copy to Clipboard 104 | div.col-4 105 | div.mb-2.form-check.form-switch(x-show="exportSettings.supportsPretty") 106 | input#exportPretty.form-check-input(@change="updateExportContent()",type="checkbox", x-model="exportSettings.pretty") 107 | label.form-check-label(for="exportPretty") pretty print 108 | template(x-if="exportSettings.type === 'chaptersjson'") 109 | div 110 | div.form-check.form-switch.mb-2 111 | input.form-check-input#writeToc(@change="updateExportContent()",type="checkbox", x-model="exportSettings.writeRedundantToc") 112 | label.form-check-label(for="writeToc") write all TOC attributes 113 | div.form-check.form-switch.mb-2 114 | input.form-check-input#writeEndTimes(@change="updateExportContent()",type="checkbox", x-model="exportSettings.writeEndTimes") 115 | label.form-check-label(for="writeEndTimes") write endTime Attributes 116 | div(x-show="exportSettings.canUseImagePrefix") 117 | label.form-label.small(for="imagePrefix") image URI prefix 118 | input.form-control#imagePrefix(@input="updateExportContent()", x-model="exportSettings.imagePrefix") 119 | template(x-if="/^(mkvmerge|vorbis)/.test(exportSettings.type)") 120 | div.btn-group.btn-group-sm(role="group") 121 | input#formatMKVMergeXML.btn-check(type="radio",name="mkvformat",value="mkvmergexml", x-model="exportSettings.type", @change="updateExportContent()") 122 | label.btn.btn-outline-primary(for="formatMKVMergeXML") XML 123 | input#formatMKVMergeSimple.btn-check(type="radio",name="mkvformat",value="mkvmergesimple", x-model="exportSettings.type", @change="updateExportContent()") 124 | label.btn.btn-outline-primary(for="formatMKVMergeSimple") simple aka Nero 125 | input#formatVorbisComment.btn-check(type="radio",name="mkvformat",value="vorbiscomment", x-model="exportSettings.type", @change="updateExportContent()") 126 | label.btn.btn-outline-primary(for="formatVorbisComment") Vorbis Comment 127 | template(x-if="exportSettings.type === 'misctext'") 128 | div 129 | div.form-check.mb-2 130 | input.form-check-input#miscTextSpotifyA(@change="updateExportContent()", value="spotifya" type="radio", x-model="exportSettings.miscTextType") 131 | label.form-check-label(for="miscTextSpotifyA") spotify(A) 132 | div.form-check.mb-2 133 | input.form-check-input#miscTextSpotifyB(@change="updateExportContent()", value="spotifyb" type="radio", x-model="exportSettings.miscTextType") 134 | label.form-check-label(for="miscTextSpotifyB") spotify(B) 135 | div.form-check.mb-2 136 | input.form-check-input#miscTextTransistorFM(@change="updateExportContent()", value="transistorfm" type="radio", x-model="exportSettings.miscTextType") 137 | label.form-check-label(for="miscTextTransistorFM") TransistorFM 138 | div.form-check.mb-2 139 | input.form-check-input#miscTextPodigeeText(@change="updateExportContent()", value="podigeetext" type="radio", x-model="exportSettings.miscTextType") 140 | label.form-check-label(for="miscTextPodigeeText") PodigeeText 141 | div.form-check.mb-2 142 | input.form-check-input#miscTextPodcastPage(@change="updateExportContent()", value="podcastpage" type="radio", x-model="exportSettings.miscTextType") 143 | label.form-check-label(for="miscTextPodcastPage") PodcastPage 144 | div.form-check.mb-2 145 | input.form-check-input#miscTextShownotes(@change="updateExportContent()", value="shownotes" type="radio", x-model="exportSettings.miscTextType") 146 | label.form-check-label(for="miscTextShownotes") "Shownotes" 147 | 148 | 149 | template(x-if="/^(psc|podlove)/.test(exportSettings.type)") 150 | div.btn-group.btn-group-sm(role="group") 151 | input#podloveSimpleChapters.btn-check(type="radio",name="podloveformat",value="psc", x-model="exportSettings.type", @change="updateExportContent()") 152 | label.btn.btn-outline-primary(for="podloveSimpleChapters") Podlove Simple Chapters 153 | input#podloveJson.btn-check(type="radio",name="podloveformat",value="podlovejson", x-model="exportSettings.type", @change="updateExportContent()") 154 | label.btn.btn-outline-primary(for="podloveJson") podlove json 155 | template(x-if="exportSettings.type === 'pyscenedetect'") 156 | div 157 | div.input-group.mb-2 158 | span.input-group-text Framerate 159 | input#psdFramerate.form-control.form-control-sm(type="text", min=1, max=240, step=0.001, x-model.number="exportSettings.psdFramerate", @input="updateExportContent()") 160 | div.form-check.form-switch.mb-2 161 | input.form-check-input#psdOmitTimecodes(@change="updateExportContent()",type="checkbox", x-model="exportSettings.psdOmitTimecodes") 162 | label.form-check-label(for="psdOmitTimecodes") omit Timecode List 163 | template(x-if="exportSettings.type === 'applechapters'") 164 | div 165 | div.form-check.form-switch.mb-2 166 | input.form-check-input#psdOmitTimecodes(@change="updateExportContent()",type="checkbox", x-model="exportSettings.acUseTextAttr") 167 | label.form-check-label(for="psdOmitTimecodes") use text-attr 168 | -------------------------------------------------------------------------------- /src/views/partials/faq.pug: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mtillmann/chaptertool/47a48244073a08f7a2499512864dfc9e78dcb89e/src/views/partials/faq.pug -------------------------------------------------------------------------------- /src/views/partials/header.pug: -------------------------------------------------------------------------------- 1 | header.container-lg 2 | div.row 3 | div.col-12.d-flex.justify-content-between.border-bottom.border-body-color.mb-1 4 | div.fs-4.d-flex.flex-grow-1 5 | a.has-tooltip(href="#" @click.stop.prevent="currentChapterIndex = null") 6 | img.mt-1.rounded.shadow(src="icons/icon-64.png") 7 | div.fs-3.d-none.d-md-flex.flex-column.me-3 8 | a.d-flex.flex-grow-1.has-tooltip(href="#" @click.stop.prevent="addChapterFromTime()" title="Insert Chapter at Given Time") 9 | i.bi.bi-input-cursor.d-flex.align-self-center 10 | div.fs-3.d-none.d-md-flex.flex-column.me-3 11 | a.d-flex.flex-grow-1.has-tooltip(href="#" @click.stop.prevent="changeDuration()" title="Change Duration") 12 | i.bi.bi-clock-history.d-flex.align-self-center 13 | div.fs-3.d-none.d-md-flex.flex-column.me-3 14 | a.d-flex.flex-grow-1.has-tooltip(href="#" @click.stop.prevent="metaPropertiesDialog.show()" title="Edit Meta Attributes") 15 | i.bi.bi-pencil-square.d-flex.align-self-center 16 | div.fs-3.d-none.d-md-flex.flex-column.me-4#headerExportButton 17 | a.d-flex.flex-grow-1.has-tooltip(href="#" @click.stop.prevent="exportOffcanvas.show()" :class="{ 'disabled opacity-50' : data.chapters.length === 0 }" title="Export / Download") 18 | i.bi.bi-download.d-flex.align-self-center 19 | div.fs-1.d-flex.flex-column#offcanvasNaviToggle 20 | a.d-flex.flex-grow-1(href="#" @click.stop.prevent="offcanvasNavi.show()") 21 | i.bi.bi-list.d-flex.align-self-center -------------------------------------------------------------------------------- /src/views/partials/importDialog.pug: -------------------------------------------------------------------------------- 1 | .modal(x-ref="importDialog") 2 | .modal-dialog.modal-xl 3 | .modal-content 4 | .modal-header 5 | h5.modal-title Import 6 | .modal-body 7 | template(x-if="importState.mode === 'data'") 8 | div 9 | p 10 | | Create a new project with 11 | | 12 | span(x-text="importState.data.chapters.length") 13 | | 14 | | chapters from 15 | | 16 | code(x-text="importState.name") 17 | | ? 18 | template(x-if="importState.mode === 'video'") 19 | div 20 | p 21 | | Choose whether to discard the current project 22 | | and create a new one from 23 | | 24 | code(x-text="importState.name") 25 | | 26 | | or keep the current chapters and use the video 27 | | for screenshots. If the video is longer than 28 | | the current duration, the timeline will be 29 | | be expanded 30 | 31 | .modal-footer.justify-content-evenly 32 | button.btn.btn-secondary(@click="importModal.hide()") cancel 33 | button.btn.btn-primary(x-show="importState.mode === 'data'", @click="newProject(importState.data)") create new project 34 | button.btn.btn-primary(x-show="importState.mode === 'video'", @click="attachVideo(importState.video)") new project 35 | button.btn.btn-info(x-show="importState.mode === 'video'", @click="attachVideo(importState.video, true)") only attach video 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/views/partials/mediaBox.pug: -------------------------------------------------------------------------------- 1 | div.col-12 2 | div#mediabox.d-none(x-show="hasVideo || hasAudio", :class="{'d-none' : mediaIsCollapsed}") 3 | video.w-100(x-ref="video", x-show="hasVideo",preload="metadata", controls) 4 | audio.w-100(x-ref="audio", x-show="hasAudio", preload="metadata", controls) 5 | -------------------------------------------------------------------------------- /src/views/partials/mediaExpandNotice.pug: -------------------------------------------------------------------------------- 1 | template(x-if="actualMediaDuration && actualMediaDuration > data.duration") 2 | div.alert.alert-info 3 | | The current duration 4 | | 5 | span(x-text="`(${secondsToTimestamp(data.duration)})`") 6 | | 7 | | is shorter than the video duration 8 | | 9 | span(x-text="`(${secondsToTimestamp(actualMediaDuration)})`") 10 | | . 11 | | 12 | a(href="#",@click.stop.prevent="adaptDuration") Expand duration 13 | template(x-if="actualMediaDuration && actualMediaDuration < data.duration") 14 | div 15 | template(x-if="data.chapters.at(-1) && data.chapters.at(-1).startTime < actualMediaDuration") 16 | div.alert.alert-info 17 | | The current duration 18 | | 19 | span(x-text="`(${secondsToTimestamp(data.duration)})`") 20 | | 21 | | is longer than the video duration 22 | | 23 | span(x-text="`(${secondsToTimestamp(actualMediaDuration)})`") 24 | | . 25 | | 26 | a(href="#",@click.stop.prevent="adaptDuration") Reduce duration 27 | template(x-if="data.chapters.at(-1) && data.chapters.at(-1).startTime > actualMediaDuration") 28 | div.alert.alert-danger 29 | | The current duration 30 | | 31 | span(x-text="`(${secondsToTimestamp(data.duration)})`") 32 | | 33 | | is longer than the video duration 34 | | 35 | span(x-text="`(${secondsToTimestamp(actualMediaDuration)})`") 36 | | 37 | | but there are chapters that start after that timestamp. -------------------------------------------------------------------------------- /src/views/partials/meta.pug: -------------------------------------------------------------------------------- 1 | meta(charset="utf-8") 2 | meta(name="viewport" content="width=device-width, initial-scale=1") 3 | 4 | // Primary Meta Tags 5 | - var title = 'chaptertool - manage chapters for podcasts, webvtt, youtube, mkv and ffmpeg' 6 | - var description = 'Create and convert chapters for podcasts, youtube, matroska, webvtt and ffmpeg. Extract snapshots from video files for podcasting 2.0 chapters.json' 7 | - var url = 'https://mtillmann.github.io/chaptertool' 8 | 9 | title= title 10 | meta(name="title" content=title) 11 | meta(name="description" content=description) 12 | 13 | // Open Graph / Facebook 14 | meta(property="og:type" content="website") 15 | meta(property="og:url" content=url) 16 | meta(property="og:title" content=title) 17 | meta(property="og:description" content=description) 18 | meta(property="og:image" itemprop="image" content=`${url}/icons/icon-512.png`) 19 | 20 | // Twitter 21 | meta(name="twitter:card" content="summary_large_image") 22 | meta(name="twitter:url" content=url) 23 | meta(name="twitter:title" content=title) 24 | meta(name="twitter:description" content=description) 25 | meta(name="twitter:image" content=`${url}/img/icon-512.png`) 26 | 27 | link(rel="apple-touch-icon" sizes="180x180" href=`${url}/icons/icon-180.png`) 28 | link(rel="icon" type="image/png" sizes="32x32" href=`${url}/icons/icon-32.png`) 29 | link(rel="icon" type="image/png" sizes="16x16" href=`${url}/icons/icon-16.png`) 30 | // this will throw a 404 on localhost 31 | link(rel="manifest" href=`/chaptertool/manifest.webmanifest`) 32 | -------------------------------------------------------------------------------- /src/views/partials/metaDialog.pug: -------------------------------------------------------------------------------- 1 | div.offcanvas.offcanvas-bottom#metaPropertiesDialog(x-ref="metaPropertiesDialog", style="--bs-offcanvas-height:initial;max-height:100vh") 2 | div.offcanvas-header 3 | h5.offcanvas-title Podcast Episode Properties 4 | |   5 | small 6 | | ( 7 | a(href="https://github.com/Podcastindex-org/podcast-namespace/blob/main/chapters/jsonChapters.md#chapters-object",target="_blank") spec 8 | | ) 9 | button.btn-close(data-bs-dismiss="offcanvas") 10 | div.offcanvas-body.pt-0 11 | p.small These properties are only used for chapters.json format! 12 | div.container-lg 13 | div.row.mb-2 14 | div.col-2 15 | label.form-label(for="chapterAuthor") Author 16 | div.col-4 17 | input.form-control#chapterAuthor(type="text", x-model="data.meta.author") 18 | small The name of the author of this podcast episode. 19 | div.col-2 20 | label.form-label(for="chapterTitle") Title 21 | div.col-4 22 | input.form-control#chapterTitle(type="text", x-model="data.meta.title") 23 | small The title of this podcast episode. 24 | div.row.mb-2 25 | div.col-2 26 | label.form-label(for="chapterPodcastName") PodcastName 27 | div.col-4 28 | input.form-control#chapterPodcastName(type="text", x-model="data.meta.podcastName") 29 | small The name of the podcast this episode belongs to. 30 | div.col-2 31 | label.form-label(for="chapterDescription") Description 32 | div.col-4 33 | input.form-control#chapterDescription(type="text", x-model="data.meta.description") 34 | small A description of this episode. 35 | div.row.mb-2 36 | div.col-2 37 | label.form-label(for="chapterFilename") Filename 38 | div.col-4 39 | input.form-control#chapterFilename(type="text", x-model="data.meta.filename") 40 | small The name of the audio file these chapters apply to. 41 | div.col-2 42 | label.form-label(for="chapterWaypoints") Waypoints 43 | div.col-4 44 | div.form-check.form-switch.mb-1 45 | input.form-check-input#chapterWaypoints(type="checkbox",x-model="data.meta.waypoints") 46 | small If this property is present, the locations in a chapter object should be displayed with a route between locations. 47 | div.row 48 | div.col-12.d-flex.justify-content-center 49 | button.btn.btn-primary(@click.stop.prevent="metaPropertiesDialog.hide()") save & close -------------------------------------------------------------------------------- /src/views/partials/noChapterSelected.pug: -------------------------------------------------------------------------------- 1 | template(x-if="currentChapterIndex === null") 2 | div.d-flex.align-items-center.justify-content-center 3 | div.text-center 4 | include mediaExpandNotice 5 | template(x-if="data.chapters.length > 0") 6 | div 7 | a(@click.stop.prevent="askForNewProject()", href="#") Create a new project 8 | hr 9 | div.d-none.d-lg-block 10 | | Paste or drop a video or audio file, or an existing chapters file here to start a new project 11 | | or select one 12 | | 13 | label.text-decoration-underline.cursor-pointer(for="importFileInput") from your disk 14 | hr 15 | div.d-lg-none 16 | label.text-decoration-underline.cursor-pointer(for="importFileInput") pick video/audio file, or an existing chapters file from your device 17 | hr 18 | template(x-if="data.chapters.length === 0") 19 | div 20 | a(@click.stop.prevent="addChapter(0)", href="#") Create a new chapter 21 | hr 22 | template(x-if="data.chapters.length > 0") 23 | div 24 | a(@click.stop.prevent="addChapterFromTime()", href="#") Insert new chapter at given time 25 | hr 26 | div 27 | | Select a chapter to edit its properties 28 | hr 29 | a(href="#", @click.stop.prevent="metaPropertiesDialog.show()") Edit Meta properties 30 | hr 31 | a(href="#",@click.stop.prevent="changeDuration") Change episode duration 32 | hr 33 | template(x-if="data.chapters.length > 0") 34 | div 35 | a(@click.stop.prevent="exportOffcanvas.show()", href="#") Export/download your project -------------------------------------------------------------------------------- /src/views/partials/offcanvasNavi.pug: -------------------------------------------------------------------------------- 1 | div.offcanvas.offcanvas-end#navi(x-ref="navi") 2 | div.offcanvas-body 3 | nav.nav.flex-column 4 | a.nav-link(href="#" @click.stop.prevent="offcanvasNavi.hide();askForNewProject()") New Project 5 | label.nav-link(for="importFileInput" @click="offcanvasNavi.hide();") Import File 6 | a.nav-link(href="#" @click.stop.prevent="offcanvasNavi.hide();addChapterFromTime()") Insert Chapter At Given Time 7 | a.nav-link(href="#" @click.stop.prevent="offcanvasNavi.hide();metaPropertiesDialog.show()") Edit Meta Attributes 8 | a.nav-link(href="#" @click.stop.prevent="offcanvasNavi.hide();changeDuration()") Change Duration 9 | a.nav-link.offcanvas-export-link(href="#" @click.stop.prevent="offcanvasNavi.hide();exportOffcanvas.show()") Export / Download 10 | a.nav-link(href="#" @click.stop.prevent="offcanvasNavi.hide();showSettingsDialog()") Settings 11 | hr 12 | div.nav-link(x-show="analyticsIsAvailable") 13 | div.form-check.form-switch 14 | input#analytics.form-check-input(type="checkbox", x-model="analyticsEnabled" @change="toggleGA()") 15 | label.form-check-label(for="analytics") Analytics Enabled 16 | div.nav-link.text-muted.disabled(x-show="!analyticsIsAvailable") 17 | | No Analytics Available - see console 18 | hr 19 | a.nav-link(href="#" @click.stop.prevent="showTourAgain()") Show Onboarding Tour 20 | a.nav-link(href="https://github.com/Mtillmann/chaptertool" target="_blank") Github Repo 21 | a.nav-link(href="https://github.com/Mtillmann/chaptertool/blob/main/faq.md" target="_blank") F.A.Q. 22 | hr 23 | span.nav-link.text-muted(x-text="versionString") 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/views/partials/settingsDialog.pug: -------------------------------------------------------------------------------- 1 | .modal(x-ref="settingsDialog") 2 | .modal-dialog.modal-xl 3 | .modal-content 4 | .modal-header 5 | h5.modal-title Settings 6 | .modal-body 7 | div Export Formats 8 | br 9 | p.small.mb-2 Controls which export formats are shown in the export dialog: 10 | div.d-flex.flex-wrap 11 | template(x-for="format in availableFormats", :key="format.key") 12 | div.form-check.me-2.mb-2 13 | input.form-check-input(:id="`sf-${format.key}`", type="checkbox", @change="toggleFormat(format.key)", :checked="selectedFormats.includes(format.key)") 14 | label.form-check-label(:for="`sf-${format.key}`", x-text="format.name") 15 | .modal-footer.justify-content-evenly 16 | button.btn.btn-secondary(@click="settingsModal.hide()") Close 17 | -------------------------------------------------------------------------------- /src/views/partials/timeline.pug: -------------------------------------------------------------------------------- 1 | div.timeline(:class="{'no-chapters' : data.chapters.length === 0}") 2 | div.backdrop 3 | div.ratio 4 | div.marker 5 | div(x-text="chapterBelowIndex") 6 | div.btn-group.btn-group-micro.shadow 7 | a.btn.btn-outline-secondary.has-tooltip.insert(href="#") 8 | i.bi.bi-bookmark-plus 9 | a.btn.btn-outline-secondary.has-tooltip.update-below(href="#", title="update chapter below image", x-show="hasVideo && chapterBelowIndex !== false", @click.stop.prevent="addImageFromVideoToChapter(chapterBelowIndex)") 10 | i.bi.bi-arrow-bar-down 11 | a.btn.btn-outline-secondary.has-tooltip.update-active(href="#", title="update selected chapter's image",x-show="hasVideo && currentChapterIndex !== null", @click.stop.prevent="addImageFromVideoToChapter(null)") 12 | i.bi.bi-arrow-down-square 13 | a.btn.btn-outline-secondary.remove(href="#") 14 | i.bi.bi-x 15 | div.hover-indicator 16 | div.timecode 17 | div.chapters 18 | div.drag-handle -------------------------------------------------------------------------------- /src/views/partials/timelineControls.pug: -------------------------------------------------------------------------------- 1 | div.col-12 2 | div#timeline-controls.d-flex.justify-content-end 3 | div 4 | template(x-if="hasVideo") 5 | a.has-tooltip( 6 | title="hide/show video" 7 | href="#" 8 | @click.stop.prevent="toggleMedia" 9 | ) 10 | i.bi(:class="{'bi-eye' : mediaIsCollapsed, 'bi-eye-slash' : !mediaIsCollapsed}") 11 | a.has-tooltip( 12 | href="#" 13 | title="toggle chapter/timeline lock" 14 | @click.stop.prevent="toggleChapterLock" 15 | ) 16 | i.bi(:class="{'bi-unlock' : !chapterLock, 'bi-lock': chapterLock}") -------------------------------------------------------------------------------- /src/views/partials/timestampDialog.pug: -------------------------------------------------------------------------------- 1 | div.offcanvas.offcanvas-bottom#timestampEditDialog(x-ref="timestampedit", style="--bs-offcanvas-height:initial;max-height:100vh") 2 | div.offcanvas-header 3 | h5.offcanvas-title(x-text="editTimestampLabel") 4 | button.btn-close(data-bs-dismiss="offcanvas") 5 | div.offcanvas-body 6 | div.container 7 | div.row 8 | div.col-12.col-lg-4.offset-lg-4 9 | form 10 | div.input-group 11 | input.form-control(:min="editTimestampBounds.min", :max="editTimestampBounds.max", type="time", step="1", :value="editTimestampTimestamp[0]") 12 | input.form-control(type="number", min=0, max = 999, step=1, :value="editTimestampTimestamp[1]") 13 | button.btn.btn-outline-secondary set 14 | span.small.text-muted hint: use arrow keys, tab and enter 15 | div(x-show="usesAMPM") 16 | span.small.text-info Using 12h format, ignore the AM/PM value -------------------------------------------------------------------------------- /static/fonts/bootstrap-icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mtillmann/chaptertool/47a48244073a08f7a2499512864dfc9e78dcb89e/static/fonts/bootstrap-icons.woff -------------------------------------------------------------------------------- /static/fonts/bootstrap-icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mtillmann/chaptertool/47a48244073a08f7a2499512864dfc9e78dcb89e/static/fonts/bootstrap-icons.woff2 -------------------------------------------------------------------------------- /static/icons/Icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mtillmann/chaptertool/47a48244073a08f7a2499512864dfc9e78dcb89e/static/icons/Icon-16.png -------------------------------------------------------------------------------- /static/icons/Icon-180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mtillmann/chaptertool/47a48244073a08f7a2499512864dfc9e78dcb89e/static/icons/Icon-180.png -------------------------------------------------------------------------------- /static/icons/Icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mtillmann/chaptertool/47a48244073a08f7a2499512864dfc9e78dcb89e/static/icons/Icon-192.png -------------------------------------------------------------------------------- /static/icons/Icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mtillmann/chaptertool/47a48244073a08f7a2499512864dfc9e78dcb89e/static/icons/Icon-32.png -------------------------------------------------------------------------------- /static/icons/Icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mtillmann/chaptertool/47a48244073a08f7a2499512864dfc9e78dcb89e/static/icons/Icon-512.png -------------------------------------------------------------------------------- /static/icons/Icon-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mtillmann/chaptertool/47a48244073a08f7a2499512864dfc9e78dcb89e/static/icons/Icon-64.png -------------------------------------------------------------------------------- /static/icons/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mtillmann/chaptertool/47a48244073a08f7a2499512864dfc9e78dcb89e/static/icons/icon-16.png -------------------------------------------------------------------------------- /static/icons/icon-180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mtillmann/chaptertool/47a48244073a08f7a2499512864dfc9e78dcb89e/static/icons/icon-180.png -------------------------------------------------------------------------------- /static/icons/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mtillmann/chaptertool/47a48244073a08f7a2499512864dfc9e78dcb89e/static/icons/icon-192.png -------------------------------------------------------------------------------- /static/icons/icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mtillmann/chaptertool/47a48244073a08f7a2499512864dfc9e78dcb89e/static/icons/icon-32.png -------------------------------------------------------------------------------- /static/icons/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mtillmann/chaptertool/47a48244073a08f7a2499512864dfc9e78dcb89e/static/icons/icon-512.png -------------------------------------------------------------------------------- /static/icons/icon-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mtillmann/chaptertool/47a48244073a08f7a2499512864dfc9e78dcb89e/static/icons/icon-64.png -------------------------------------------------------------------------------- /static/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chaptertool", 3 | "short_name": "ctool", 4 | "start_url": "/chaptertool/", 5 | "display": "standalone", 6 | "background_color": "#111111", 7 | "lang": "en", 8 | "scope": "/chaptertool/", 9 | "description": "chapter management tool", 10 | "theme_color": "#111111", 11 | "orientation": "natural", 12 | "icons": [ 13 | { 14 | "src": "icons/icon-192.png", 15 | "sizes": "192x192", 16 | "type": "image/png" 17 | }, 18 | { 19 | "src": "icons/icon-512.png", 20 | "sizes": "512x512", 21 | "type": "image/png" 22 | }, 23 | { 24 | "src": "icons/icon-512.png", 25 | "sizes": "512x512", 26 | "type": "image/png" 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /static/sw.js: -------------------------------------------------------------------------------- 1 | // Licensed under a CC0 1.0 Universal (CC0 1.0) Public Domain Dedication 2 | // http://creativecommons.org/publicdomain/zero/1.0/ 3 | 4 | // HTML files: try the network first, then the cache. 5 | // Other files: try the cache first, then the network. 6 | // Both: cache a fresh version if possible. 7 | // (beware: the cache will grow and grow; there's no cleanup) 8 | 9 | const cacheName = 'files' 10 | 11 | addEventListener('fetch', fetchEvent => { 12 | return // return always?! will this work 13 | 14 | const request = fetchEvent.request 15 | if (request.method !== 'GET') { 16 | return 17 | } 18 | const url = new URL(request.url) 19 | if (url.origin !== location.origin) { 20 | return 21 | } 22 | 23 | fetchEvent.respondWith(async function () { 24 | const fetchPromise = fetch(request) 25 | fetchEvent.waitUntil(async function () { 26 | const responseFromFetch = await fetchPromise 27 | const responseCopy = responseFromFetch.clone() 28 | const myCache = await caches.open(cacheName) 29 | return myCache.put(request, responseCopy) 30 | }()) 31 | if (request.headers.get('Accept').includes('text/html')) { 32 | try { 33 | return await fetchPromise 34 | } catch (error) { 35 | return caches.match(request) 36 | } 37 | } else { 38 | const responseFromCache = await caches.match(request) 39 | return responseFromCache || fetchPromise 40 | } 41 | }()) 42 | }) 43 | -------------------------------------------------------------------------------- /static/version: -------------------------------------------------------------------------------- 1 | 0.5.2 --------------------------------------------------------------------------------