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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
+21
-4
@@ -118,7 +118,8 @@ Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image
|
||||
int renderFrames(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
||||
int width, int height, std::uint64_t seed, const StyleSet& styles,
|
||||
const VideoSettings& video,
|
||||
const std::function<bool(int index, Image&& frame)>& onFrame) {
|
||||
const std::function<bool(int index, Image&& frame)>& onFrame,
|
||||
const std::vector<int>& 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<ChainStage>& stages, const std::vector<Image>
|
||||
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<int>(order.size());
|
||||
int handed = 0;
|
||||
for (int position = 0; position < total; ++position) {
|
||||
const int index = order.empty() ? position : order[static_cast<std::size_t>(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<int> draftFirstOrder(int count, int step) {
|
||||
step = std::max(1, step);
|
||||
std::vector<int> order;
|
||||
order.reserve(static_cast<std::size_t>(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<ChainStage>& stages, bool video) {
|
||||
|
||||
+11
-1
@@ -71,10 +71,20 @@ Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image
|
||||
// frame. The stages before the first video effect are rendered once, and
|
||||
// their picture and the rng state they leave are reused for every frame.
|
||||
// Returns the number of frames handed out.
|
||||
//
|
||||
// `order` lists the frame indices to render, in the order to render them;
|
||||
// empty means every frame from the first. Each frame is the same whichever
|
||||
// order it comes in, so a preview can render a draft first (draftFirstOrder).
|
||||
int renderFrames(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
||||
int width, int height, std::uint64_t seed, const StyleSet& styles,
|
||||
const VideoSettings& video,
|
||||
const std::function<bool(int index, Image&& frame)>& onFrame);
|
||||
const std::function<bool(int index, Image&& frame)>& onFrame,
|
||||
const std::vector<int>& 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<int> 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
|
||||
|
||||
+66
-26
@@ -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<Image> frames;
|
||||
std::vector<std::pair<int, 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;
|
||||
Glib::RefPtr<Gdk::Texture> latest;
|
||||
for (const auto& [index, frame] : frames) {
|
||||
const auto slot = static_cast<std::size_t>(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<Glib::RefPtr<Gdk::Texture>> clip;
|
||||
clip.reserve(incomingClip_.size());
|
||||
Glib::RefPtr<Gdk::Texture> 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<int>(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<int>(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<std::mutex> lock(resultMutex_);
|
||||
pendingFrames_.clear();
|
||||
}
|
||||
incomingClip_.clear();
|
||||
incomingTotal_ = video.frameCount();
|
||||
incomingClip_.assign(static_cast<std::size_t>(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<std::mutex> 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<std::mutex> 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<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 {
|
||||
|
||||
+9
-2
@@ -10,6 +10,7 @@
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#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<Image> pendingFrames_; // handed over under resultMutex_
|
||||
std::vector<std::pair<int, Image>> pendingFrames_; // by index, under resultMutex_
|
||||
std::vector<Glib::RefPtr<Gdk::Texture>> clip_;
|
||||
std::vector<Glib::RefPtr<Gdk::Texture>> 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;
|
||||
|
||||
@@ -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<int>({0, 3, 6, 1, 2, 4, 5, 7}),
|
||||
"a draft order takes every third frame first, then the rest");
|
||||
std::vector<effector::Image> drafted(8);
|
||||
const int draftHanded = effector::renderFrames(
|
||||
chain, sources, 80, 48, 21, {}, video,
|
||||
[&](int index, effector::Image&& frame) {
|
||||
drafted[static_cast<std::size_t>(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<effector::Image> skipped;
|
||||
effector::renderFrames(chain, sources, 80, 48, 21, {}, video,
|
||||
|
||||
Reference in New Issue
Block a user