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