Monopoly — Portage moderne 0.1.0
Portage moderne C/C++23 du Monopoly de 1999
Loading...
Searching...
No Matches
VideoDecoder.cpp
Go to the documentation of this file.
1#include "VideoDecoder.hpp"
2
3#include <SDL3/SDL.h>
4
5#include <algorithm>
6#include <array>
7#include <atomic>
8#include <charconv>
9#include <chrono>
10#include <cmath>
11#include <condition_variable>
12#include <deque>
13#include <limits>
14#include <mutex>
15#include <sstream>
16#include <string_view>
17#include <thread>
18#include <utility>
19
20#if defined(_WIN32)
21#ifndef NOMINMAX
22#define NOMINMAX
23#endif
24#ifndef WIN32_LEAN_AND_MEAN
25#define WIN32_LEAN_AND_MEAN
26#endif
27#include <windows.h>
28#endif
29
30namespace monopoly::video
31{
32 namespace
33 {
34 using Clock = std::chrono::steady_clock;
35 constexpr std::size_t diagnosticLimit = 16384;
36 constexpr std::size_t probeLimit = 65536;
37 constexpr std::size_t audioChunkBytes = 960 * 4;
38 constexpr std::uint64_t maximumDuration = 24ULL * 60 * 60 * 1000000;
39
40 std::string utf8Path(const std::filesystem::path& path)
41 {
42 const auto bytes = path.u8string();
43 return {reinterpret_cast<const char*>(bytes.data()), bytes.size()};
44 }
45
46 std::string seconds(std::uint64_t microseconds)
47 {
48 auto fraction = std::to_string(microseconds % 1000000);
49 return std::to_string(microseconds / 1000000) + "." +
50 std::string(6 - fraction.size(), '0') + fraction;
51 }
52
53 template<class T>
54 bool number(std::string_view text, T& value)
55 {
56 const auto result = std::from_chars(text.data(), text.data() + text.size(), value);
57 return result.ec == std::errc{} && result.ptr == text.data() + text.size();
58 }
59
60 bool rational(std::string_view text, std::uint32_t& numerator,
61 std::uint32_t& denominator)
62 {
63 const auto slash = text.find('/');
64 if (slash == std::string_view::npos ||
65 !number(text.substr(0, slash), numerator) ||
66 !number(text.substr(slash + 1), denominator)) return false;
68 denominator <= 1000000 &&
69 static_cast<double>(numerator) / denominator <= 240.0;
70 }
71
72 std::expected<DecoderMetadata, std::string> parseProbe(std::string_view text)
73 {
74 DecoderMetadata result;
75 struct Stream
76 {
77 std::string kind, codec, average, rate;
78 std::uint32_t width{}, height{}, sampleRate{};
79 double duration{};
80 } stream;
81 bool inStream = false;
82 double formatDuration{};
83 std::istringstream input{std::string(text)};
84 std::string line;
85 while (std::getline(input, line))
86 {
87 if (!line.empty() && line.back() == '\r') line.pop_back();
88 if (line == "[STREAM]") { stream = {}; inStream = true; continue; }
89 if (line == "[/STREAM]")
90 {
91 if (stream.kind == "video" && result.videoCodec.empty())
92 {
93 result.width = stream.width;
94 result.height = stream.height;
95 result.videoCodec = stream.codec;
96 if (!rational(stream.average, result.frameRateNumerator,
97 result.frameRateDenominator) &&
98 !rational(stream.rate, result.frameRateNumerator,
99 result.frameRateDenominator))
100 return std::unexpected("FFprobe: video has no supported rational cadence (maximum 240 fps)");
101 if (std::isfinite(stream.duration) && stream.duration > 0 &&
102 stream.duration <= static_cast<double>(maximumDuration / 1000000))
103 result.durationMicroseconds = static_cast<std::uint64_t>(stream.duration * 1000000.0);
104 }
105 else if (stream.kind == "audio" && !result.hasAudio)
106 {
107 result.hasAudio = true;
108 result.audioSampleRate = stream.sampleRate;
109 result.audioCodec = stream.codec;
110 }
111 inStream = false;
112 continue;
113 }
114 const auto split = line.find('=');
115 if (split == std::string::npos) continue;
116 const std::string_view key(line.data(), split);
117 const std::string_view value(line.data() + split + 1, line.size() - split - 1);
118 if (!inStream)
119 {
120 if (key == "duration") number(value, formatDuration);
121 }
122 else if (key == "codec_type") stream.kind = value;
123 else if (key == "codec_name") stream.codec = value;
124 else if (key == "width") number(value, stream.width);
125 else if (key == "height") number(value, stream.height);
126 else if (key == "avg_frame_rate") stream.average = value;
127 else if (key == "r_frame_rate") stream.rate = value;
128 else if (key == "sample_rate") number(value, stream.sampleRate);
129 else if (key == "duration") number(value, stream.duration);
130 }
131 if (result.videoCodec.empty() || result.width == 0 || result.height == 0)
132 return std::unexpected("FFprobe: input has no video stream with valid dimensions");
133 if (result.width > 8192 || result.height > 8192)
134 return std::unexpected("FFprobe: video dimension exceeds the 8192-pixel limit");
135 if (result.durationMicroseconds == 0 && std::isfinite(formatDuration) &&
136 formatDuration > 0 && formatDuration <= static_cast<double>(maximumDuration / 1000000))
137 result.durationMicroseconds = static_cast<std::uint64_t>(formatDuration * 1000000.0);
138 if (result.durationMicroseconds == 0)
139 return std::unexpected("FFprobe: input has no finite movie duration within the 24-hour limit");
140 return result;
141 }
142
143 // All methods, including destruction, run on the decoder's single worker.
144 // SDL process pipes are nonblocking, so cancellation never waits for IO.
145 struct Child
146 {
152 bool exited{};
153 int exitCode{};
154 std::string diagnostics;
155 std::string ioError;
156#if defined(_WIN32)
158#endif
159
160 ~Child() { close(); }
161 Child() = default;
162 Child(const Child&) = delete;
163 Child& operator=(const Child&) = delete;
164
165 void close() noexcept
166 {
167 if (!process) return;
168 if (!exited)
169 {
170 SDL_KillProcess(process, true);
171 SDL_WaitProcess(process, true, &exitCode);
172 }
173#if defined(_WIN32)
175 exitStatusHandle = nullptr;
176#endif
177 SDL_DestroyProcess(process);
178 process = nullptr;
179 output = errors = nullptr;
180 }
181
182 std::expected<void, std::string> start(const std::vector<std::string>& arguments)
183 {
184 std::vector<const char*> argv;
185 argv.reserve(arguments.size() + 1);
186 for (const auto& argument : arguments) argv.push_back(argument.c_str());
187 argv.push_back(nullptr);
188 const auto properties = SDL_CreateProperties();
189 if (!properties) return std::unexpected(std::string("SDL process properties: ") + SDL_GetError());
190 bool configured = SDL_SetPointerProperty(properties,
195#if defined(_WIN32)
196 // SDL's background flag supplies CREATE_NO_WINDOW, including when
197 // Monopoly has no parent console. SDL then deliberately masks the
198 // exit code; a query-only handle retains the real Windows status.
199 configured = configured && SDL_SetBooleanProperty(properties,
201#endif
202 if (configured) process = SDL_CreateProcessWithProperties(properties);
203 const std::string failure = process ? std::string{} : SDL_GetError();
204 SDL_DestroyProperties(properties);
205 if (!process) return std::unexpected("Cannot start " + arguments.front() +
206 "; install FFmpeg/FFprobe or configure their executable paths: " + failure);
207#if defined(_WIN32)
211 static_cast<DWORD>(processId));
212 if (!exitStatusHandle)
213 return std::unexpected("Cannot retain decoder process exit status (Windows error " +
214 std::to_string(GetLastError()) + ")");
215#endif
216 output = SDL_GetProcessOutput(process);
219 if (!output || !errors) return std::unexpected("SDL did not create the requested decoder pipes");
220 return {};
221 }
222
223 std::size_t read(SDL_IOStream* pipe, void* destination, std::size_t capacity,
224 bool& ended)
225 {
226 if (ended) return 0;
227 const auto count = SDL_ReadIO(pipe, destination, capacity);
228 const auto status = SDL_GetIOStatus(pipe);
229 if (status == SDL_IO_STATUS_EOF) ended = true;
230 else if (status == SDL_IO_STATUS_ERROR)
231 {
232 ended = true;
234 }
235 return count;
236 }
237
238 void poll()
239 {
240 std::array<char, 4096> buffer{};
241 // Continue draining after the diagnostic cap, avoiding a stderr
242 // pipe deadlock without retaining unbounded child output.
243 for (int readCount = 0; readCount < 8 && !errorEnded; ++readCount)
244 {
245 const auto count = read(errors, buffer.data(), buffer.size(), errorEnded);
246 diagnostics.append(buffer.data(), std::min(count, diagnosticLimit - diagnostics.size()));
247 if (count == 0) break;
248 }
249 if (!exited)
250 {
251 exited = SDL_WaitProcess(process, false, &exitCode);
252#if defined(_WIN32)
253 if (exited)
254 {
257 exitCode = static_cast<int>(actualExitCode);
258 else ioError = "Cannot query decoder process exit status (Windows error " +
259 std::to_string(GetLastError()) + ")";
260 }
261#endif
262 }
263 }
264
265 std::string failure(std::string_view operation) const
266 {
267 return std::string(operation) + " (exit " + std::to_string(exitCode) + "): " +
268 (ioError.empty() ? diagnostics : ioError);
269 }
270 };
271 }
272
274 {
275 mutable std::mutex mutex;
276 std::condition_variable wake;
277 std::atomic<bool> stopping{false};
278 std::thread worker;
281 std::filesystem::path file;
282 std::deque<DecodedVideoFrame> video;
283 std::deque<DecodedAudioChunk> audio;
284 std::optional<std::uint64_t> pendingSeek;
285
286 void pause()
287 {
288 std::unique_lock lock(mutex);
289 wake.wait_for(lock, std::chrono::milliseconds(2));
290 }
291
292 void fail(std::string error)
293 {
294 std::lock_guard lock(mutex);
295 if (stopping.load()) return;
297 state.error = std::move(error);
298 }
299
300 std::expected<DecoderMetadata, std::string> probe()
301 {
302 Child child;
303 const auto started = child.start({options.ffprobe, "-v", "error",
304 "-protocol_whitelist", "file,pipe", "-show_entries",
305 "stream=codec_type,codec_name,width,height,avg_frame_rate,r_frame_rate,sample_rate,duration:format=duration",
306 "-of", "default=noprint_wrappers=0", utf8Path(file)});
307 if (!started) return std::unexpected(started.error());
308 const auto deadline = Clock::now() + std::chrono::milliseconds(options.probeTimeoutMilliseconds);
309 std::string text;
310 std::array<char, 4096> buffer{};
311 while (!stopping.load())
312 {
313 child.poll();
314 const auto count = child.read(child.output, buffer.data(), buffer.size(), child.outputEnded);
315 if (text.size() + count > probeLimit)
316 return std::unexpected("FFprobe metadata exceeds the 64 KiB limit");
317 text.append(buffer.data(), count);
318 if (!child.ioError.empty()) return std::unexpected(child.failure("FFprobe IO failure"));
319 if (child.outputEnded && child.errorEnded && child.exited)
320 {
321 if (child.exitCode != 0) return std::unexpected(child.failure("FFprobe rejected input"));
322 return parseProbe(text);
323 }
324 if (Clock::now() >= deadline) return std::unexpected("FFprobe metadata timeout");
325 if (count == 0) pause();
326 }
327 return std::unexpected("Video probe cancelled");
328 }
329
330 std::vector<std::string> command(std::uint64_t offset, bool isVideo,
331 const DecoderMetadata& metadata) const
332 {
333 std::vector<std::string> args{options.ffmpeg, "-nostdin", "-hide_banner", "-v", "error", "-xerror",
334 "-max_alloc", "67108864", "-threads", "1", "-protocol_whitelist", "file,pipe",
335 "-ss", seconds(offset), "-noautorotate", "-i", utf8Path(file),
336 "-t", seconds(metadata.durationMicroseconds - offset), "-threads", "1"};
337 if (isVideo)
338 {
339 const std::string filter = "fps=fps=" + std::to_string(metadata.frameRateNumerator) +
340 "/" + std::to_string(metadata.frameRateDenominator) + ":start_time=0,scale=" +
341 std::to_string(metadata.width) + ":" + std::to_string(metadata.height);
342 args.insert(args.end(), {"-map", "0:v:0", "-an", "-sn", "-dn", "-vf", filter,
343 "-fps_mode", "passthrough", "-pix_fmt", "rgba", "-c:v", "rawvideo",
344 "-f", "rawvideo", "pipe:1"});
345 }
346 else args.insert(args.end(), {"-map", "0:a:0", "-vn", "-sn", "-dn",
347 "-af", "aresample=48000:async=1:first_pts=0", "-ac", "2", "-ar", "48000",
348 "-c:a", "pcm_s16le", "-f", "s16le", "pipe:1"});
349 return args;
350 }
351
352 // Returns to the outer loop on seek, EOF, failure or cancellation.
353 void decode(const DecoderMetadata& metadata, std::uint64_t offset,
354 std::uint64_t generation)
355 {
356 Child videoChild, audioChild;
357 std::expected<void, std::string> started;
359 {
360 started = videoChild.start(command(offset, true, metadata));
361 if (!started) { fail(started.error()); return; }
362 }
363 if (metadata.hasAudio && options.decodeAudio)
364 {
365 started = audioChild.start(command(offset, false, metadata));
366 if (!started) { fail(started.error()); return; }
367 }
368 const auto frameBytes = static_cast<std::size_t>(metadata.width) * metadata.height * 4;
369 std::vector<std::uint8_t> frame(frameBytes), samples(audioChunkBytes);
370 std::size_t frameUsed{}, samplesUsed{};
371 std::uint64_t frameIndex{}, sampleIndex{};
373 bool audioDone = !metadata.hasAudio || !options.decodeAudio;
374 {
375 std::lock_guard lock(mutex);
376 if (generation != state.generation) return;
379 }
380 auto lastProgress = Clock::now();
381 while (!stopping.load())
382 {
383 bool videoRoom{}, audioRoom{};
384 {
385 std::lock_guard lock(mutex);
386 if (generation != state.generation) return;
389 }
390 if (options.decodeVideo) videoChild.poll();
391 if (metadata.hasAudio && options.decodeAudio) audioChild.poll();
392 bool progressed = false;
393 if (!videoDone && videoRoom)
394 {
395 const auto count = videoChild.read(videoChild.output, frame.data() + frameUsed,
396 frame.size() - frameUsed, videoChild.outputEnded);
397 frameUsed += count;
398 progressed |= count != 0;
399 if (frameUsed == frame.size())
400 {
402 metadata.frameRateDenominator / metadata.frameRateNumerator,
403 metadata.width, metadata.height, std::move(frame)};
404 {
405 std::lock_guard lock(mutex);
406 if (generation != state.generation) return;
407 video.push_back(std::move(ready));
408 }
409 frame.resize(frameBytes);
410 frameUsed = 0;
411 ++frameIndex;
412 }
413 if (videoChild.outputEnded && videoChild.errorEnded && videoChild.exited)
414 {
415 if (videoChild.exitCode != 0 || !videoChild.ioError.empty())
416 { fail(videoChild.failure("FFmpeg video decoder " + metadata.videoCodec)); return; }
417 if (frameUsed != 0 || frameIndex == 0)
418 { fail("FFmpeg video decoder " + metadata.videoCodec + " returned an incomplete or empty RGBA stream"); return; }
419 const auto expectedFrames = (metadata.durationMicroseconds - offset) *
420 metadata.frameRateNumerator / (1000000ULL * metadata.frameRateDenominator);
421 // Allow one frame for endpoint rounding at a fractional
422 // seek position; a silently truncated movie is not EOF.
423 if (frameIndex + 1 < expectedFrames)
424 { fail("FFmpeg video decoder " + metadata.videoCodec + " ended before the declared movie duration"); return; }
425 videoDone = true;
426 std::lock_guard lock(mutex);
427 if (generation == state.generation) state.videoEnded = true;
428 }
429 }
430 if (!audioDone && audioRoom)
431 {
432 const auto count = audioChild.read(audioChild.output, samples.data() + samplesUsed,
433 samples.size() - samplesUsed, audioChild.outputEnded);
435 progressed |= count != 0;
436 if (samplesUsed == samples.size() || (audioChild.outputEnded && samplesUsed != 0))
437 {
438 if (samplesUsed % 4 != 0)
439 { fail("FFmpeg audio decoder " + metadata.audioCodec + " returned incomplete stereo S16LE PCM"); return; }
440 samples.resize(samplesUsed);
444 {
445 std::lock_guard lock(mutex);
446 if (generation != state.generation) return;
447 audio.push_back(std::move(ready));
448 }
449 samples.resize(audioChunkBytes);
450 samplesUsed = 0;
451 }
452 if (audioChild.outputEnded && audioChild.errorEnded && audioChild.exited)
453 {
454 if (audioChild.exitCode != 0 || !audioChild.ioError.empty())
455 { fail(audioChild.failure("FFmpeg audio decoder " + metadata.audioCodec)); return; }
456 audioDone = true;
457 std::lock_guard lock(mutex);
458 if (generation == state.generation) state.audioEnded = true;
459 }
460 }
461 if (videoDone && audioDone)
462 {
463 std::lock_guard lock(mutex);
464 if (generation == state.generation) state.phase = DecoderPhase::Ended;
465 return;
466 }
467 if (progressed || (!videoDone && !videoRoom) || (!audioDone && !audioRoom))
468 lastProgress = Clock::now();
469 else if (Clock::now() - lastProgress > std::chrono::seconds(30))
470 { fail("FFmpeg decode timed out without output for video codec " + metadata.videoCodec); return; }
471 if (!progressed) pause();
472 }
473 }
474
476 {
477 try
478 {
479 const auto metadata = probe();
480 if (!metadata) { fail(metadata.error()); return; }
481 const auto frameBytes = static_cast<std::uint64_t>(metadata->width) * metadata->height * 4;
483 { fail("Decoded RGBA frame exceeds the configured video memory limit"); return; }
484 {
485 std::lock_guard lock(mutex);
488 }
489 std::uint64_t offset{}, generation{};
490 while (!stopping.load())
491 {
492 {
493 std::lock_guard lock(mutex);
494 generation = state.generation;
495 if (pendingSeek) { offset = *pendingSeek; pendingSeek.reset(); }
497 state.error.clear();
498 }
499 decode(*metadata, offset, generation);
500 if (stopping.load()) break;
501 std::unique_lock lock(mutex);
502 if (state.phase == DecoderPhase::Failed) break;
503 wake.wait(lock, [&] { return stopping.load() || pendingSeek.has_value(); });
504 }
505 }
506 catch (const std::exception& error) { fail(std::string("Video decoder worker: ") + error.what()); }
507 catch (...) { fail("Video decoder worker: unknown failure"); }
508 }
509 };
510
511 Decoder::Decoder() : impl_(std::make_unique<Impl>()) {}
513
514 std::expected<void, std::string> Decoder::open(const std::filesystem::path& file,
515 const DecoderOptions& options)
516 {
517 if (options.ffmpeg.empty() || options.ffprobe.empty())
518 return std::unexpected("FFmpeg and FFprobe executable paths must not be empty");
519 if ((!options.decodeVideo && !options.decodeAudio) ||
520 options.videoQueueFrames == 0 || options.videoQueueFrames > 16 ||
521 options.audioQueueChunks == 0 || options.audioQueueChunks > 256 ||
522 options.maximumFrameBytes < 4 || options.maximumFrameBytes > 16U * 1024U * 1024U ||
523 options.videoQueueFrames * options.maximumFrameBytes > 64U * 1024U * 1024U ||
524 options.probeTimeoutMilliseconds == 0 || options.probeTimeoutMilliseconds > 60000)
525 return std::unexpected("Invalid decoder limits (RGBA queues maximum 64 MiB, frame maximum 16 MiB)");
526 std::error_code error;
527 const auto absolute = std::filesystem::absolute(file, error);
528 if (error || !std::filesystem::is_regular_file(absolute, error))
529 return std::unexpected("Video input is not a readable local file: " + utf8Path(file));
530 stop();
531 impl_->options = options;
532 impl_->file = absolute;
533 impl_->stopping.store(false);
534 {
535 std::lock_guard lock(impl_->mutex);
536 impl_->state.phase = DecoderPhase::Probing;
537 ++impl_->state.generation;
538 }
539 try { impl_->worker = std::thread([this] { impl_->run(); }); }
540 catch (const std::exception& exception)
541 {
542 const auto failure = std::string("Cannot start video decoder worker: ") + exception.what();
543 impl_->fail(failure);
544 return std::unexpected(failure);
545 }
546 return {};
547 }
548
549 std::expected<void, std::string> Decoder::seek(std::uint64_t timestampMicroseconds)
550 {
551 {
552 std::lock_guard lock(impl_->mutex);
553 if (!impl_->state.metadata || impl_->state.phase == DecoderPhase::Failed ||
554 impl_->state.phase == DecoderPhase::Stopped)
555 return std::unexpected("Cannot seek before movie metadata is ready or after decoder failure");
556 if (timestampMicroseconds >= impl_->state.metadata->durationMicroseconds)
557 return std::unexpected("Video seek position is outside the movie duration");
558 impl_->video.clear();
559 impl_->audio.clear();
560 impl_->pendingSeek = timestampMicroseconds;
561 ++impl_->state.generation;
562 impl_->state.phase = DecoderPhase::Decoding;
563 impl_->state.videoEnded = false;
564 impl_->state.audioEnded = false;
565 }
566 impl_->wake.notify_all();
567 return {};
568 }
569
571 {
572 impl_->stopping.store(true);
573 impl_->wake.notify_all();
574 if (impl_->worker.joinable()) impl_->worker.join();
575 std::lock_guard lock(impl_->mutex);
576 impl_->video.clear();
577 impl_->audio.clear();
578 impl_->pendingSeek.reset();
579 const auto generation = impl_->state.generation;
580 impl_->state = {};
581 impl_->state.generation = generation;
582 }
583
585 {
586 std::lock_guard lock(impl_->mutex);
587 auto state = impl_->state;
588 state.queuedVideoFrames = impl_->video.size();
589 state.queuedAudioChunks = impl_->audio.size();
590 return state;
591 }
592
593 std::optional<DecodedVideoFrame> Decoder::popVideo()
594 {
595 std::lock_guard lock(impl_->mutex);
596 if (impl_->video.empty()) return std::nullopt;
597 auto frame = std::move(impl_->video.front());
598 impl_->video.pop_front();
599 impl_->wake.notify_all();
600 return frame;
601 }
602
603 std::optional<DecodedAudioChunk> Decoder::popAudio()
604 {
605 std::lock_guard lock(impl_->mutex);
606 if (impl_->audio.empty()) return std::nullopt;
607 auto chunk = std::move(impl_->audio.front());
608 impl_->audio.pop_front();
609 impl_->wake.notify_all();
610 return chunk;
611 }
612}
std::size_t count
std::size_t offset
fs::path absolute
std::string_view text
LegacyBitmapMetadata metadata
std::vector< std::byte > bytes
std::uint64_t key
std::uint32_t height
std::uint32_t width
unsigned state
unsigned index
Kind kind
std::size_t frame
bool exited
SDL_Process * process
SDL_IOStream * errors
HelpClock::time_point deadline
std::string diagnostics
std::string path
std::string error
Bytes input
std::deque< Bytes > output
Clock::time_point lastProgress
std::uint16_t value
Definition Timers.cpp:16
bool errorEnded
std::string ioError
int exitCode
bool outputEnded
std::optional< DecodedVideoFrame > popVideo()
std::expected< void, std::string > seek(std::uint64_t timestampMicroseconds)
std::expected< void, std::string > open(const std::filesystem::path &file, const DecoderOptions &options={})
std::optional< DecodedAudioChunk > popAudio()
DecoderSnapshot snapshot() const
static constexpr std::uint32_t sampleRate
std::optional< DecoderMetadata > metadata
std::deque< DecodedVideoFrame > video
std::expected< DecoderMetadata, std::string > probe()
std::deque< DecodedAudioChunk > audio
std::vector< std::string > command(std::uint64_t offset, bool isVideo, const DecoderMetadata &metadata) const
void decode(const DecoderMetadata &metadata, std::uint64_t offset, std::uint64_t generation)
std::optional< std::uint64_t > pendingSeek
void fail(std::string error)
std::condition_variable wake