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 }