From ac8b62208c8f724cf04f2807025ab92ddd99fe83 Mon Sep 17 00:00:00 2001 From: Si Date: Sat, 26 Sep 2026 19:49:59 +0200 Subject: [PATCH] Render the video preview draft first: every third frame, then the rest The preview plays a draft of every third frame, each held for three, as soon as those are in, and fills in the frames between while it plays. renderFrames takes an optional frame order (draftFirstOrder builds this one); a frame is the same whatever order it is rendered in. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +++ src/core/chain.cpp | 25 ++++++++++-- src/core/chain.hpp | 12 +++++- src/gui/window.cpp | 92 +++++++++++++++++++++++++++++++------------- src/gui/window.hpp | 11 +++++- tests/core_tests.cpp | 19 +++++++++ 6 files changed, 132 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf62e0b..7238650 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ commit that made it. The interface shows this file under Changelog in its menu. command line it is `--video-quality small|standard|high`. Standard writes videos exactly as before. +### Changed +- The video preview plays sooner: it renders every third frame first and + plays that draft, each frame held for three, then fills in the frames + between while it plays. The finished preview and the saved video are + unchanged. + ## [0.7.0] - 2026-09-26 ### Added diff --git a/src/core/chain.cpp b/src/core/chain.cpp index 319a9aa..10ddc53 100644 --- a/src/core/chain.cpp +++ b/src/core/chain.cpp @@ -118,7 +118,8 @@ Image renderChain(const std::vector& stages, const std::vector& stages, const std::vector& sources, int width, int height, std::uint64_t seed, const StyleSet& styles, const VideoSettings& video, - const std::function& onFrame) { + const std::function& onFrame, + const std::vector& order) { const auto running = runningStages(stages, true); ensureRenderFits(running, width, height, styles); const int count = video.frameCount(); @@ -134,16 +135,32 @@ int renderFrames(const std::vector& stages, const std::vector const Image prefix = runStages(running, 0, still, sources, Image(), width, height, prefixRng, styles, FrameTime{0, count, fps}); - for (int index = 0; index < count; ++index) { + const int total = order.empty() ? count : static_cast(order.size()); + int handed = 0; + for (int position = 0; position < total; ++position) { + const int index = order.empty() ? position : order[static_cast(position)]; + if (index < 0 || index >= count) continue; const FrameTime frame{index, count, fps}; Rng rng = prefixRng; Image image = still == running.size() ? prefix : runStages(running, still, running.size(), sources, prefix, width, height, rng, styles, frame); - if (!onFrame(index, std::move(image))) return index + 1; + ++handed; + if (!onFrame(index, std::move(image))) break; } - return count; + return handed; +} + +std::vector draftFirstOrder(int count, int step) { + step = std::max(1, step); + std::vector order; + order.reserve(static_cast(std::max(0, count))); + for (int index = 0; index < count; index += step) order.push_back(index); + for (int index = 0; index < count; ++index) { + if (index % step != 0) order.push_back(index); + } + return order; } bool chainNeedsSources(const std::vector& stages, bool video) { diff --git a/src/core/chain.hpp b/src/core/chain.hpp index dc99013..b5bb551 100644 --- a/src/core/chain.hpp +++ b/src/core/chain.hpp @@ -71,10 +71,20 @@ Image renderChain(const std::vector& stages, const std::vector& stages, const std::vector& sources, int width, int height, std::uint64_t seed, const StyleSet& styles, const VideoSettings& video, - const std::function& onFrame); + const std::function& onFrame, + const std::vector& order = {}); + +// Every frame of a clip of `count`, draft first: every `step`th frame from +// the first, which held for `step` frames each plays the whole clip roughly, +// and then the frames between them, in order. +std::vector draftFirstOrder(int count, int step); // Whether the chain has to be given source images. Only the first stage that // runs ever sees them — every later one works on the stage before it — so this diff --git a/src/gui/window.cpp b/src/gui/window.cpp index 1c34d3a..b6ba65a 100644 --- a/src/gui/window.cpp +++ b/src/gui/window.cpp @@ -51,6 +51,10 @@ constexpr double kClipBudgetBytes = 256.0 * 1024 * 1024; constexpr int kClipMinSide = 160; constexpr int kClipMaxSide = 720; +// A preview clip renders every third frame first and plays those, each held +// for three frames, while the frames between them are filled in. +constexpr int kDraftStep = 3; + 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)); @@ -688,26 +692,59 @@ void EffectorWindow::matchVideoPreset() { } // 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. +// textures here, on the main thread. They come draft first (draftFirstOrder): +// the clip on screen keeps playing until the draft frames are all in, then +// the draft takes its place and each later frame drops into it as it comes. +// The very first clip shows its frames as they come. void EffectorWindow::onClipProgress() { - std::vector frames; + std::vector> frames; { std::lock_guard 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; + Glib::RefPtr latest; + for (const auto& [index, frame] : frames) { + const auto slot = static_cast(index); + if (slot >= incomingClip_.size()) continue; // the clip was cleared meanwhile + latest = toTexture(frame); + incomingClip_[slot] = latest; + ++incomingReceived_; + if (draftShown_ && slot < clip_.size()) clip_[slot] = latest; + } + if (!latest) return; - if (clip_.empty()) { - picture_.set_paintable(incomingClip_.back()); + const int draftFrames = (incomingTotal_ + kDraftStep - 1) / kDraftStep; + if (!draftShown_ && incomingReceived_ >= draftFrames) { + showIncomingClip(); + } else if (clip_.empty()) { + picture_.set_paintable(latest); previewStack_.set_visible_child("picture"); } - status_.set_text("Rendering preview · frame " + std::to_string(incomingClip_.size()) + " of " + + status_.set_text(std::string(draftShown_ ? "Refining preview" : "Rendering preview") + + " · frame " + std::to_string(incomingReceived_) + " of " + std::to_string(incomingTotal_)); } +// Puts the clip being rendered on screen, a frame not rendered yet held from +// the one before it. +void EffectorWindow::showIncomingClip() { + std::vector> clip; + clip.reserve(incomingClip_.size()); + Glib::RefPtr held; + for (const auto& texture : incomingClip_) { + if (texture) held = texture; + clip.push_back(held); + } + if (!clip.empty() && !clip.front()) return; // not even the first frame yet + clip_ = std::move(clip); + clipFps_ = incomingFps_; + draftShown_ = true; + if (playFrame_ >= static_cast(clip_.size())) playFrame_ = 0; + previewStack_.set_visible_child("picture"); + startPlayback(); +} + void EffectorWindow::showClipFrame(int index) { if (clip_.empty()) return; playFrame_ = std::clamp(index, 0, static_cast(clip_.size()) - 1); @@ -752,6 +789,7 @@ void EffectorWindow::clearClip() { stopPlayback(); clip_.clear(); incomingClip_.clear(); + draftShown_ = false; playFrame_ = 0; playbackBar_.set_visible(false); } @@ -2218,8 +2256,11 @@ void EffectorWindow::startRender() { std::lock_guard lock(resultMutex_); pendingFrames_.clear(); } - incomingClip_.clear(); incomingTotal_ = video.frameCount(); + incomingClip_.assign(static_cast(incomingTotal_), nullptr); + incomingReceived_ = 0; + incomingFps_ = video.fps; + draftShown_ = false; status_.set_text("Rendering preview…"); } @@ -2230,19 +2271,22 @@ void EffectorWindow::startRender() { try { if (video.enabled) { // Rendered at the full size, so the preview shows what the - // file will hold, and shrunk only to be kept. + // file will hold, and shrunk only to be kept. A draft of + // every few frames comes first, so the clip plays early. 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 lock(resultMutex_); - pendingFrames_.push_back(std::move(small)); - } - clipProgress_.emit(); - return true; - }); + renderFrames( + stages, *sources, width, height, seed, styles, video, + [this, side](int index, Image&& frame) { + if (renderCancel_.load(std::memory_order_relaxed)) return false; + Image small = frame.scaledToFit(side); + { + std::lock_guard lock(resultMutex_); + pendingFrames_.emplace_back(index, std::move(small)); + } + clipProgress_.emit(); + return true; + }, + draftFirstOrder(video.frameCount(), kDraftStep)); } else { Rng rng(seed); image = renderChain(stages, *sources, width, height, rng, styles); @@ -2305,13 +2349,9 @@ void EffectorWindow::onRenderFinished() { const std::size_t effects = enabledStages(stages_, wasVideo).size(); if (wasVideo) { result_ = Image(); - clip_ = std::move(incomingClip_); + showIncomingClip(); incomingClip_.clear(); - clipFps_ = videoSettings().fps; - if (playFrame_ >= static_cast(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 { diff --git a/src/gui/window.hpp b/src/gui/window.hpp index 5285b22..5bc0236 100644 --- a/src/gui/window.hpp +++ b/src/gui/window.hpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include "cli/options.hpp" @@ -102,6 +103,7 @@ private: // The preview of a video is the whole clip, rendered first and then looped. void onClipProgress(); // dispatcher: frames arrived from the worker + void showIncomingClip(); // the clip in flight takes the screen, gaps held void showClipFrame(int index); void startPlayback(); void stopPlayback(); @@ -246,12 +248,17 @@ private: 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. + // of the clip being rendered, by index, which take its place once its + // draft frames are in (showIncomingClip). Glib::Dispatcher clipProgress_; - std::vector pendingFrames_; // handed over under resultMutex_ + std::vector> pendingFrames_; // by index, under resultMutex_ std::vector> clip_; std::vector> incomingClip_; int incomingTotal_ = 0; + int incomingReceived_ = 0; + int incomingFps_ = kDefaultFps; + // The clip on screen is the one being rendered, its draft frames all in. + bool draftShown_ = false; int clipFps_ = kDefaultFps; int playFrame_ = 0; bool playing_ = true; diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index ab52820..a71fcad 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -1959,6 +1959,25 @@ int main() { [&](int, effector::Image&&) { return ++seen < 3; }); check(seen == 3, "returning false stops a clip early"); + // A preview renders a draft of every third frame first; the frames + // are the same whichever order they come in. + const auto order = effector::draftFirstOrder(8, 3); + check(order == std::vector({0, 3, 6, 1, 2, 4, 5, 7}), + "a draft order takes every third frame first, then the rest"); + std::vector drafted(8); + const int draftHanded = effector::renderFrames( + chain, sources, 80, 48, 21, {}, video, + [&](int index, effector::Image&& frame) { + drafted[static_cast(index)] = std::move(frame); + return true; + }, + order); + bool sameClip = draftHanded == 8; + for (std::size_t i = 0; sameClip && i < first.size(); ++i) { + sameClip = sameBytes(first[i], drafted[i]); + } + check(sameClip, "a clip rendered draft first holds exactly the same frames"); + chain[1].enabled = false; std::vector skipped; effector::renderFrames(chain, sources, 80, 48, 21, {}, video,