1 /+ 2 == pixmaprecorder == 3 Copyright Elias Batek (0xEAB) 2024. 4 Distributed under the Boost Software License, Version 1.0. 5 +/ 6 /++ 7 $(B Pixmap Recorder) is an auxiliary library for rendering video files from 8 [arsd.pixmappaint.Pixmap|Pixmap] frames by piping them to 9 [FFmpeg](https://ffmpeg.org/about.html). 10 11 $(SIDEBAR 12 Piping frame data into an independent copy of FFmpeg 13 enables this library to be used with a wide range of versions of said 14 third-party program 15 and (hopefully) helps to reduce the potential for breaking changes. 16 17 It also allows end-users to upgrade their possibilities by swapping the 18 accompanying copy FFmpeg. 19 20 This could be useful in cases where software distributors can only 21 provide limited functionality in their bundled binaries because of 22 legal requirements like patent licenses. 23 Keep in mind, support for more formats can be added to FFmpeg by 24 linking it against external libraries; such can also come with 25 additional distribution requirements that must be considered. 26 These things might be perceived as extra burdens and can make their 27 inclusion a matter of viability for distributors. 28 ) 29 30 ### Tips and tricks 31 32 $(TIP 33 The FFmpeg binary to be used can be specified by the optional 34 constructor parameter `ffmpegExecutablePath`. 35 36 It defaults to `ffmpeg`; this will trigger the usual lookup procedures 37 of the system the application runs on. 38 On POSIX this usually means searching for FFmpeg in the directories 39 specified by the environment variable PATH. 40 On Windows it will also look for an executable file with that name in 41 the current working directory. 42 ) 43 44 $(TIP 45 The value of the `outputFormat` parameter of various constructor 46 overloads is passed to FFmpeg via the `-f` (“format”) option. 47 48 Run `ffmpeg -formats` to get a list of available formats. 49 ) 50 51 $(TIP 52 To pass additional options to FFmpeg, use the 53 [PixmapRecorder.advancedFFmpegAdditionalOutputArgs|additional-output-args property]. 54 ) 55 56 $(TIP 57 Combining this module with [arsd.pixmappresenter|Pixmap Presenter] 58 is really straightforward. 59 60 In the most simplistic case, set up a [PixmapRecorder] before running 61 the presenter. 62 Then call 63 [PixmapRecorder.put|pixmapRecorder.record(presenter.framebuffer)] 64 at the end of the drawing callback in the eventloop. 65 66 --- 67 auto recorder = new PixmapRecorder(60, /* … */); 68 scope(exit) { 69 const recorderStatus = recorder.stopRecording(); 70 } 71 72 return presenter.eventLoop(delegate() { 73 // […] 74 recorder.record(presenter.framebuffer); 75 return LoopCtrl.redrawIn(16); 76 } 77 --- 78 ) 79 80 $(TIP 81 To use this module with [arsd.color] (which includes the image file 82 loading functionality provided by other arsd modules), 83 convert the 84 [arsd.color.TrueColorImage|TrueColorImage] or 85 [arsd.color.MemoryImage|MemoryImage] to a 86 [arsd.pixmappaint.Pixmap|Pixmap] first by calling 87 [arsd.pixmappaint.Pixmap.fromTrueColorImage|Pixmap.fromTrueColorImage()] 88 or 89 [arsd.pixmappaint.Pixmap.fromMemoryImage|Pixmap.fromMemoryImage()] 90 respectively. 91 ) 92 93 ### Examples 94 95 #### Getting started 96 97 1. Install FFmpeg (the CLI version). 98 - Debian derivatives (with FFmpeg in their repos): `apt install ffmpeg` 99 - Homebew: `brew install ffmpeg` 100 - Chocolatey: `choco install ffmpeg` 101 - Links to pre-built binaries can be found on <https://ffmpeg.org/download.html>. 102 2. Determine where you’ve installed FFmpeg to. 103 Ideally, it’s somewhere within “PATH” so it can be run from the 104 command-line by just doing `ffmpeg`. 105 Otherwise, you’ll need the specific path to the executable to pass it 106 to the constructor of [PixmapRecorder]. 107 108 --- 109 import arsd.pixmaprecorder; 110 import arsd.pixmappaint; 111 112 /++ 113 This demo renders a 1280×720 video at 30 FPS 114 fading from white (#FFF) to blue (#00F). 115 +/ 116 int main() { 117 // Instantiate a recorder. 118 auto recorder = new PixmapRecorder( 119 30, // Video framerate [=FPS] 120 "out.mkv", // Output path to write the video file to. 121 ); 122 123 // We will use this framebuffer later on to provide image data 124 // to the encoder. 125 auto frame = Pixmap(1280, 720); 126 127 for (int light = 0xFF; light >= 0; --light) { 128 auto color = Color(light, light, 0xFF); 129 frame.clear(color); 130 131 // Record the current frame. 132 // The video resolution to use is derived from the first frame. 133 recorder.put(frame); 134 } 135 136 // End and finalize the recording process. 137 return recorder.stopRecording(); 138 } 139 --- 140 +/ 141 module arsd.pixmaprecorder; 142 143 import arsd.pixmappaint; 144 145 import std.format; 146 import std.path : buildPath; 147 import std.process; 148 import std.range : isOutputRange, OutputRange; 149 import std.sumtype; 150 import std.stdio : File; 151 152 private @safe { 153 154 auto stderrFauxSafe() @trusted { 155 import std.stdio : stderr; 156 157 return stderr; 158 } 159 160 auto stderr() { 161 return stderrFauxSafe; 162 } 163 164 alias RecorderOutput = SumType!(string, File); 165 } 166 167 /++ 168 Video file encoder 169 170 Feed in video data frame by frame to encode video files 171 in one of the various formats supported by FFmpeg. 172 173 This is a convenience wrapper for piping pixmaps into FFmpeg. 174 FFmpeg will render an actual video file from the frame data. 175 This uses the CLI version of FFmpeg, no linking is required. 176 +/ 177 final class PixmapRecorder : OutputRange!(const(Pixmap)) { 178 179 private { 180 string _ffmpegExecutablePath; 181 double _frameRate; 182 string _outputFormat; 183 RecorderOutput _output; 184 File _log; 185 string[] _outputAdditionalArgs; 186 187 Pid _pid; 188 Pipe _input; 189 Size _resolution; 190 bool _outputIsOurs = false; 191 } 192 193 @safe: 194 195 private this( 196 string ffmpegExecutablePath, 197 double frameRate, 198 string outputFormat, 199 RecorderOutput output, 200 File log, 201 ) { 202 _ffmpegExecutablePath = ffmpegExecutablePath; 203 _frameRate = frameRate; 204 _outputFormat = outputFormat; 205 _output = output; 206 _log = log; 207 } 208 209 /++ 210 Prepares a recorder for encoding a video file into the provided pipe. 211 212 $(WARNING 213 FFmpeg cannot produce certain formats in pipes. 214 Look out for error messages such as: 215 216 $(BLOCKQUOTE 217 `[mp4 @ 0xdead1337beef] muxer does not support non-seekable output` 218 ) 219 220 This is not a limitation of this library (but rather one of FFmpeg). 221 222 Nevertheless, it’s still possible to use the affected formats. 223 Let FFmpeg output the video to the file path instead; 224 check out the other constructor overloads. 225 ) 226 227 Params: 228 frameRate = Framerate of the video output; in frames per second. 229 output = File handle to write the video output to. 230 outputFormat = Video (container) format to output. 231 This value is passed to FFmpeg via the `-f` option. 232 log = Target file for the stderr log output of FFmpeg. 233 This is where error messages are written to. 234 ffmpegExecutablePath = Path to the FFmpeg executable 235 (e.g. `ffmpeg`, `ffmpeg.exe` or `/usr/bin/ffmpeg`). 236 237 $(COMMENT Keep this table in sync with the ones of other overloads.) 238 +/ 239 public this( 240 double frameRate, 241 File output, 242 string outputFormat, 243 File log = stderr, 244 string ffmpegExecutablePath = "ffmpeg", 245 ) 246 in (frameRate > 0) 247 in (output.isOpen) 248 in (outputFormat != "") 249 in (log.isOpen) 250 in (ffmpegExecutablePath != "") { 251 this( 252 ffmpegExecutablePath, 253 frameRate, 254 outputFormat, 255 RecorderOutput(output), 256 log, 257 ); 258 } 259 260 /++ 261 Prepares a recorder for encoding a video file 262 saved to the specified path. 263 264 $(TIP 265 This allows FFmpeg to seek through the output file 266 and enables the creation of file formats otherwise not supported 267 when using piped output. 268 ) 269 270 Params: 271 frameRate = Framerate of the video output; in frames per second. 272 outputPath = File path to write the video output to. 273 Existing files will be overwritten. 274 FFmpeg will use this to autodetect the format 275 when no `outputFormat` is provided. 276 log = Target file for the stderr log output of FFmpeg. 277 This is where error messages are written to, as well. 278 outputFormat = Video (container) format to output. 279 This value is passed to FFmpeg via the `-f` option. 280 If `null`, the format is not provided and FFmpeg 281 will try to autodetect the format from the filename 282 of the `outputPath`. 283 ffmpegExecutablePath = Path to the FFmpeg executable 284 (e.g. `ffmpeg`, `ffmpeg.exe` or `/usr/bin/ffmpeg`). 285 286 $(COMMENT Keep this table in sync with the ones of other overloads.) 287 +/ 288 public this( 289 double frameRate, 290 string outputPath, 291 File log = stderr, 292 string outputFormat = null, 293 string ffmpegExecutablePath = "ffmpeg", 294 ) 295 in (frameRate > 0) 296 in ((outputPath != "") && (outputPath != "-")) 297 in (log.isOpen) 298 in ((outputFormat is null) || outputFormat != "") 299 in (ffmpegExecutablePath != "") { 300 301 // Sanitize the output path 302 // if it were to get confused with a command-line arg. 303 // Otherwise a relative path like `-my.mkv` would make FFmpeg complain 304 // about an “Unrecognized option 'out.mkv'”. 305 if (outputPath[0] == '-') { 306 outputPath = buildPath(".", outputPath); 307 } 308 309 this( 310 ffmpegExecutablePath, 311 frameRate, 312 null, 313 RecorderOutput(outputPath), 314 log, 315 ); 316 } 317 318 /++ 319 $(I Advanced users only:) 320 Additional command-line arguments to be passed to FFmpeg. 321 322 $(WARNING 323 The values provided through this property function are not 324 validated and passed verbatim to FFmpeg. 325 ) 326 327 $(PITFALL 328 If code makes use of this and FFmpeg errors, 329 check the arguments provided here first. 330 ) 331 +/ 332 void advancedFFmpegAdditionalOutputArgs(string[] args) { 333 _outputAdditionalArgs = args; 334 } 335 336 /++ 337 Determines whether the recorder is active 338 (which implies that an output file is open). 339 +/ 340 bool isOpen() { 341 return _input.writeEnd.isOpen; 342 } 343 344 /// ditto 345 alias isRecording = isOpen; 346 347 private string[] buildFFmpegCommand() pure { 348 // Build resolution as understood by FFmpeg. 349 const string resolutionString = format!"%sx%s"( 350 _resolution.width, 351 _resolution.height, 352 ); 353 354 // Convert framerate to string. 355 const string frameRateString = format!"%s"(_frameRate); 356 357 // Build command-line argument list. 358 auto cmd = [ 359 _ffmpegExecutablePath, 360 "-y", 361 "-r", 362 frameRateString, 363 "-f", 364 "rawvideo", 365 "-pix_fmt", 366 "rgba", 367 "-s", 368 resolutionString, 369 "-i", 370 "-", 371 ]; 372 373 if (_outputFormat !is null) { 374 cmd ~= "-f"; 375 cmd ~= _outputFormat; 376 } 377 378 if (_outputAdditionalArgs.length > 0) { 379 cmd = cmd ~ _outputAdditionalArgs; 380 } 381 382 cmd ~= _output.match!( 383 (string filePath) => filePath, 384 (ref File file) => "-", 385 ); 386 387 return cmd; 388 } 389 390 /++ 391 Starts the video encoding process. 392 Launches FFmpeg. 393 394 This function sets the video resolution for the encoding process. 395 All frames to record must match it. 396 397 $(SIDEBAR 398 Variable/dynamic resolution is neither supported by this library 399 nor by most real-world applications. 400 ) 401 402 $(NOTE 403 This function is called by [put|put()] automatically. 404 There’s usually no need to call this manually. 405 ) 406 +/ 407 void open(const Size resolution) 408 in (!this.isOpen) { 409 // Save resolution for sanity checks. 410 _resolution = resolution; 411 412 const string[] cmd = buildFFmpegCommand(); 413 414 // Prepare arsd → FFmpeg I/O pipe. 415 _input = pipe(); 416 417 // Launch FFmpeg. 418 const processConfig = ( 419 Config.suppressConsole 420 | Config.newEnv 421 ); 422 423 // dfmt off 424 _pid = _output.match!( 425 delegate(string filePath) { 426 auto stdout = pipe(); 427 stdout.readEnd.close(); 428 return spawnProcess( 429 cmd, 430 _input.readEnd, 431 stdout.writeEnd, 432 _log, 433 null, 434 processConfig, 435 ); 436 }, 437 delegate(File file) { 438 auto stdout = pipe(); 439 stdout.readEnd.close(); 440 return spawnProcess( 441 cmd, 442 _input.readEnd, 443 file, 444 _log, 445 null, 446 processConfig, 447 ); 448 } 449 ); 450 // dfmt on 451 } 452 453 /// ditto 454 alias startRecording = close; 455 456 /++ 457 Supplies the next frame to the video encoder. 458 459 $(TIP 460 This function automatically calls [open|open()] if necessary. 461 ) 462 +/ 463 void put(const Pixmap frame) { 464 if (!this.isOpen) { 465 this.open(frame.size); 466 } else { 467 assert(frame.size == _resolution, "Variable resolutions are not supported."); 468 } 469 470 _input.writeEnd.rawWrite(frame.data); 471 } 472 473 /// ditto 474 alias record = put; 475 476 /++ 477 Ends the recording process. 478 479 $(NOTE 480 Waits for the FFmpeg process to exit in a blocking way. 481 ) 482 483 Returns: 484 The status code provided by the FFmpeg program. 485 +/ 486 int close() { 487 if (!this.isOpen) { 488 return 0; 489 } 490 491 _input.writeEnd.flush(); 492 _input.writeEnd.close(); 493 scope (exit) { 494 _input.close(); 495 } 496 497 return wait(_pid); 498 } 499 500 /// ditto 501 alias stopRecording = close; 502 } 503 504 // self-test 505 private { 506 static assert(isOutputRange!(PixmapRecorder, Pixmap)); 507 static assert(isOutputRange!(PixmapRecorder, const(Pixmap))); 508 }