A Quality dropdown under Video on Setup, --video-quality on the command line, and "quality" in a project's video settings (written only when it is not Standard). One table, videoQualities(), holds the ffmpeg settings per codec; Standard is what every video was written with before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
304 lines
11 KiB
C++
304 lines
11 KiB
C++
#include "core/video.hpp"
|
|
|
|
#include <fcntl.h>
|
|
#include <signal.h>
|
|
#include <spawn.h>
|
|
#include <sys/wait.h>
|
|
#include <unistd.h>
|
|
|
|
#include <algorithm>
|
|
#include <cerrno>
|
|
#include <cmath>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <filesystem>
|
|
#include <locale>
|
|
#include <mutex>
|
|
#include <sstream>
|
|
|
|
#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<int>(std::lround(seconds * rate)));
|
|
}
|
|
|
|
const std::vector<VideoPreset>& 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<VideoPreset> 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<VideoQuality>& 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<VideoQuality> 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<char>(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<std::string> 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<char*> 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<std::size_t>(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
|