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 }