├── .gitignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── ffmpeg-templates.ts ├── lib ├── bindings │ ├── detect_hardware_acceleration.ts │ └── ffmpeg.ts ├── cli.ts ├── errors.ts ├── float_math.ts ├── font.ts ├── geometry.ts ├── logger.ts ├── mod.ts ├── parsers │ ├── duration.ts │ ├── ffmpeg_output.ts │ ├── template.ts │ └── unit.ts ├── probe.ts ├── template_input.ts ├── timeline.ts └── zoompan.ts └── test ├── .gitignore ├── cli.test.ts ├── fixtures ├── empty_preview │ └── ffmpeg.sh ├── speed │ └── ffmpeg.sh └── zoompan │ └── ffmpeg.sh ├── import-map.json ├── mock └── bindings │ └── ffmpeg.ts └── resources ├── assets ├── Pexels Videos 2048452.mp4 └── Video Of People Waiting For A Taxi On A Rainy Night.mp4 ├── empty_preview.yml ├── speed.yml └── zoompan.yml /.gitignore: -------------------------------------------------------------------------------- 1 | samples 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "printWidth": 110, 5 | "trailingComma": "es5" 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Andrew Kaiser 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ffmpeg-templates 2 | 3 | - [Installation](#Installation) 4 | - [Usage](#Usage) 5 | - [Getting Started](#Getting-Started) 6 | - [Template Syntax](#Template-Syntax) 7 | - [Typescript Interface](#Typescript-Interface) 8 | - [Documentation](https://doc.deno.land/https/raw.githubusercontent.com/andykais/ffmpeg-templates/main/lib/template_input.ts#Template) 9 | 10 | 11 | ## Installation 12 | Requires [ffmpeg](https://ffmpeg.org/download.html) and [deno](https://deno.land) >= 1.5.0 13 | ```bash 14 | deno install --allow-read --allow-run --unstable -f https://raw.githubusercontent.com/andykais/ffmpeg-templates/main/ffmpeg-templates.ts 15 | ``` 16 | 17 | 18 | ## Usage 19 | ``` 20 | ffmpeg-templates v0.2.0 21 | 22 | Usage: ffmpeg-templates [] [options] 23 | 24 | ARGS: 25 | Path to a YAML or JSON template file which defines the structure of 26 | the outputted video 27 | 28 | The folder in which the output and generated assets will be saved to. 29 | When not specified, a folder will be created adjacent to the template. 30 | 31 | OPTIONS: 32 | --preview Instead of outputting the whole video, output a single frame as a jpg. 33 | Use this flag to set up your layouts and iterate quickly. 34 | 35 | --open Open the outputted file after it is rendered. 36 | 37 | --watch Run continously when the template file changes. This is most useful 38 | in tandem with --preview. 39 | 40 | --develop Alias for running "--watch --preview --open" 41 | 42 | --quiet Do not print a progress bar 43 | 44 | --debug Write debug information to a file 45 | 46 | --help Print this message. 47 | ``` 48 | 49 | ## Getting Started 50 | ```bash 51 | # create a video from a template 52 | ffmpeg-templates template.yml output.mp4 53 | 54 | # render a single frame from the output at the specified timestamp 55 | # and continuously re-render when the template file is changed 56 | ffmpeg-templates template.yml output.jpg --render-sample-frame 00:00:03 --watch 57 | ``` 58 | 59 | ### Template Syntax 60 | A video project is defined using a template file like this example one below. Think of this like the video 61 | editor GUI. Full documentation exists [here](https://doc.deno.land/https/raw.githubusercontent.com/andykais/ffmpeg-templates/main/lib/template_input.ts#Template) 62 | ```yaml 63 | clips: 64 | # specify clips in an array, the only field necessary is 'file' 65 | - file: './some-neato-video.mp4' 66 | 67 | - file: './another-clip.mp4' 68 | layout: # use the layout field to position and size the clip in the output 69 | width: '50%' # lots of fields can accept percentages of the total size of the output 70 | x: 71 | offset: '12px' # regular pixel inputs are also accepted most places 72 | align: 'right' # snap this clip to the righthand side 73 | crop: # sometimes you may want to crop a clip, this is also optional 74 | left: '10%' 75 | 76 | - file: './something-really-long.mp4' 77 | id: 'BACKGROUND_VIDEO' # you can specify an id for the timeline below 78 | speed: '50%' # slow down or speed up a video 79 | trim: { end: 'fit' } # this video is too long, so lets make sure it is trimmed to the length of the next-longest video 80 | 81 | # by default, all clips are started at the same time, but you can use the timeline to change up that order. 82 | # Lets start one video in the background, and then play two other clips on top of it, one after the other. 83 | timeline: 84 | 00:00:00: 85 | - ['BACKGROUND_VIDEO'] 86 | - [CLIP_0, CLIP_1] 87 | ``` 88 | 89 | ## Javascript Interface 90 | ```ts 91 | import { render_video, render_sample_frame } from 'https://raw.githubusercontent.com/andykais/ffmpeg-templates/main/lib/mod.ts' 92 | 93 | 94 | const template = { clips: [{ file: './input.mp4' }] } 95 | const output_folder = 'output' 96 | const options = { cwd: '~/Projects' } 97 | await render_video(template, output_filepath, options) 98 | ``` 99 | 100 | ## Motivation 101 | So why does this exist? There are countless video editors out there, and this little command line program cant 102 | possibly match their feature set, so why use this? 103 | 104 | In the end, it comes down to your opinions on GUI programs. I believe that there is a lot less interesting 105 | things that can be done with a traditional video editor. This isn't to say this little program can do more, 106 | but it does open the door for reusability, and automation in ways that a GUI never could. For instance, if I 107 | wanted to truly make a _template_ where one video is swapped out for another, its a single line change in the 108 | template file. Doing the same thing inside a GUI program is not nearly as trivial. It would mean opening a 109 | project file, massaging the new clip to the same shape, size and length as an old clip, and placing it again. 110 | 111 | `ffmpeg-templates` 112 | is really just nicer syntax on top of what the ffmpeg program already offers, but it offers an easy to pick up 113 | syntax and schema. Everything that this program can do, is defined in a single [schema file](./template_input.ts). No 114 | complicated tutorials, no hidden settings in a application preferences. Its just a bare bones video editor. 115 | 116 | ## Roadmap 117 | (please ignore this mess) 118 | - [X] Cache probed clip information in watch mode 119 | - [ ] Cache trimmed clips in watch mode 120 | - [ ] Support audio only inputs 121 | - [ ] Add `--render-sample-thumbnails [num_thumbnails]` flag as alternative to `--render-sample-frame` 122 | - [ ] [REJECTED]Make `--render-sample-frame` interactive (e.g., -> moves forward one frame, `<-` backward. `Shift` + `->` Skips ahead 1 second) 123 | - [X] Add trim.stop or a similar word to signify trimming to a 'stop' timestamp (trim.end trims in reverse). A negative duration on trim.end would work as well. 124 | - [X] Add `clip[].speed` filter (`setpts={speed}*PTS`) 125 | - [ ] Alternatively to implementing more terminal-ui things, we could create a real web page which has the 126 | preview window and a timeline. All still config driven. The preview window does however let you change 127 | what timestamp the preview is of 128 | - [X] Replace fractions with percentages. All units are either `10%` or `10px` 129 | - [X] Add `--preview` flag. Opens image previews and uses a field in the template for previews 130 | - use feh: `feh --image-bg '#1e1c1c' --scale-down --title ` 131 | - use eog 132 | - use imagemagick's display: `display -update 1.0 ` 133 | - [x] Intelligently inspect previews. Only include clips that are relevant to the desired frame. 134 | - [x] support duration expressions like `"00:02:12 - 00:00:03.4"` 135 | - [X] support image inputs 136 | - [X] support font inputs 137 | - [X] add timeline variables 138 | - [X] add rotation clip option 139 | - [ ] durations should support '00:00:00' and '00:00' and '00' and '0' 140 | - [ ] zoompan 141 | - during previews, arrows should represent where the zoom is going from and going to 142 | - automatic face tracking option? 143 | - [ ] create placeholder loading image (from imagemagick) that immediately shows up on preview 144 | - [ ] add warning about unused clips in timeline 145 | - [ ] report this one as a bug? 146 | - [ ] add 'smoothing' option. Just unsure what the name of it would be 147 | - `interpolate_frames: 60`? 148 | - `smooth: 30`? 149 | - `smooth_fps: 30`? 150 | - `smooth_frames: 30`? 151 | ``` 152 | 2s [----------------------------------------------] 0.0%error: Uncaught (in promise) Busy: Resource is unavailable because it is in use by a promise 153 | at processResponse (deno:core/core.js:223:11) 154 | at Object.jsonOpSync (deno:core/core.js:246:12) 155 | at Object.consoleSize (deno:runtime/js/40_tty.js:7:17) 156 | at progress_callback (ffmpeg-templates.ts:60:30) 157 | at copied_options.progress_callback (ffmpeg-templates.ts:96:54) 158 | at ffmpeg (mod.ts:508:9) 159 | at async render (mod.ts:645:3) 160 | at async render_sample_frame (mod.ts:659:10) 161 | at async try_render_video (ffmpeg-templates.ts:103:9) 162 | ``` 163 | - [X] make `YAMLError`s recoverable 164 | - [ ] make background color a parameter 165 | - [ ] add border param 166 | - [X] add transitions 167 | - [X] cross fade 168 | - [ ] screen wipe? 169 | - [ ] crop width/height percentage instead of left vs right? 170 | - [ ] input error on `crop <= 0` 171 | - [ ] make framerate configurable `framerate: { fps: 60, smooth: true }` 172 | - cache some of the font asset creation work 173 | - add `-pix_fmt yuv420p` to get better compatability 174 | - trim.stop_total where stop is performed against _total_ time. 175 | - when showing preview, hint with the next keyframe (not really helpful actually) 176 | - cancel previous render when file is changed 177 | - improve preview time by seeking inputs right to the desired frame. Unsure if there are implications, but it 178 | should work! 179 | - if we run into more memory topping out issues, we can try out [segment muxer](https://askubuntu.com/a/948449/390949) (or possibly do it by hand) 180 | - reverse the stacking order. Lower things in the timeline appear on top of other things. It makes more sense 181 | - some kind of audio cues in the preview window. Its impossible to line up audio only clips with the preview 182 | system right now 183 | - secondary command for building music videos? General workflow would be: 184 | ``` 185 | ffmpeg-music-video 186 | > "Tap [spacebar] whenever you hear a beat that you want a clip to start at. Ready? Y/n" 187 | > ...[space]..[space]....[space]...etc 188 | > "3 marker(s) recorded for clips. Preview markers, reset markers, or add clips? Pp/Rr/Cc" 189 | > "Input a folder containing clips. Clips will be added in alphabetical order:" 190 | > "Clips and timeline have been recorded to template.yml. Render video now? Y/n" 191 | ``` 192 | - audio visualizations to help with previews? https://www.youtube.com/watch?v=M58rc7cxl_s 193 | - template.layers or template.stack or template.order to separate the vertical ordering from the timeline? Not necessary, but possibly more clear 194 | - add CI testing (install ffmpeg via apt, or use a cache: https://stackoverflow.com/questions/59269850/caching-apt-packages-in-github-actions-workflow) 195 | -------------------------------------------------------------------------------- /ffmpeg-templates.ts: -------------------------------------------------------------------------------- 1 | import * as flags from 'https://deno.land/std@0.91.0/flags/mod.ts' 2 | import ffmpeg_templates from './lib/cli.ts' 3 | 4 | 5 | const VERSION = 'v0.2.0' 6 | 7 | 8 | const args = flags.parse(Deno.args) 9 | if (args._.length < 1 || args._.length > 2 || args['help']) { 10 | console.error(`ffmpeg-templates ${VERSION} 11 | 12 | Usage: ffmpeg-templates [] [options] 13 | 14 | ARGS: 15 | Path to a YAML or JSON template file which defines the structure of 16 | the outputted video 17 | 18 | The folder in which the output and generated assets will be saved to. 19 | When not specified, a folder will be created adjacent to the template. 20 | 21 | OPTIONS: 22 | --preview Instead of outputting the whole video, output a single frame as a jpg. 23 | Use this flag to set up your layouts and iterate quickly. 24 | 25 | --open Open the outputted file after it is rendered. 26 | 27 | --watch Run continously when the template file changes. This is most useful 28 | in tandem with --preview. 29 | 30 | --develop Alias for running "--watch --preview --open" 31 | 32 | --quiet Do not print a progress bar 33 | 34 | --debug Write debug information to a file 35 | 36 | --help Print this message.`) 37 | Deno.exit(args['help'] ? 0 : 1) 38 | } 39 | await ffmpeg_templates(...Deno.args) 40 | -------------------------------------------------------------------------------- /lib/bindings/detect_hardware_acceleration.ts: -------------------------------------------------------------------------------- 1 | async function exec(...cmd: string[]) { 2 | const decoder = new TextDecoder() 3 | const proc = Deno.run({ cmd, stdout: 'piped' }) 4 | const status = await proc.status() 5 | const stdout = await proc.output() 6 | proc.close() 7 | const output = decoder.decode(stdout) 8 | if (status.code !== 0) throw new Error(`CommandError: ${output}`) 9 | else return output 10 | } 11 | 12 | const QSV_FFMPEG_ARGS = { input_decoder: [], filter: [], video_encoder: ['-codec:v', 'h264_qsv'] } 13 | // const QSV_FFMPEG_ARGS = { input_decoder: [], filter: ['-init_hw_device', 'qsv=hw', '-filter_hw_device', 'hw'], video_encoder: ['-codec:v', 'h264_qsv'] } 14 | const GPU_HARDWARE_ACCELERATION_MAP = { 15 | intel: [{ requires: { device: 'qsv', video_encoder: 'h264_qsv' }, ffmpeg_args: QSV_FFMPEG_ARGS }], 16 | nvidia: [], 17 | amd: [], 18 | } 19 | 20 | function get_gpu_type(graphics_card: string): 'intel' | 'nvidia' | 'amd' | undefined { 21 | const graphics_card_lowercase = graphics_card.toLowerCase() 22 | const is_type = (names: string[]) => names.some((name) => graphics_card_lowercase.includes(name)) 23 | if (is_type(['intel', 'i965'])) return 'intel' 24 | if (is_type(['amd', 'mesa'])) return 'amd' 25 | if (is_type(['nvidia'])) return 'nvidia' 26 | } 27 | 28 | async function get_available_ffmpeg_hw_devices(): Promise { 29 | const output = await exec('ffmpeg', '-v', 'error', '-init_hw_device', 'list') 30 | return output.trim().split('\n').slice(1) 31 | } 32 | 33 | async function get_available_ffmpeg_hw_video_encoders(): Promise { 34 | const output = await exec('ffmpeg', '-v', 'error', '-encoders') 35 | return output.split('\n').filter(s => s.startsWith(' V.....')).map(s => s.split(' ')[2]).slice(1) 36 | } 37 | 38 | interface FfmpegHWAccelArgs { 39 | input_decoder: string[] 40 | filter: string[] 41 | video_encoder: string[] 42 | } 43 | async function get_hardware_acceleration_options(): Promise { 44 | const gpu_adapter = await navigator.gpu.requestAdapter() 45 | if (!gpu_adapter) return undefined 46 | const graphics_card: string = gpu_adapter.name 47 | const gpu_type = get_gpu_type(graphics_card) 48 | if (!gpu_type) return undefined 49 | const gpu_type_hw_accel_options = GPU_HARDWARE_ACCELERATION_MAP[gpu_type] 50 | 51 | const [devices, encoders] = await Promise.all([ 52 | get_available_ffmpeg_hw_devices(), 53 | get_available_ffmpeg_hw_video_encoders(), 54 | ]) 55 | 56 | for (const { requires, ffmpeg_args } of gpu_type_hw_accel_options) { 57 | if (devices.includes(requires.device) && encoders.includes(requires.video_encoder)) return ffmpeg_args 58 | } 59 | } 60 | 61 | 62 | export { get_hardware_acceleration_options } 63 | -------------------------------------------------------------------------------- /lib/bindings/ffmpeg.ts: -------------------------------------------------------------------------------- 1 | import * as io from 'https://deno.land/std@0.91.0/io/mod.ts' 2 | import { InputError, CommandError } from '../errors.ts' 3 | import { parse_duration } from '../parsers/duration.ts' 4 | import type { ClipID } from '../template_input.ts' 5 | import type * as template_parsed from '../parsers/template.ts' 6 | import type { Timestamp } from '../template_input.ts' 7 | 8 | type OnReadLine = (line: string) => void 9 | async function exec(cmd: string[]) { 10 | const decoder = new TextDecoder() 11 | const proc = Deno.run({ cmd, stdout: 'piped' }) 12 | const result = await proc.status() 13 | const output_buffer = await proc.output() 14 | const output = decoder.decode(output_buffer) 15 | await proc.stdout.close() 16 | await proc.close() 17 | if (result.success) { 18 | return output 19 | } else { 20 | throw new CommandError(`Command "${cmd.join(' ')}" failed.\n\n${output}`) 21 | } 22 | } 23 | 24 | type FfmpegProgress = { 25 | out_time: Timestamp 26 | progress: 'continue' | 'end' 27 | speed: string 28 | percentage: number 29 | } 30 | type OnProgress = (percentage: number) => void 31 | async function ffmpeg( 32 | template: template_parsed.Template, 33 | ffmpeg_cmd: (string | number)[], 34 | longest_duration: number, 35 | progress_callback?: OnProgress 36 | ) { 37 | const ffmpeg_safe_cmd = ffmpeg_cmd.map((a) => a.toString()) 38 | if (progress_callback) { 39 | ffmpeg_safe_cmd.push('-progress', 'pipe:1') 40 | const proc = Deno.run({ cmd: ffmpeg_safe_cmd, stdout: 'piped', stdin: 'inherit' }) 41 | let progress: Partial = {} 42 | for await (const line of io.readLines(proc.stdout!)) { 43 | const [key, value] = line.split('=') 44 | ;(progress as any)[key] = value 45 | if (key === 'progress') { 46 | const ffmpeg_percentage = parse_duration(progress.out_time!, template) / longest_duration 47 | const percentage = Math.max(0, Math.min(1, ffmpeg_percentage)) 48 | progress_callback(percentage) 49 | progress = {} 50 | } 51 | } 52 | const result = await proc.status() 53 | await proc.stdout.close() 54 | await proc.close() 55 | if (!result.success) { 56 | throw new CommandError(`Command "${ffmpeg_safe_cmd.join(' ')}" failed.\n\n`) 57 | } 58 | } else { 59 | await exec(ffmpeg_safe_cmd) 60 | } 61 | } 62 | 63 | export { ffmpeg } 64 | export type { OnProgress, FfmpegProgress } 65 | -------------------------------------------------------------------------------- /lib/cli.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'https://deno.land/std@0.91.0/path/mod.ts' 2 | import * as fs from 'https://deno.land/std@0.91.0/fs/mod.ts' 3 | import * as flags from 'https://deno.land/std@0.91.0/flags/mod.ts' 4 | import * as yaml from 'https://deno.land/std@0.91.0/encoding/yaml.ts' 5 | import { opn } from "https://denopkg.com/hashrock/deno-opn/opn.ts"; 6 | import * as errors from './errors.ts' 7 | import { Logger } from './logger.ts' 8 | import { render_video, render_sample_frame, get_output_locations } from './mod.ts' 9 | import type { Template, RenderOptions, FfmpegProgress } from './mod.ts' 10 | 11 | const encoder = new TextEncoder() 12 | const decoder = new TextDecoder() 13 | 14 | // TODO DRY this up when fonts are added to clips 15 | async function create_loading_placeholder_preview(output_path: string) { 16 | const proc = Deno.run({ 17 | cmd: [ 18 | 'magick', 19 | '-size', 20 | '500x500', 21 | 'xc:', 22 | '-gravity', 23 | 'Center', 24 | '-pointsize', 25 | '24', 26 | '-annotate', 27 | '0', 28 | 'Loading Preview...', 29 | output_path, 30 | ], 31 | }) 32 | await Deno.mkdir(path.dirname(output_path), { recursive: true }) 33 | const result = await proc.status() 34 | if (result.code !== 0) { 35 | // console.error(await proc.output()) 36 | throw new Error('Magick exception') 37 | } 38 | return proc 39 | } 40 | 41 | function construct_output_folder(args: flags.Args, template_filepath: string) { 42 | const { dir, name } = path.parse(template_filepath) 43 | const render_ext = args['preview'] ? '.jpg' : '.mp4' 44 | return path.join(dir, 'ffmpeg-templates-projects', dir, `${name}`) 45 | } 46 | 47 | function human_readable_duration(duration_seconds: number): string { 48 | if (duration_seconds / 60 >= 100) return `${(duration_seconds / 60 / 60).toFixed(1)}h` 49 | else if (duration_seconds >= 100) return `${(duration_seconds / 60).toFixed(1)}m` 50 | else return `${duration_seconds.toFixed(0)}s` 51 | } 52 | 53 | let writing_progress_bar = false 54 | let queued_progress: { execution_start_time: number; percentage: number } | null = null 55 | async function progress_callback(execution_start_time: number, percentage: number) { 56 | if (writing_progress_bar) { 57 | queued_progress = { execution_start_time, percentage } 58 | return 59 | } 60 | writing_progress_bar = true 61 | const console_width = await Deno.consoleSize(Deno.stdout.rid).columns 62 | // const unicode_bar = '\u2588' 63 | const unicode_bar = '#' 64 | const execution_time_seconds = (performance.now() - execution_start_time) / 1000 65 | const prefix = `${human_readable_duration(execution_time_seconds).padStart(4)} [` 66 | const suffix = `] ${(percentage * 100).toFixed(1)}%` 67 | const total_bar_width = console_width - prefix.length - suffix.length 68 | const bar = unicode_bar.repeat(Math.min(percentage, 1) * total_bar_width) 69 | const message = `\r${prefix}${bar.padEnd(total_bar_width, '-')}${suffix}` 70 | await Deno.writeAll(Deno.stdout, encoder.encode(message)) 71 | writing_progress_bar = false 72 | if (queued_progress) { 73 | const args = queued_progress 74 | queued_progress = null 75 | progress_callback(args.execution_start_time, args.percentage) 76 | } 77 | } 78 | 79 | async function read_template(template_filepath: string): Promise