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:
+498
-16
@@ -14,6 +14,7 @@
|
||||
#include "core/registry.hpp"
|
||||
#include "core/rng.hpp"
|
||||
#include "core/version.hpp"
|
||||
#include "core/video.hpp"
|
||||
|
||||
namespace effector {
|
||||
namespace {
|
||||
@@ -42,6 +43,20 @@ Glib::RefPtr<Gdk::Texture> toTexture(const Image& image) {
|
||||
bytes, image.stride());
|
||||
}
|
||||
|
||||
// The most memory a preview clip may take, all frames together. The frames
|
||||
// are shrunk to fit, so a long clip is previewed smaller rather than not at
|
||||
// all; the longer side stays between the two bounds below.
|
||||
constexpr double kClipBudgetBytes = 256.0 * 1024 * 1024;
|
||||
constexpr int kClipMinSide = 160;
|
||||
constexpr int kClipMaxSide = 720;
|
||||
|
||||
int clipSide(int width, int height, int frames) {
|
||||
const double full = 3.0 * width * height * std::max(1, frames);
|
||||
const double scale = std::min(1.0, std::sqrt(kClipBudgetBytes / full));
|
||||
const int side = static_cast<int>(std::max(width, height) * scale);
|
||||
return std::clamp(side, kClipMinSide, kClipMaxSide);
|
||||
}
|
||||
|
||||
Gtk::Label* sectionLabel(const char* text) {
|
||||
auto* label = Gtk::make_managed<Gtk::Label>(text);
|
||||
label->set_xalign(0.0F);
|
||||
@@ -97,6 +112,9 @@ EffectorWindow::EffectorWindow(const Options& initial) {
|
||||
renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished));
|
||||
loadProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onLoadProgress));
|
||||
loadFinished_.connect(sigc::mem_fun(*this, &EffectorWindow::onLoadFinished));
|
||||
clipProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onClipProgress));
|
||||
exportProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onExportProgress));
|
||||
exportDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onExportFinished));
|
||||
|
||||
buildUi();
|
||||
applyInitial(initial);
|
||||
@@ -116,9 +134,13 @@ EffectorWindow::~EffectorWindow() {
|
||||
chainSplitFit_.disconnect();
|
||||
recentChanged_.disconnect();
|
||||
loadWindowDelay_.disconnect();
|
||||
playTimer_.disconnect();
|
||||
loadCancel_.store(true, std::memory_order_relaxed);
|
||||
renderCancel_.store(true, std::memory_order_relaxed);
|
||||
exportCancel_.store(true, std::memory_order_relaxed);
|
||||
if (loader_.joinable()) loader_.join();
|
||||
if (worker_.joinable()) worker_.join();
|
||||
if (exporter_.joinable()) exporter_.join(); // cancelled: removes its half-written file
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- UI ---
|
||||
@@ -334,6 +356,8 @@ Gtk::Widget& EffectorWindow::buildSetupTab() {
|
||||
updateSourcesLabel();
|
||||
tab->append(sourcesSection_);
|
||||
|
||||
tab->append(buildVideoSection());
|
||||
|
||||
tab->append(*sectionLabel("Canvas"));
|
||||
tab->append(buildCanvasSection());
|
||||
|
||||
@@ -392,6 +416,8 @@ Gtk::Widget& EffectorWindow::buildCanvasSection() {
|
||||
widthSpin_.set_range(kMinCanvasSide, kMaxCanvasSide);
|
||||
widthSpin_.set_increments(1, 100);
|
||||
widthSpin_.set_value(1920);
|
||||
widthSpin_.set_name("canvas-width");
|
||||
heightSpin_.set_name("canvas-height");
|
||||
heightSpin_.set_range(kMinCanvasSide, kMaxCanvasSide);
|
||||
heightSpin_.set_increments(1, 100);
|
||||
heightSpin_.set_value(1080);
|
||||
@@ -484,6 +510,220 @@ void EffectorWindow::syncCanvasWidgets() {
|
||||
if (!quiet) onParamChanged();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- video ---
|
||||
|
||||
// A still project shows only the switch; a video shows its preset, length and
|
||||
// rate below it. The preset list reads like the canvas one: "Custom" first,
|
||||
// then every preset with what it amounts to.
|
||||
Gtk::Widget& EffectorWindow::buildVideoSection() {
|
||||
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6);
|
||||
|
||||
auto* heading = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8);
|
||||
auto* label = sectionLabel("Video");
|
||||
label->set_hexpand(true);
|
||||
heading->append(*label);
|
||||
videoSwitch_.set_name("video");
|
||||
videoSwitch_.set_valign(Gtk::Align::CENTER);
|
||||
videoSwitch_.set_tooltip_text("Make a video clip rather than a still picture");
|
||||
videoSwitch_.property_active().signal_changed().connect(
|
||||
sigc::mem_fun(*this, &EffectorWindow::onVideoToggled));
|
||||
heading->append(videoSwitch_);
|
||||
box->append(*heading);
|
||||
|
||||
videoDetails_.set_orientation(Gtk::Orientation::VERTICAL);
|
||||
videoDetails_.set_spacing(6);
|
||||
|
||||
// Index 0 is "Custom"; every later index is videoPresets()[index - 1].
|
||||
std::vector<Glib::ustring> labels{"Custom"};
|
||||
for (const auto& preset : videoPresets()) {
|
||||
labels.push_back(preset.label + " (" + videoPresetDetail(preset) + ")");
|
||||
}
|
||||
videoPresetDrop_.set_name("video-preset");
|
||||
videoPresetDrop_.set_model(Gtk::StringList::create(labels));
|
||||
videoPresetDrop_.set_selected(0);
|
||||
videoPresetDrop_.set_tooltip_text("A canvas and a length made for one place a clip is shown");
|
||||
videoPresetDrop_.property_selected().signal_changed().connect(
|
||||
sigc::mem_fun(*this, &EffectorWindow::onVideoPresetSelected));
|
||||
videoDetails_.append(videoPresetDrop_);
|
||||
|
||||
auto* grid = Gtk::make_managed<Gtk::Grid>();
|
||||
grid->set_column_spacing(8);
|
||||
grid->set_row_spacing(6);
|
||||
durationSpin_.set_name("video-duration");
|
||||
durationSpin_.set_range(kMinDuration, kMaxDuration);
|
||||
durationSpin_.set_increments(0.5, 5.0);
|
||||
durationSpin_.set_digits(1);
|
||||
durationSpin_.set_value(kDefaultDuration);
|
||||
fpsSpin_.set_name("video-fps");
|
||||
fpsSpin_.set_range(kMinFps, kMaxFps);
|
||||
fpsSpin_.set_increments(1, 5);
|
||||
fpsSpin_.set_value(kDefaultFps);
|
||||
for (auto* spin : {&durationSpin_, &fpsSpin_}) {
|
||||
spin->set_hexpand(true);
|
||||
scrollPastUnlessFocused(*spin);
|
||||
spin->signal_value_changed().connect(
|
||||
sigc::mem_fun(*this, &EffectorWindow::onVideoTimingChanged));
|
||||
}
|
||||
auto* durationLabel = Gtk::make_managed<Gtk::Label>("Length (s)");
|
||||
durationLabel->set_xalign(0.0F);
|
||||
auto* fpsLabel = Gtk::make_managed<Gtk::Label>("Frames/s");
|
||||
fpsLabel->set_xalign(0.0F);
|
||||
grid->attach(*durationLabel, 0, 0);
|
||||
grid->attach(durationSpin_, 1, 0);
|
||||
grid->attach(*fpsLabel, 0, 1);
|
||||
grid->attach(fpsSpin_, 1, 1);
|
||||
videoDetails_.append(*grid);
|
||||
videoDetails_.set_visible(false);
|
||||
box->append(videoDetails_);
|
||||
return *box;
|
||||
}
|
||||
|
||||
VideoSettings EffectorWindow::videoSettings() const {
|
||||
VideoSettings video;
|
||||
video.enabled = isVideo();
|
||||
video.duration = durationSpin_.get_value();
|
||||
video.fps = static_cast<int>(fpsSpin_.get_value());
|
||||
const auto selected = videoPresetDrop_.get_selected();
|
||||
const auto& presets = videoPresets();
|
||||
if (selected != GTK_INVALID_LIST_POSITION && selected > 0 && selected <= presets.size()) {
|
||||
video.preset = presets[selected - 1].key;
|
||||
}
|
||||
return video;
|
||||
}
|
||||
|
||||
void EffectorWindow::setVideoSettings(const VideoSettings& video) {
|
||||
const bool suspended = suspendVideoSync_;
|
||||
suspendVideoSync_ = true;
|
||||
durationSpin_.set_value(video.duration);
|
||||
fpsSpin_.set_value(video.fps);
|
||||
guint selected = 0;
|
||||
const auto& presets = videoPresets();
|
||||
for (std::size_t i = 0; i < presets.size(); ++i) {
|
||||
if (presets[i].key == video.preset) selected = static_cast<guint>(i + 1);
|
||||
}
|
||||
videoPresetDrop_.set_selected(selected);
|
||||
videoSwitch_.set_active(video.enabled);
|
||||
videoDetails_.set_visible(video.enabled);
|
||||
suspendVideoSync_ = suspended;
|
||||
playbackBar_.set_visible(false);
|
||||
clearClip();
|
||||
buildParamWidgets(); // the motion params come and go with the switch
|
||||
}
|
||||
|
||||
void EffectorWindow::onVideoToggled() {
|
||||
videoDetails_.set_visible(isVideo());
|
||||
if (suspendVideoSync_) return;
|
||||
// What was on screen was the other kind of result, and cannot be saved as
|
||||
// this one.
|
||||
result_ = Image();
|
||||
clearClip();
|
||||
saveButton_.set_sensitive(false);
|
||||
buildParamWidgets();
|
||||
requestRender();
|
||||
}
|
||||
|
||||
// A preset decides the canvas too, so picking one fills in the format as well
|
||||
// as the timing -- one render for all of it.
|
||||
void EffectorWindow::onVideoPresetSelected() {
|
||||
if (suspendVideoSync_) return;
|
||||
const auto* preset = videoPresetByKey(videoSettings().preset);
|
||||
if (preset == nullptr) return; // "Custom": leave the numbers as they are
|
||||
suspendVideoSync_ = true;
|
||||
durationSpin_.set_value(preset->seconds);
|
||||
fpsSpin_.set_value(preset->fps);
|
||||
setCanvasFormat(preset->canvasFormat, static_cast<int>(dpiSpin_.get_value()));
|
||||
suspendVideoSync_ = false;
|
||||
requestRender();
|
||||
}
|
||||
|
||||
void EffectorWindow::onVideoTimingChanged() {
|
||||
if (suspendVideoSync_) return;
|
||||
matchVideoPreset();
|
||||
requestRender();
|
||||
}
|
||||
|
||||
void EffectorWindow::matchVideoPreset() {
|
||||
if (suspendVideoSync_) return;
|
||||
const auto* preset = videoPresetByKey(videoSettings().preset);
|
||||
if (preset == nullptr) return;
|
||||
const bool same = std::abs(durationSpin_.get_value() - preset->seconds) < 1e-9 &&
|
||||
static_cast<int>(fpsSpin_.get_value()) == preset->fps &&
|
||||
canvasFormatKey() == preset->canvasFormat;
|
||||
if (same) return;
|
||||
suspendVideoSync_ = true;
|
||||
videoPresetDrop_.set_selected(0);
|
||||
suspendVideoSync_ = false;
|
||||
}
|
||||
|
||||
// Frames come over from the worker a few at a time and are turned into
|
||||
// textures here, on the main thread. The clip on screen keeps playing until
|
||||
// the new one is complete; the very first one shows its frames as they come.
|
||||
void EffectorWindow::onClipProgress() {
|
||||
std::vector<Image> frames;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(resultMutex_);
|
||||
frames.swap(pendingFrames_);
|
||||
}
|
||||
if (!renderingVideo_) return; // left over from a render that was dropped
|
||||
for (const auto& frame : frames) incomingClip_.push_back(toTexture(frame));
|
||||
if (frames.empty()) return;
|
||||
|
||||
if (clip_.empty()) {
|
||||
picture_.set_paintable(incomingClip_.back());
|
||||
previewStack_.set_visible_child("picture");
|
||||
}
|
||||
status_.set_text("Rendering preview · frame " + std::to_string(incomingClip_.size()) + " of " +
|
||||
std::to_string(incomingTotal_));
|
||||
}
|
||||
|
||||
void EffectorWindow::showClipFrame(int index) {
|
||||
if (clip_.empty()) return;
|
||||
playFrame_ = std::clamp(index, 0, static_cast<int>(clip_.size()) - 1);
|
||||
picture_.set_paintable(clip_[static_cast<std::size_t>(playFrame_)]);
|
||||
suspendScrub_ = true;
|
||||
playbackScale_.set_value(playFrame_);
|
||||
suspendScrub_ = false;
|
||||
playbackTime_.set_text(formatSeconds(static_cast<double>(playFrame_) / clipFps_) + " / " +
|
||||
formatSeconds(static_cast<double>(clip_.size()) / clipFps_));
|
||||
}
|
||||
|
||||
void EffectorWindow::startPlayback() {
|
||||
playTimer_.disconnect();
|
||||
if (clip_.empty()) return;
|
||||
playbackScale_.set_range(0, std::max(1, static_cast<int>(clip_.size()) - 1));
|
||||
playbackBar_.set_visible(true);
|
||||
showClipFrame(playFrame_);
|
||||
if (!playing_) return;
|
||||
playTimer_ = Glib::signal_timeout().connect(
|
||||
[this] {
|
||||
showClipFrame((playFrame_ + 1) % static_cast<int>(clip_.size()));
|
||||
return true;
|
||||
},
|
||||
static_cast<unsigned>(std::max(1, 1000 / std::max(1, clipFps_))));
|
||||
}
|
||||
|
||||
void EffectorWindow::stopPlayback() { playTimer_.disconnect(); }
|
||||
|
||||
void EffectorWindow::setPlaying(bool playing) {
|
||||
playing_ = playing;
|
||||
playButton_.set_icon_name(playing ? "media-playback-pause-symbolic"
|
||||
: "media-playback-start-symbolic");
|
||||
playButton_.set_tooltip_text(playing ? "Pause" : "Play");
|
||||
if (playing) {
|
||||
startPlayback();
|
||||
} else {
|
||||
stopPlayback();
|
||||
}
|
||||
}
|
||||
|
||||
void EffectorWindow::clearClip() {
|
||||
stopPlayback();
|
||||
clip_.clear();
|
||||
incomingClip_.clear();
|
||||
playFrame_ = 0;
|
||||
playbackBar_.set_visible(false);
|
||||
}
|
||||
|
||||
Gtk::Widget& EffectorWindow::buildPreview() {
|
||||
picture_.set_can_shrink(true);
|
||||
picture_.set_content_fit(Gtk::ContentFit::CONTAIN);
|
||||
@@ -516,6 +756,37 @@ Gtk::Widget& EffectorWindow::buildPreview() {
|
||||
status_.set_margin_top(6);
|
||||
status_.set_margin_bottom(8);
|
||||
status_.add_css_class("dim-label");
|
||||
|
||||
// Play and pause, and a scale to look at any one frame; only a video has it.
|
||||
playbackBar_.set_name("playback");
|
||||
playbackBar_.set_orientation(Gtk::Orientation::HORIZONTAL);
|
||||
playbackBar_.set_spacing(8);
|
||||
playbackBar_.set_margin_start(8);
|
||||
playbackBar_.set_margin_end(8);
|
||||
playbackBar_.set_margin_top(6);
|
||||
playButton_.set_icon_name("media-playback-pause-symbolic");
|
||||
playButton_.set_tooltip_text("Pause");
|
||||
playButton_.signal_clicked().connect([this] { setPlaying(!playing_); });
|
||||
playbackBar_.append(playButton_);
|
||||
playbackScale_.set_orientation(Gtk::Orientation::HORIZONTAL);
|
||||
playbackScale_.set_hexpand(true);
|
||||
playbackScale_.set_draw_value(false);
|
||||
playbackScale_.set_range(0, 1);
|
||||
playbackScale_.set_increments(1, 10);
|
||||
playbackScale_.set_tooltip_text("Drag to look at one frame");
|
||||
playbackScale_.signal_value_changed().connect([this] {
|
||||
if (suspendScrub_) return;
|
||||
// Looking at a frame means holding it: playback stops there.
|
||||
if (playing_) setPlaying(false);
|
||||
showClipFrame(static_cast<int>(std::lround(playbackScale_.get_value())));
|
||||
});
|
||||
playbackBar_.append(playbackScale_);
|
||||
playbackTime_.add_css_class("dim-label");
|
||||
playbackTime_.add_css_class("numeric");
|
||||
playbackBar_.append(playbackTime_);
|
||||
playbackBar_.set_visible(false);
|
||||
box->append(playbackBar_);
|
||||
|
||||
box->append(status_);
|
||||
return *box;
|
||||
}
|
||||
@@ -557,6 +828,9 @@ void EffectorWindow::buildParamWidgets() {
|
||||
}
|
||||
|
||||
for (const auto& spec : stage.effect->params()) {
|
||||
// How a stage moves means nothing in a still, so it is left out there;
|
||||
// the values stay with the stage for when the project becomes a video.
|
||||
if (spec.videoOnly && !isVideo()) continue;
|
||||
// A styled param is read from the style while one is followed, so the
|
||||
// widgets show what the stage will actually render with.
|
||||
const bool shared = style != nullptr && spec.inStyle;
|
||||
@@ -1011,6 +1285,7 @@ void EffectorWindow::applyInitial(const Options& initial) {
|
||||
widthSpin_.set_value(initial.width);
|
||||
heightSpin_.set_value(initial.height);
|
||||
setCanvasFormat(initial.format, initial.dpi); // a format fills the spins itself
|
||||
setVideoSettings(initial.video);
|
||||
if (initial.seed) {
|
||||
seed_ = *initial.seed;
|
||||
seedEntry_.set_text(std::to_string(seed_));
|
||||
@@ -1689,6 +1964,7 @@ void EffectorWindow::onParamChanged() {
|
||||
// styled one can rename every stage sharing the style.
|
||||
for (std::size_t i = 0; i < stageLabels_.size() && i < stages_.size(); ++i)
|
||||
stageLabels_[i]->set_text(stageLabel(i));
|
||||
matchVideoPreset(); // the canvas may have left the preset's format
|
||||
requestRender();
|
||||
}
|
||||
|
||||
@@ -1758,6 +2034,7 @@ std::string EffectorWindow::missingText() const {
|
||||
|
||||
void EffectorWindow::showPlaceholder() {
|
||||
result_ = Image();
|
||||
clearClip();
|
||||
saveButton_.set_sensitive(false);
|
||||
placeholder_.set_text(missingText());
|
||||
previewStack_.set_visible_child("placeholder");
|
||||
@@ -1786,9 +2063,13 @@ void EffectorWindow::startRender() {
|
||||
if (!canRender()) return;
|
||||
if (rendering_) {
|
||||
staleResult_ = true; // a fresh render follows this one
|
||||
// A clip takes seconds; there is no point finishing one whose
|
||||
// settings are already out of date.
|
||||
renderCancel_.store(true, std::memory_order_relaxed);
|
||||
return;
|
||||
}
|
||||
rendering_ = true;
|
||||
renderCancel_.store(false, std::memory_order_relaxed);
|
||||
|
||||
const auto sources = sources_;
|
||||
const auto stages = stages_; // a snapshot, like the sources
|
||||
@@ -1796,17 +2077,46 @@ void EffectorWindow::startRender() {
|
||||
const int width = static_cast<int>(widthSpin_.get_value());
|
||||
const int height = static_cast<int>(heightSpin_.get_value());
|
||||
const std::uint64_t seed = seed_;
|
||||
const VideoSettings video = videoSettings();
|
||||
|
||||
spinner_.start();
|
||||
recreateButton_.set_sensitive(false);
|
||||
|
||||
worker_ = std::thread([this, sources, stages, styles, width, height, seed] {
|
||||
renderingVideo_ = video.enabled;
|
||||
if (video.enabled) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(resultMutex_);
|
||||
pendingFrames_.clear();
|
||||
}
|
||||
incomingClip_.clear();
|
||||
incomingTotal_ = video.frameCount();
|
||||
status_.set_text("Rendering preview…");
|
||||
}
|
||||
|
||||
worker_ = std::thread([this, sources, stages, styles, width, height, seed, video] {
|
||||
Image image;
|
||||
std::string error;
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
try {
|
||||
Rng rng(seed);
|
||||
image = renderChain(stages, *sources, width, height, rng, styles);
|
||||
if (video.enabled) {
|
||||
// Rendered at the full size, so the preview shows what the
|
||||
// file will hold, and shrunk only to be kept.
|
||||
const int side = clipSide(width, height, video.frameCount());
|
||||
renderFrames(stages, *sources, width, height, seed, styles, video,
|
||||
[this, side](int, Image&& frame) {
|
||||
if (renderCancel_.load(std::memory_order_relaxed)) return false;
|
||||
Image small = frame.scaledToFit(side);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(resultMutex_);
|
||||
pendingFrames_.push_back(std::move(small));
|
||||
}
|
||||
clipProgress_.emit();
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
Rng rng(seed);
|
||||
image = renderChain(stages, *sources, width, height, rng, styles);
|
||||
}
|
||||
} catch (const std::exception& exception) {
|
||||
error = exception.what();
|
||||
}
|
||||
@@ -1826,6 +2136,9 @@ void EffectorWindow::startRender() {
|
||||
void EffectorWindow::onRenderFinished() {
|
||||
if (worker_.joinable()) worker_.join();
|
||||
rendering_ = false;
|
||||
onClipProgress(); // whatever frames came in after the last wake-up
|
||||
const bool wasVideo = renderingVideo_;
|
||||
renderingVideo_ = false;
|
||||
|
||||
Image image;
|
||||
std::string error;
|
||||
@@ -1839,6 +2152,7 @@ void EffectorWindow::onRenderFinished() {
|
||||
|
||||
if (staleResult_) { // settings changed while this one was running
|
||||
staleResult_ = false;
|
||||
incomingClip_.clear();
|
||||
startRender();
|
||||
return;
|
||||
}
|
||||
@@ -1848,25 +2162,45 @@ void EffectorWindow::onRenderFinished() {
|
||||
|
||||
if (!error.empty()) {
|
||||
result_ = Image();
|
||||
clearClip();
|
||||
saveButton_.set_sensitive(false);
|
||||
showError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
clearError();
|
||||
result_ = std::move(image);
|
||||
picture_.set_paintable(toTexture(result_));
|
||||
previewStack_.set_visible_child("picture");
|
||||
saveButton_.set_sensitive(true);
|
||||
|
||||
// The line itself stays short; the full chain and setup go in the tooltip.
|
||||
const std::size_t effects = enabledStages(stages_).size();
|
||||
std::ostringstream status;
|
||||
status.setf(std::ios::fixed);
|
||||
status.precision(1);
|
||||
status << effects << (effects == 1 ? " effect" : " effects") << " · " << millis << " ms";
|
||||
const std::size_t effects = enabledStages(stages_).size();
|
||||
if (wasVideo) {
|
||||
result_ = Image();
|
||||
clip_ = std::move(incomingClip_);
|
||||
incomingClip_.clear();
|
||||
clipFps_ = videoSettings().fps;
|
||||
if (playFrame_ >= static_cast<int>(clip_.size())) playFrame_ = 0;
|
||||
previewStack_.set_visible_child("picture");
|
||||
saveButton_.set_sensitive(!clip_.empty());
|
||||
startPlayback();
|
||||
status << clip_.size() << " frames · " << effects << (effects == 1 ? " effect" : " effects")
|
||||
<< " · " << millis / 1000.0 << " s";
|
||||
} else {
|
||||
clearClip();
|
||||
result_ = std::move(image);
|
||||
picture_.set_paintable(toTexture(result_));
|
||||
previewStack_.set_visible_child("picture");
|
||||
saveButton_.set_sensitive(true);
|
||||
status << effects << (effects == 1 ? " effect" : " effects") << " · " << millis << " ms";
|
||||
}
|
||||
|
||||
// The line itself stays short; the full chain and setup go in the tooltip.
|
||||
std::ostringstream details;
|
||||
details << chainTitle(stages_) << " · " << result_.width() << "×" << result_.height() << " · ";
|
||||
details << chainTitle(stages_) << " · " << static_cast<int>(widthSpin_.get_value()) << "×"
|
||||
<< static_cast<int>(heightSpin_.get_value()) << " · ";
|
||||
if (wasVideo) {
|
||||
const auto video = videoSettings();
|
||||
details << formatSeconds(video.duration) << " at " << video.fps << " fps · ";
|
||||
}
|
||||
if (!sources_->empty()) {
|
||||
details << sources_->size() << (sources_->size() == 1 ? " source" : " sources") << " · ";
|
||||
}
|
||||
@@ -1945,6 +2279,7 @@ Project EffectorWindow::currentProject() const {
|
||||
project.height = static_cast<int>(heightSpin_.get_value());
|
||||
project.format = canvasFormatKey();
|
||||
project.dpi = static_cast<int>(dpiSpin_.get_value());
|
||||
project.video = videoSettings();
|
||||
project.seed = seed_;
|
||||
return project;
|
||||
}
|
||||
@@ -1965,6 +2300,7 @@ void EffectorWindow::applyProject(const Project& project, const std::string& pat
|
||||
widthSpin_.set_value(project.width);
|
||||
heightSpin_.set_value(project.height);
|
||||
setCanvasFormat(project.format, project.dpi);
|
||||
setVideoSettings(project.video);
|
||||
seed_ = project.seed.value_or(Rng::randomSeed());
|
||||
seedEntry_.set_text(std::to_string(seed_));
|
||||
|
||||
@@ -1988,6 +2324,7 @@ void EffectorWindow::clearSources() {
|
||||
if (loading_) dropLoadedImages_ = true;
|
||||
|
||||
result_ = Image();
|
||||
clearClip();
|
||||
saveButton_.set_sensitive(false);
|
||||
previewStack_.set_visible_child("placeholder");
|
||||
}
|
||||
@@ -2287,11 +2624,13 @@ void EffectorWindow::updateTitle() {
|
||||
// --------------------------------------------------------------- saving ---
|
||||
|
||||
void EffectorWindow::onSave() {
|
||||
if (result_.empty()) return;
|
||||
const bool video = isVideo();
|
||||
if (video ? clip_.empty() || exporting_ : result_.empty()) return;
|
||||
|
||||
auto dialog = Gtk::FileDialog::create();
|
||||
dialog->set_title("Save image");
|
||||
dialog->set_initial_name("effector-" + chainSlug(stages_) + "-" + std::to_string(seed_) + ".png");
|
||||
dialog->set_title(video ? "Save video" : "Save image");
|
||||
dialog->set_initial_name("effector-" + chainSlug(stages_) + "-" + std::to_string(seed_) +
|
||||
(video ? ".mp4" : ".png"));
|
||||
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
|
||||
onSaveChosen(result, dialog);
|
||||
});
|
||||
@@ -2307,8 +2646,19 @@ void EffectorWindow::onSaveChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
|
||||
} catch (const Glib::Error&) {
|
||||
return; // cancelled
|
||||
}
|
||||
if (path.extension().empty()) path.replace_extension(".png");
|
||||
|
||||
if (isVideo()) {
|
||||
if (path.extension().empty()) path.replace_extension(".mp4");
|
||||
if (!isVideoPath(path.string())) {
|
||||
showError("A video is saved as .mp4, .mov, .mkv, .webm or .gif, not " +
|
||||
path.filename().string() + ".");
|
||||
return;
|
||||
}
|
||||
startExport(path.string());
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.extension().empty()) path.replace_extension(".png");
|
||||
try {
|
||||
result_.save(path.string());
|
||||
} catch (const std::exception& error) {
|
||||
@@ -2320,6 +2670,138 @@ void EffectorWindow::onSaveChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
|
||||
status_.set_tooltip_text("Saved " + path.string());
|
||||
}
|
||||
|
||||
// The preview keeps only small frames, so the file is rendered again at the
|
||||
// full size, frame by frame, straight into ffmpeg. That takes a while, which
|
||||
// is why it has a window of its own and can be cancelled.
|
||||
void EffectorWindow::startExport(const std::string& path) {
|
||||
if (exporting_) return;
|
||||
if (exporter_.joinable()) exporter_.join();
|
||||
exporting_ = true;
|
||||
exportCancel_.store(false, std::memory_order_relaxed);
|
||||
exportFrames_.store(0, std::memory_order_relaxed);
|
||||
exportPath_ = path;
|
||||
exportError_.clear();
|
||||
saveButton_.set_sensitive(false);
|
||||
|
||||
const auto sources = sources_;
|
||||
const auto stages = stages_;
|
||||
const auto styles = styles_;
|
||||
const int width = static_cast<int>(widthSpin_.get_value());
|
||||
const int height = static_cast<int>(heightSpin_.get_value());
|
||||
const std::uint64_t seed = seed_;
|
||||
const VideoSettings video = videoSettings();
|
||||
exportTotal_ = video.frameCount();
|
||||
|
||||
exportWindow_ = std::make_unique<Gtk::Window>();
|
||||
exportWindow_->set_name("export");
|
||||
exportWindow_->set_title("Saving video");
|
||||
exportWindow_->set_transient_for(*this);
|
||||
exportWindow_->set_modal(true);
|
||||
exportWindow_->set_resizable(false);
|
||||
exportWindow_->set_default_size(420, -1);
|
||||
|
||||
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
|
||||
box->set_margin(18);
|
||||
auto* heading = Gtk::make_managed<Gtk::Label>("Saving " +
|
||||
std::filesystem::path(path).filename().string());
|
||||
heading->set_xalign(0.0F);
|
||||
heading->set_ellipsize(Pango::EllipsizeMode::MIDDLE);
|
||||
heading->add_css_class("heading");
|
||||
box->append(*heading);
|
||||
auto* bar = Gtk::make_managed<Gtk::ProgressBar>();
|
||||
bar->set_show_text(true);
|
||||
bar->set_text("frame 0 of " + std::to_string(exportTotal_));
|
||||
box->append(*bar);
|
||||
exportBar_ = bar;
|
||||
auto* cancel = Gtk::make_managed<Gtk::Button>("Cancel");
|
||||
cancel->set_halign(Gtk::Align::END);
|
||||
cancel->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::cancelExport));
|
||||
box->append(*cancel);
|
||||
// As with reading images: closing means Cancel, and the window stays up
|
||||
// until the exporter has actually stopped and cleaned up.
|
||||
exportWindow_->signal_close_request().connect(
|
||||
[this] {
|
||||
cancelExport();
|
||||
return true;
|
||||
},
|
||||
false);
|
||||
exportWindow_->set_child(*box);
|
||||
exportWindow_->present();
|
||||
|
||||
exporter_ = std::thread([this, path, sources, stages, styles, width, height, seed, video] {
|
||||
std::string error;
|
||||
try {
|
||||
VideoWriter writer(path, width, height, video.fps);
|
||||
renderFrames(stages, *sources, width, height, seed, styles, video,
|
||||
[this, &writer](int, Image&& frame) {
|
||||
if (exportCancel_.load(std::memory_order_relaxed)) return false;
|
||||
writer.write(frame);
|
||||
exportFrames_.fetch_add(1, std::memory_order_relaxed);
|
||||
exportProgress_.emit();
|
||||
return true;
|
||||
});
|
||||
if (exportCancel_.load(std::memory_order_relaxed)) {
|
||||
writer.cancel();
|
||||
} else {
|
||||
writer.finish();
|
||||
}
|
||||
} catch (const std::exception& exception) {
|
||||
error = exception.what();
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(resultMutex_);
|
||||
exportError_ = std::move(error);
|
||||
}
|
||||
exportDone_.emit();
|
||||
});
|
||||
}
|
||||
|
||||
void EffectorWindow::onExportProgress() {
|
||||
if (exportBar_ == nullptr || exportCancel_.load(std::memory_order_relaxed)) return;
|
||||
const int done = exportFrames_.load(std::memory_order_relaxed);
|
||||
exportBar_->set_fraction(exportTotal_ > 0 ? static_cast<double>(done) / exportTotal_ : 0.0);
|
||||
exportBar_->set_text("frame " + std::to_string(done) + " of " + std::to_string(exportTotal_));
|
||||
}
|
||||
|
||||
void EffectorWindow::onExportFinished() {
|
||||
if (exporter_.joinable()) exporter_.join();
|
||||
exporting_ = false;
|
||||
closeExportWindow();
|
||||
saveButton_.set_sensitive(isVideo() ? !clip_.empty() : !result_.empty());
|
||||
|
||||
std::string error;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(resultMutex_);
|
||||
error = exportError_;
|
||||
}
|
||||
if (!error.empty()) {
|
||||
showError("Could not save the video: " + error);
|
||||
return;
|
||||
}
|
||||
if (exportCancel_.load(std::memory_order_relaxed)) {
|
||||
status_.set_text("Saving cancelled");
|
||||
status_.set_tooltip_text("");
|
||||
return;
|
||||
}
|
||||
clearError();
|
||||
status_.set_text("Saved " + exportPath_);
|
||||
status_.set_tooltip_text("Saved " + exportPath_);
|
||||
}
|
||||
|
||||
void EffectorWindow::cancelExport() {
|
||||
if (!exporting_) return;
|
||||
exportCancel_.store(true, std::memory_order_relaxed);
|
||||
if (exportBar_ != nullptr) exportBar_->set_text("Cancelling…");
|
||||
}
|
||||
|
||||
void EffectorWindow::closeExportWindow() {
|
||||
exportBar_ = nullptr;
|
||||
if (!exportWindow_) return;
|
||||
exportWindow_->set_hide_on_close(false);
|
||||
exportWindow_->hide();
|
||||
exportWindow_.reset();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- errors ---
|
||||
|
||||
void EffectorWindow::showError(const std::string& message) {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#include "core/image.hpp"
|
||||
#include "core/project.hpp"
|
||||
#include "core/style.hpp"
|
||||
#include "core/video.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
@@ -87,6 +88,34 @@ private:
|
||||
void syncCanvasWidgets();
|
||||
void removeSource(Gtk::ListBoxRow* row);
|
||||
|
||||
// Video: a switch that makes the project a clip, and its length, rate and
|
||||
// preset. A preset owns the canvas format as well as the timing; changing
|
||||
// any of them by hand drops it back to "Custom".
|
||||
Gtk::Widget& buildVideoSection();
|
||||
VideoSettings videoSettings() const;
|
||||
void setVideoSettings(const VideoSettings& video); // quietly, for a project
|
||||
void onVideoToggled();
|
||||
void onVideoPresetSelected();
|
||||
void onVideoTimingChanged();
|
||||
void matchVideoPreset(); // "Custom" once the settings are no longer the preset's
|
||||
bool isVideo() const { return videoSwitch_.get_active(); }
|
||||
|
||||
// The preview of a video is the whole clip, rendered first and then looped.
|
||||
void onClipProgress(); // dispatcher: frames arrived from the worker
|
||||
void showClipFrame(int index);
|
||||
void startPlayback();
|
||||
void stopPlayback();
|
||||
void setPlaying(bool playing);
|
||||
void clearClip();
|
||||
|
||||
// Writing a video file: rendered again at full size, on a thread of its
|
||||
// own, into ffmpeg, with a progress window that can cancel it.
|
||||
void startExport(const std::string& path);
|
||||
void onExportProgress();
|
||||
void onExportFinished();
|
||||
void cancelExport();
|
||||
void closeExportWindow();
|
||||
|
||||
Gtk::Widget& buildChainSection();
|
||||
Gtk::Widget& buildAddMenu(); // the menu of effects that can be appended
|
||||
void appendStage(const EffectPtr& effect);
|
||||
@@ -205,6 +234,35 @@ private:
|
||||
Image pendingImage_;
|
||||
std::string pendingError_;
|
||||
double pendingMillis_ = 0.0;
|
||||
// Set to stop a clip render early once its settings are out of date.
|
||||
std::atomic<bool> renderCancel_{false};
|
||||
bool renderingVideo_ = false; // what the render in flight is making
|
||||
|
||||
// The clip being previewed: small frames, ready to show, and the frames
|
||||
// of the clip being rendered, which take its place once it is complete.
|
||||
Glib::Dispatcher clipProgress_;
|
||||
std::vector<Image> pendingFrames_; // handed over under resultMutex_
|
||||
std::vector<Glib::RefPtr<Gdk::Texture>> clip_;
|
||||
std::vector<Glib::RefPtr<Gdk::Texture>> incomingClip_;
|
||||
int incomingTotal_ = 0;
|
||||
int clipFps_ = kDefaultFps;
|
||||
int playFrame_ = 0;
|
||||
bool playing_ = true;
|
||||
sigc::connection playTimer_;
|
||||
bool suspendScrub_ = false; // set while playback moves the scale itself
|
||||
|
||||
// Video export
|
||||
std::thread exporter_;
|
||||
Glib::Dispatcher exportProgress_;
|
||||
Glib::Dispatcher exportDone_;
|
||||
std::atomic<bool> exportCancel_{false};
|
||||
std::atomic<int> exportFrames_{0};
|
||||
int exportTotal_ = 0;
|
||||
bool exporting_ = false;
|
||||
std::string exportPath_;
|
||||
std::string exportError_; // written by the exporter before exportDone_
|
||||
std::unique_ptr<Gtk::Window> exportWindow_;
|
||||
Gtk::ProgressBar* exportBar_ = nullptr; // nulled with the window
|
||||
|
||||
// Widgets
|
||||
Gtk::Label banner_;
|
||||
@@ -224,6 +282,12 @@ private:
|
||||
Gtk::SpinButton heightSpin_;
|
||||
Gtk::SpinButton dpiSpin_;
|
||||
Gtk::Label* dpiLabel_ = nullptr; // hidden along with the dpi spin
|
||||
Gtk::Switch videoSwitch_;
|
||||
Gtk::Box videoDetails_; // hidden while the project is a still
|
||||
Gtk::DropDown videoPresetDrop_;
|
||||
Gtk::SpinButton durationSpin_;
|
||||
Gtk::SpinButton fpsSpin_;
|
||||
bool suspendVideoSync_ = false; // set while code, not the user, fills them
|
||||
// Set while a format fills the spins, so their own handler does not queue
|
||||
// a second render on top of the one the format change asks for.
|
||||
bool suspendCanvasSync_ = false;
|
||||
@@ -245,6 +309,10 @@ private:
|
||||
Gtk::Label placeholder_;
|
||||
Gtk::Spinner spinner_;
|
||||
Gtk::Label status_;
|
||||
Gtk::Box playbackBar_; // under the preview of a video only
|
||||
Gtk::Button playButton_;
|
||||
Gtk::Scale playbackScale_;
|
||||
Gtk::Label playbackTime_;
|
||||
|
||||
struct ParamWidget {
|
||||
ParamSpec spec;
|
||||
|
||||
Reference in New Issue
Block a user