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,