#include "core/video.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "core/canvas.hpp" #include "core/effect.hpp" extern char** environ; namespace effector { int VideoSettings::frameCount() const { const double seconds = std::clamp(duration, kMinDuration, kMaxDuration); const int rate = std::clamp(fps, kMinFps, kMaxFps); return std::max(1, static_cast(std::lround(seconds * rate))); } const std::vector& videoPresets() { // A story segment runs fifteen seconds, a reel is best kept to half a // minute, and a loop is short enough to watch go round a few times. static const std::vector presets = { {"instagram-story", "Instagram story", "instagram-story", 15.0, 30}, {"instagram-reel", "Instagram reel", "instagram-story", 30.0, 30}, {"instagram-feed", "Instagram feed video", "instagram-portrait", 10.0, 30}, {"vertical-short", "TikTok / YouTube Short", "vertical-hd", 30.0, 30}, {"square-loop", "Square loop", "instagram-post", 6.0, 30}, {"hd-clip", "Full HD clip", "hd", 10.0, 30}, }; return presets; } const VideoPreset* videoPresetByKey(const std::string& key) { if (key.empty()) return nullptr; for (const auto& preset : videoPresets()) { if (preset.key == key) return &preset; } return nullptr; } const std::vector& videoQualities() { // Standard is what every video was written with before there was a // choice: CRF 18 is close to what the eye can tell from the frames. static const std::vector qualities = { {"small", "Small file", "smaller and quicker to share, with visible softening", 26, "fast", 40, 128}, {kDefaultVideoQuality, "Standard", "close to the rendered frames at a moderate size", 18, "medium", 32, 256}, {"high", "High", "as faithful as it gets, larger and slower to write", 12, "slow", 24, 256}, }; return qualities; } const VideoQuality* videoQualityByKey(const std::string& key) { for (const auto& quality : videoQualities()) { if (quality.key == key) return &quality; } return nullptr; } const VideoQuality& videoQualityOrDefault(const std::string& key) { if (const auto* quality = videoQualityByKey(key)) return *quality; return *videoQualityByKey(kDefaultVideoQuality); } // In the C locale whatever the user's is, for the same reason as the // millimetres of a paper size: "7.5 s", never "7,5 s". std::string formatSeconds(double seconds) { std::ostringstream out; out.imbue(std::locale::classic()); out.setf(std::ios::fixed); out.precision(1); out << seconds; std::string text = out.str(); if (text.size() > 2 && text.compare(text.size() - 2, 2, ".0") == 0) text.resize(text.size() - 2); return text + " s"; } std::string videoPresetDetail(const VideoPreset& preset) { const auto* format = canvasFormatByKey(preset.canvasFormat); std::string text = format != nullptr ? canvasFormatDetail(*format) + " ยท " : std::string(); return text + formatSeconds(preset.seconds); } namespace { std::string lowerExtension(const std::string& path) { std::string extension = std::filesystem::path(path).extension().string(); std::transform(extension.begin(), extension.end(), extension.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); return extension; } // The container ffmpeg is told to write, by extension; empty for one that // is not a video. std::string muxerFor(const std::string& extension) { if (extension == ".mp4") return "mp4"; if (extension == ".mov") return "mov"; if (extension == ".mkv") return "matroska"; if (extension == ".webm") return "webm"; if (extension == ".gif") return "gif"; return {}; } } // namespace bool isVideoPath(const std::string& path) { return !muxerFor(lowerExtension(path)).empty(); } bool ffmpegAvailable() { const char* path = std::getenv("PATH"); if (path == nullptr) return false; std::stringstream dirs(path); std::string dir; while (std::getline(dirs, dir, ':')) { if (dir.empty()) dir = "."; if (::access((dir + "/ffmpeg").c_str(), X_OK) == 0) return true; } return false; } VideoWriter::VideoWriter(const std::string& path, int width, int height, int fps, const std::string& quality) : path_(path), width_(width), height_(height) { const std::string muxer = muxerFor(lowerExtension(path)); if (muxer.empty()) { throw EffectError("A video is written as .mp4, .mov, .mkv, .webm or .gif, not '" + std::filesystem::path(path).filename().string() + "'."); } if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels."); // A write to an ffmpeg that has already died must come back as an error // to report, not as a signal that takes the whole program down with it. static std::once_flag ignorePipe; std::call_once(ignorePipe, [] { ::signal(SIGPIPE, SIG_IGN); }); const VideoQuality& squeeze = videoQualityOrDefault(quality); std::vector args = { "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", std::to_string(width) + "x" + std::to_string(height), "-r", std::to_string(std::clamp(fps, kMinFps, kMaxFps)), "-i", "pipe:0", "-an", }; if (muxer == "gif") { // One palette made for the whole clip, rather than the fixed web one. args.insert(args.end(), {"-vf", "split[a][b];[b]palettegen=max_colors=" + std::to_string(squeeze.gifColors) + "[p];[a][p]paletteuse", "-loop", "0"}); } else { // 4:2:0 wants even sides; a pixel of padding beats refusing the canvas. args.insert(args.end(), {"-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2"}); if (muxer == "webm") { args.insert(args.end(), {"-c:v", "libvpx-vp9", "-crf", std::to_string(squeeze.vp9Crf), "-b:v", "0", "-row-mt", "1"}); } else { args.insert(args.end(), {"-c:v", "libx264", "-preset", squeeze.h264Preset, "-crf", std::to_string(squeeze.h264Crf)}); } args.insert(args.end(), {"-pix_fmt", "yuv420p"}); if (muxer == "mp4" || muxer == "mov") args.insert(args.end(), {"-movflags", "+faststart"}); } // "file:" keeps a name that starts with a dash, or holds a colon, a file. args.insert(args.end(), {"-f", muxer, "file:" + path}); int pipeEnds[2] = {-1, -1}; if (::pipe2(pipeEnds, O_CLOEXEC) != 0) { throw EffectError(std::string("Could not start ffmpeg: ") + std::strerror(errno)); } errors_ = std::tmpfile(); if (errors_ != nullptr) ::fcntl(fileno(errors_), F_SETFD, FD_CLOEXEC); posix_spawn_file_actions_t actions; posix_spawn_file_actions_init(&actions); posix_spawn_file_actions_adddup2(&actions, pipeEnds[0], STDIN_FILENO); if (errors_ != nullptr) posix_spawn_file_actions_adddup2(&actions, fileno(errors_), STDERR_FILENO); std::vector argv; for (auto& arg : args) argv.push_back(arg.data()); argv.push_back(nullptr); pid_t child = -1; const int spawned = ::posix_spawnp(&child, "ffmpeg", &actions, nullptr, argv.data(), environ); posix_spawn_file_actions_destroy(&actions); ::close(pipeEnds[0]); if (spawned != 0) { ::close(pipeEnds[1]); if (errors_ != nullptr) std::fclose(errors_); errors_ = nullptr; if (spawned == ENOENT) { throw EffectError("Writing a video needs ffmpeg, which is not installed or not on " "the PATH."); } throw EffectError(std::string("Could not start ffmpeg: ") + std::strerror(spawned)); } child_ = child; input_ = pipeEnds[1]; } VideoWriter::~VideoWriter() { if (child_ > 0) cancel(); if (errors_ != nullptr) std::fclose(errors_); } void VideoWriter::write(const Image& frame) { if (input_ < 0) throw EffectError("The video has already been closed."); if (frame.width() != width_ || frame.height() != height_) { throw EffectError("Every frame of a video must be the same size."); } const std::uint8_t* data = frame.data(); std::size_t left = frame.byteSize(); while (left > 0) { const ssize_t written = ::write(input_, data, left); if (written < 0) { if (errno == EINTR) continue; failed("ffmpeg stopped taking frames"); } data += written; left -= static_cast(written); } } int VideoWriter::wait() { int status = 0; while (::waitpid(child_, &status, 0) < 0) { if (errno != EINTR) { status = -1; break; } } child_ = -1; return status; } void VideoWriter::finish() { if (child_ <= 0) return; ::close(input_); input_ = -1; const int status = wait(); if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { std::error_code ignored; std::filesystem::remove(path_, ignored); std::string message = "ffmpeg could not write the video"; const std::string said = errorOutput(); if (!said.empty()) message += ": " + said; throw EffectError(message); } } void VideoWriter::cancel() { if (input_ >= 0) { ::close(input_); input_ = -1; } if (child_ > 0) { // Not SIGTERM: ffmpeg takes that as "finish up", and flushing a slow // encoder can take many seconds for a file that is thrown away. ::kill(child_, SIGKILL); wait(); } std::error_code ignored; std::filesystem::remove(path_, ignored); } void VideoWriter::failed(const std::string& what) { cancel(); std::string message = what; const std::string said = errorOutput(); if (!said.empty()) message += ": " + said; throw EffectError(message); } // The last thing ffmpeg said, which is where it names the problem. std::string VideoWriter::errorOutput() const { if (errors_ == nullptr) return {}; std::string text; std::rewind(errors_); char buffer[4096]; std::size_t read = 0; while ((read = std::fread(buffer, 1, sizeof buffer, errors_)) > 0) text.append(buffer, read); while (!text.empty() && (text.back() == '\n' || text.back() == '\r' || text.back() == ' ')) { text.pop_back(); } const auto lastLine = text.rfind('\n'); if (lastLine != std::string::npos) text.erase(0, lastLine + 1); return text; } } // namespace effector