Make video projects, with a moving channel split as the first video effect
A project can now be a clip: Setup has a Video switch with a length, a frame rate and presets that set the canvas too (Instagram story, reel and feed video, TikTok / YouTube Short, Square loop, Full HD clip). The preview renders the whole clip, shrunk to a memory budget, and loops it with play/pause and a scale; Save As... renders it again at full size and pipes the frames into ffmpeg as .mp4, .mov, .mkv, .webm or .gif, with a progress window that can cancel. --duration, --fps and --video-preset do the same from the command line. Effects learn about time through FrameTime and Effect::renderFrame, which falls back to render, so nothing that holds still changes. Channel split now sways, shakes and rolls a tracking bar down the picture in a video; its motion params are marked forVideo() and hidden in a still project, and a still renders byte for byte as before. renderFrames starts every frame from the same seed and renders the still stages ahead of the first moving one only once. The version moves to 0.6.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
#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;
|
||||
}
|
||||
|
||||
// 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)
|
||||
: 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); });
|
||||
|
||||
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[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", "32", "-b:v", "0",
|
||||
"-row-mt", "1"});
|
||||
} else {
|
||||
args.insert(args.end(), {"-c:v", "libx264", "-preset", "medium", "-crf", "18"});
|
||||
}
|
||||
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
|
||||
Reference in New Issue
Block a user