diff --git a/viewer/assets/qml/Profiler.qml b/viewer/assets/qml/Profiler.qml
index c8fde993e3..56628a708a 100644
--- a/viewer/assets/qml/Profiler.qml
+++ b/viewer/assets/qml/Profiler.qml
@@ -217,6 +217,133 @@ Item {
}
}
+ /* Spacer before Node Statistics (only visible in PAGX mode) */
+ Item {
+ width: 1
+ height: sectionSpacing
+ visible: nodeStatistics.visible
+ }
+
+ /* Node Statistics for PAGX */
+ Rectangle {
+ id: nodeStatistics
+ visible: runTimeDataModel && runTimeDataModel.nodeStatsModel.totalCount > 0
+ height: visible ? nodeStatsContent.height : 0
+ width: parent.width
+ color: "#00000000"
+
+ Column {
+ id: nodeStatsContent
+ width: parent.width
+ spacing: 1
+
+ // Title row
+ Rectangle {
+ height: 24
+ width: parent.width
+ color: "#2D2D37"
+ Text {
+ text: qsTr("Node Distribution")
+ font.pixelSize: 11
+ font.bold: true
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ anchors.leftMargin: 8
+ color: "#FFFFFF"
+ renderType: Text.NativeRendering
+ }
+ Text {
+ text: runTimeDataModel && runTimeDataModel.nodeStatsModel ? runTimeDataModel.nodeStatsModel.totalCount + " " + qsTr("nodes") : ""
+ font.pixelSize: 11
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: parent.right
+ anchors.rightMargin: 8
+ color: "#9B9B9B"
+ renderType: Text.NativeRendering
+ }
+ }
+
+ // Stacked bar showing distribution
+ Rectangle {
+ height: 20
+ width: parent.width
+ color: "#2D2D37"
+
+ Row {
+ id: nodeBarRow
+ anchors.fill: parent
+ anchors.margins: 4
+ spacing: 1
+
+ Repeater {
+ model: runTimeDataModel ? runTimeDataModel.nodeStatsModel : null
+ Rectangle {
+ height: parent.height
+ width: {
+ var total = runTimeDataModel && runTimeDataModel.nodeStatsModel ? Math.max(runTimeDataModel.nodeStatsModel.totalCount, 1) : 1;
+ return Math.max((nodeBarRow.width - 10) * count / total, 0);
+ }
+ color: colorCode
+ radius: 2
+ }
+ }
+ }
+ }
+
+ // Detail rows
+ Repeater {
+ model: runTimeDataModel ? runTimeDataModel.nodeStatsModel : null
+ Rectangle {
+ height: 20
+ width: nodeStatsContent.width
+ color: "#2D2D37"
+
+ Rectangle {
+ width: 4
+ height: 12
+ radius: 2
+ color: colorCode
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ anchors.leftMargin: 8
+ }
+
+ Text {
+ text: name
+ font.pixelSize: 10
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ anchors.leftMargin: 18
+ color: "#9B9B9B"
+ renderType: Text.NativeRendering
+ }
+
+ Text {
+ text: {
+ var total = runTimeDataModel && runTimeDataModel.nodeStatsModel ? Math.max(runTimeDataModel.nodeStatsModel.totalCount, 1) : 1;
+ return Math.round(count * 100 / total) + "%";
+ }
+ font.pixelSize: 10
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.horizontalCenter: parent.horizontalCenter
+ color: "#9B9B9B"
+ renderType: Text.NativeRendering
+ }
+
+ Text {
+ text: count
+ font.pixelSize: 11
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: parent.right
+ anchors.rightMargin: 8
+ color: "#FFFFFF"
+ renderType: Text.NativeRendering
+ }
+ }
+ }
+ }
+ }
+
/* Spacer before Performance Chart (only visible in PAG mode) */
Item {
width: 1
@@ -663,133 +790,6 @@ Item {
}
}
- /* Spacer before Node Statistics (only visible in PAGX mode) */
- Item {
- width: 1
- height: sectionSpacing
- visible: nodeStatistics.visible
- }
-
- /* Node Statistics for PAGX */
- Rectangle {
- id: nodeStatistics
- visible: runTimeDataModel && runTimeDataModel.nodeStatsModel.totalCount > 0
- height: visible ? nodeStatsContent.height : 0
- width: parent.width
- color: "#00000000"
-
- Column {
- id: nodeStatsContent
- width: parent.width
- spacing: 1
-
- // Title row
- Rectangle {
- height: 24
- width: parent.width
- color: "#2D2D37"
- Text {
- text: qsTr("Node Distribution")
- font.pixelSize: 11
- font.bold: true
- anchors.verticalCenter: parent.verticalCenter
- anchors.left: parent.left
- anchors.leftMargin: 8
- color: "#FFFFFF"
- renderType: Text.NativeRendering
- }
- Text {
- text: runTimeDataModel && runTimeDataModel.nodeStatsModel ? runTimeDataModel.nodeStatsModel.totalCount + " " + qsTr("nodes") : ""
- font.pixelSize: 11
- anchors.verticalCenter: parent.verticalCenter
- anchors.right: parent.right
- anchors.rightMargin: 8
- color: "#9B9B9B"
- renderType: Text.NativeRendering
- }
- }
-
- // Stacked bar showing distribution
- Rectangle {
- height: 20
- width: parent.width
- color: "#2D2D37"
-
- Row {
- id: nodeBarRow
- anchors.fill: parent
- anchors.margins: 4
- spacing: 1
-
- Repeater {
- model: runTimeDataModel ? runTimeDataModel.nodeStatsModel : null
- Rectangle {
- height: parent.height
- width: {
- var total = runTimeDataModel && runTimeDataModel.nodeStatsModel ? Math.max(runTimeDataModel.nodeStatsModel.totalCount, 1) : 1;
- return Math.max((nodeBarRow.width - 10) * count / total, 0);
- }
- color: colorCode
- radius: 2
- }
- }
- }
- }
-
- // Detail rows
- Repeater {
- model: runTimeDataModel ? runTimeDataModel.nodeStatsModel : null
- Rectangle {
- height: 20
- width: nodeStatsContent.width
- color: "#2D2D37"
-
- Rectangle {
- width: 4
- height: 12
- radius: 2
- color: colorCode
- anchors.verticalCenter: parent.verticalCenter
- anchors.left: parent.left
- anchors.leftMargin: 8
- }
-
- Text {
- text: name
- font.pixelSize: 10
- anchors.verticalCenter: parent.verticalCenter
- anchors.left: parent.left
- anchors.leftMargin: 18
- color: "#9B9B9B"
- renderType: Text.NativeRendering
- }
-
- Text {
- text: {
- var total = runTimeDataModel && runTimeDataModel.nodeStatsModel ? Math.max(runTimeDataModel.nodeStatsModel.totalCount, 1) : 1;
- return Math.round(count * 100 / total) + "%";
- }
- font.pixelSize: 10
- anchors.verticalCenter: parent.verticalCenter
- anchors.horizontalCenter: parent.horizontalCenter
- color: "#9B9B9B"
- renderType: Text.NativeRendering
- }
-
- Text {
- text: count
- font.pixelSize: 11
- anchors.verticalCenter: parent.verticalCenter
- anchors.right: parent.right
- anchors.rightMargin: 8
- color: "#FFFFFF"
- renderType: Text.NativeRendering
- }
- }
- }
- }
- }
-
/* Spacer before FPS Info (only visible in PAGX mode) */
Item {
width: 1
diff --git a/viewer/assets/translation/Chinese.ts b/viewer/assets/translation/Chinese.ts
index 9f5df745f6..76bff4816b 100644
--- a/viewer/assets/translation/Chinese.ts
+++ b/viewer/assets/translation/Chinese.ts
@@ -449,12 +449,12 @@
建议将视频序列帧数量控制在两个以内
-
+
Node Distribution
节点分布
-
+
nodes
个节点
@@ -602,27 +602,31 @@
pag::PAGXViewModel
-
+
Failed to parse XML: invalid syntax or structure
解析 XML 失败:语法或结构无效
-
Failed to build layer from XML document
- 从 XML 文档构建图层失败
+ 从 XML 文档构建图层失败
-
+
+ Failed to build PAGScene from XML document
+ 从 XML 文档构建 PAGScene 失败
+
+
+
No file path specified
未指定文件路径
-
+
Failed to open file for writing: %1
无法打开文件进行写入:%1
-
+
Failed to write all data to file
写入文件数据不完整
diff --git a/viewer/src/profiling/PAGRunTimeDataModel.cpp b/viewer/src/profiling/PAGRunTimeDataModel.cpp
index 09dc513ba5..94db0996ee 100644
--- a/viewer/src/profiling/PAGRunTimeDataModel.cpp
+++ b/viewer/src/profiling/PAGRunTimeDataModel.cpp
@@ -17,6 +17,7 @@
/////////////////////////////////////////////////////////////////////////////////////////////////
#include "PAGRunTimeDataModel.h"
+#include
#include "base/utils/TimeUtil.h"
#include "pag/pag.h"
@@ -128,8 +129,38 @@ void PAGRunTimeDataModel::setPAGFile(std::shared_ptr pagFile) {
}
void PAGRunTimeDataModel::setPAGXDocument(std::shared_ptr pagxDocument) {
- frameModeEnabled = false;
- totalFrame = pagxDocument ? 1 : 0;
+ if (pagxDocument != nullptr && !pagxDocument->animations.empty()) {
+ // The animations vector may contain Animation or StateMachine entries; only Animation exposes
+ // frameRate and duration fields.
+ auto* firstAnim = pagxDocument->animations[0];
+ if (firstAnim != nullptr && firstAnim->nodeType() == pagx::NodeType::Animation) {
+ auto* anim = static_cast(firstAnim);
+ // Derive totalFrame from the microsecond duration using the same two-step rounding as the
+ // render path (PAGTimeline::duration truncates frames→microseconds, then frames are recovered
+ // via round). Using anim->duration directly would diverge by up to one frame at non-integer
+ // frame rates, causing the render-side clamped last frame to be dropped by updateData's guard.
+ if (anim->frameRate > 0.0f && anim->duration > 0) {
+ auto durationUs = static_cast(static_cast(anim->duration) * 1000000.0 /
+ static_cast(anim->frameRate));
+ totalFrame = static_cast(std::round(
+ static_cast(durationUs) * static_cast(anim->frameRate) / 1000000.0));
+ } else {
+ totalFrame = 0;
+ }
+ if (totalFrame < 1) {
+ totalFrame = 1;
+ }
+ } else {
+ // A StateMachine-first document has no single duration/frameRate to derive a frame count
+ // from; fall back to a single frame so frame mode does not run with a stale/invalid value,
+ // matching PAGXViewModel::updateAnimationState()'s no-animation fallback.
+ totalFrame = 1;
+ }
+ frameModeEnabled = true;
+ } else {
+ totalFrame = pagxDocument ? 1 : 0;
+ frameModeEnabled = false;
+ }
currentFrame = pagxDocument ? 0 : -1;
lastUpdatedFrame = -1;
chartDataModel.clearItems();
diff --git a/viewer/src/rendering/pagx/PAGXRenderer.cpp b/viewer/src/rendering/pagx/PAGXRenderer.cpp
index a4239f3a7c..8801831733 100644
--- a/viewer/src/rendering/pagx/PAGXRenderer.cpp
+++ b/viewer/src/rendering/pagx/PAGXRenderer.cpp
@@ -17,10 +17,18 @@
/////////////////////////////////////////////////////////////////////////////////////////////////
#include "rendering/pagx/PAGXRenderer.h"
+#include
+#include
+#include "pagx/PAGSurface.h"
+#include "pagx/tgfx.h"
#include "tgfx/core/Clock.h"
namespace pag {
+// Minimum change in seek target (microseconds) before re-seeking the paused timeline. Avoids
+// redundant setCurrentTime/apply calls when the progress position is effectively unchanged.
+static constexpr int64_t SeekThresholdUs = 1000;
+
PAGXRenderer::PAGXRenderer(PAGXViewModel* viewModel) : viewModel(viewModel) {
}
@@ -41,6 +49,7 @@ void PAGXRenderer::updateSize() {
IContentRenderer::RenderMetrics PAGXRenderer::flush() {
RenderMetrics metrics = {};
+
if (!viewModel->takeNeedsRender()) {
return metrics;
}
@@ -49,11 +58,11 @@ IContentRenderer::RenderMetrics PAGXRenderer::flush() {
}
auto state = viewModel->getRenderState();
- if (state.displayList == nullptr) {
+ if (state.scene == nullptr) {
return metrics;
}
- auto device = this->drawable->getDevice();
+ auto device = drawable->getDevice();
if (device == nullptr) {
return metrics;
}
@@ -61,43 +70,55 @@ IContentRenderer::RenderMetrics PAGXRenderer::flush() {
if (context == nullptr) {
return metrics;
}
- auto surface = this->drawable->getSurface(context, false);
- if (surface == nullptr) {
+ auto tgfxSurface = drawable->getSurface(context, false);
+ if (tgfxSurface == nullptr) {
device->unlock();
return metrics;
}
- if (state.rootLayer != nullptr && state.contentWidth > 0 && state.contentHeight > 0) {
- int surfaceWidth = surface->width();
- int surfaceHeight = surface->height();
- float scaleX = static_cast(surfaceWidth) / static_cast(state.contentWidth);
- float scaleY = static_cast(surfaceHeight) / static_cast(state.contentHeight);
- float scale = std::min(scaleX, scaleY);
- float offsetX =
- (static_cast(surfaceWidth) - static_cast(state.contentWidth) * scale) * 0.5f;
- float offsetY =
- (static_cast(surfaceHeight) - static_cast(state.contentHeight) * scale) *
- 0.5f;
- auto matrix = tgfx::Matrix::MakeTrans(offsetX, offsetY);
- matrix.preScale(scale, scale);
- state.rootLayer->setMatrix(matrix);
+ // Reset the advance/seek trackers on a play-state transition OR a content reload (generation
+ // bump), so a paused→resumed cycle does not count pause time as animation delta, and a freshly
+ // loaded scene always re-seats to its start instead of inheriting the previous file's timers.
+ bool playStateChanged = state.isPlaying != lastIsPlaying;
+ bool generationChanged = state.generation != lastGeneration;
+ bool reseat = playStateChanged || generationChanged;
+ if (reseat) {
+ lastIsPlaying = state.isPlaying;
+ lastGeneration = state.generation;
+ lastAdvanceTimeUs = -1;
+ lastSeekTimeUs = -1;
+ playbackFinishNotified = false;
}
- auto renderStart = tgfx::Clock::Now();
- auto canvas = surface->getCanvas();
- canvas->clear();
- state.displayList->render(surface.get());
- auto recording = context->flush();
- metrics.renderTime = tgfx::Clock::Now() - renderStart;
+ if (state.isPlaying && state.animation != nullptr && state.animation->duration() > 0) {
+ advancePlayback(state, reseat);
+ } else if (state.animation != nullptr && state.animation->duration() > 0) {
+ seekPaused(state, reseat);
+ }
- auto imageStart = tgfx::Clock::Now();
- if (recording) {
- context->submit(std::move(recording));
+ metrics.currentFrame = computeProfileFrame(state);
+
+ if (state.contentWidth > 0 && state.contentHeight > 0) {
+ applyFitTransform(state, tgfxSurface->width(), tgfxSurface->height());
}
- metrics.imageDecodeTime = tgfx::Clock::Now() - imageStart;
+
+ auto renderStart = tgfx::Clock::Now();
+ auto pagSurface = pagx::MakeFrom(tgfxSurface);
+ if (pagSurface == nullptr) {
+ device->unlock();
+ return metrics;
+ }
+ auto recording = pagx::Record(context, state.scene, pagSurface, true);
+ if (!recording) {
+ device->unlock();
+ return metrics;
+ }
+ context->submit(std::move(recording));
+ metrics.renderTime = tgfx::Clock::Now() - renderStart;
+ // PAGX rendering via pagx::Record has no distinct image-decode phase, so imageDecodeTime stays 0.
auto presentStart = tgfx::Clock::Now();
- this->drawable->present(context);
+ drawable->present(context);
metrics.presentTime = tgfx::Clock::Now() - presentStart;
metrics.rendered = true;
@@ -105,4 +126,128 @@ IContentRenderer::RenderMetrics PAGXRenderer::flush() {
return metrics;
}
+void PAGXRenderer::advancePlayback(PAGXViewModel::RenderState& state, bool reseat) {
+ // On a fresh play request or content reload, re-seat the animation at the requested position.
+ // PAGAnimation holds no play/pause state of its own (playback is gated by the owning
+ // PAGComposition's pausedTimelineIds), so replaying only requires seeking to the target time and
+ // applying it; the per-frame advanceAndApply below then drives it forward.
+ if (reseat) {
+ // Seek to the requested progress so playback starts from the position the UI shows,
+ // regardless of where the timeline was left. A finished non-looping (Once) animation is
+ // rewound to the start: the replay path (play button) leaves progress at ~1.0, and the user
+ // expects a restart rather than resuming stuck at the end. Looping animations (Loop/PingPong)
+ // are never rewound here because PingPong can legitimately sit at currentTime == duration at
+ // the mirror peak; they always resume from their reported progress. state.progress is in [0,1].
+ auto durationUs = state.animation->duration();
+ int64_t targetUs =
+ state.loopMode == pagx::LoopMode::Once && state.animation->currentTime() >= durationUs
+ ? 0
+ : static_cast(state.progress * static_cast(durationUs));
+ state.animation->setCurrentTime(targetUs);
+ state.animation->apply();
+ }
+ auto now = tgfx::Clock::Now();
+ if (lastAdvanceTimeUs < 0) {
+ lastAdvanceTimeUs = now;
+ }
+ int64_t deltaUs = now - lastAdvanceTimeUs;
+ lastAdvanceTimeUs = now;
+ if (deltaUs > 0) {
+ state.animation->advanceAndApply(deltaUs);
+ state.scene->advanceAndApply(deltaUs);
+ }
+ // Report the current playback progress back to the main thread for UI updates.
+ auto durationUs = state.animation->duration();
+ auto currentUs = state.animation->currentTime();
+ auto renderProgress = static_cast(currentUs) / static_cast(durationUs);
+ if (renderProgress < 0.0) {
+ renderProgress = 0.0;
+ }
+ if (renderProgress > 1.0) {
+ renderProgress = 1.0;
+ }
+ viewModel->updateProgressFromRender(renderProgress, state.generation);
+ // A non-looping animation stays at the last frame once it reaches the end. Notify the main
+ // thread so it flips the UI back to the paused state and stops requesting further renders.
+ // Looping modes (Loop/PingPong) never report finished: Loop keeps currentTime in [0, duration),
+ // while PingPong can momentarily land exactly on duration at the mirror peak, which must NOT be
+ // treated as finished.
+ if (state.loopMode == pagx::LoopMode::Once &&
+ state.animation->currentTime() >= state.animation->duration() && !playbackFinishNotified) {
+ playbackFinishNotified = true;
+ viewModel->notifyPlaybackFinished(state.generation);
+ }
+}
+
+void PAGXRenderer::seekPaused(PAGXViewModel::RenderState& state, bool reseat) {
+ auto durationUs = state.animation->duration();
+ if (reseat) {
+ if (state.seekRequested) {
+ // An explicit seek was requested alongside the pause (frame-step controls or slider while
+ // transitioning). Honor the target progress instead of reporting the timeline's actual
+ // position, which would overwrite the user's intent.
+ auto targetUs = static_cast(state.progress * static_cast(durationUs));
+ lastSeekTimeUs = targetUs;
+ state.animation->setCurrentTime(targetUs);
+ state.animation->apply();
+ } else {
+ // Pure pause (play button): the timeline already sits at the true playback position, which
+ // leads the main thread's progress snapshot by up to one queued frame. Keep the timeline
+ // where it is and report its actual position so the UI catches up.
+ auto currentUs = state.animation->currentTime();
+ lastSeekTimeUs = currentUs;
+ auto pausedProgress = static_cast(currentUs) / static_cast(durationUs);
+ if (pausedProgress < 0.0) {
+ pausedProgress = 0.0;
+ }
+ if (pausedProgress > 1.0) {
+ pausedProgress = 1.0;
+ }
+ viewModel->updateProgressFromRender(pausedProgress, state.generation);
+ }
+ } else {
+ // Steady-state paused (e.g. slider scrubbing): seek to the requested progress position.
+ auto targetUs = static_cast(state.progress * static_cast(durationUs));
+ if (lastSeekTimeUs < 0 || std::abs(targetUs - lastSeekTimeUs) >= SeekThresholdUs) {
+ lastSeekTimeUs = targetUs;
+ state.animation->setCurrentTime(targetUs);
+ state.animation->apply();
+ }
+ }
+}
+
+int64_t PAGXRenderer::computeProfileFrame(const PAGXViewModel::RenderState& state) const {
+ if (state.animation == nullptr || state.animation->duration() <= 0) {
+ return -1;
+ }
+ auto rate = state.animation->frameRate();
+ if (rate <= 0) {
+ return -1;
+ }
+ auto durationUs = state.animation->duration();
+ auto currentUs = state.animation->currentTime();
+ auto totalFrames =
+ static_cast(std::round(static_cast(durationUs) * rate / 1000000.0));
+ auto frame = static_cast(static_cast(currentUs) * rate / 1000000.0);
+ // Clamp to the last valid frame index so an animation ending exactly on a frame boundary
+ // (integer fps) still records the final frame instead of overflowing totalFrame.
+ if (totalFrames > 0 && frame >= totalFrames) {
+ frame = totalFrames - 1;
+ }
+ return frame;
+}
+
+void PAGXRenderer::applyFitTransform(PAGXViewModel::RenderState& state, int surfaceWidth,
+ int surfaceHeight) {
+ float scaleX = static_cast(surfaceWidth) / static_cast(state.contentWidth);
+ float scaleY = static_cast(surfaceHeight) / static_cast(state.contentHeight);
+ float scale = std::min(scaleX, scaleY);
+ float offsetX =
+ (static_cast(surfaceWidth) - static_cast(state.contentWidth) * scale) * 0.5f;
+ float offsetY =
+ (static_cast(surfaceHeight) - static_cast(state.contentHeight) * scale) * 0.5f;
+ state.scene->getDisplayOptions()->setZoomScale(scale);
+ state.scene->getDisplayOptions()->setContentOffset(offsetX, offsetY);
+}
+
} // namespace pag
diff --git a/viewer/src/rendering/pagx/PAGXRenderer.h b/viewer/src/rendering/pagx/PAGXRenderer.h
index 7b7034167a..bb2da15f3c 100644
--- a/viewer/src/rendering/pagx/PAGXRenderer.h
+++ b/viewer/src/rendering/pagx/PAGXRenderer.h
@@ -25,8 +25,7 @@
namespace pag {
/**
- * Renderer implementation for PAGX format content. Executes tgfx DisplayList rendering
- * and collects per-frame timing metrics.
+ * Renders a PAGX scene via pagx::Record() and drives its timeline with wall-clock deltas.
*/
class PAGXRenderer : public IContentRenderer {
public:
@@ -39,8 +38,42 @@ class PAGXRenderer : public IContentRenderer {
bool isReady() const override;
private:
+ // Advances the playing timeline and scene by the wall-clock delta since the last flush, reports
+ // progress back to the main thread, and notifies playback-finished for non-looping animations.
+ // reseat is true on the flush that first observes a paused→playing transition or a content reload
+ // (generation bump), which re-arms the timeline (seek to the shown progress or rewind a finished
+ // one, then play).
+ void advancePlayback(PAGXViewModel::RenderState& state, bool reseat);
+
+ // Handles the paused timeline: on a play-state transition or content reload it reports the
+ // timeline's actual position so the UI catches up; in steady state it seeks to the requested
+ // progress (slider scrubbing).
+ void seekPaused(PAGXViewModel::RenderState& state, bool reseat);
+
+ // Returns the current frame index for the profiling histogram, clamped to the last valid frame.
+ // Returns -1 when no frame applies (no timeline, zero duration, or non-positive frame rate).
+ int64_t computeProfileFrame(const PAGXViewModel::RenderState& state) const;
+
+ // Applies the fit-to-screen transform to the scene's display options so the content is centered
+ // and uniformly scaled to fit the surface.
+ void applyFitTransform(PAGXViewModel::RenderState& state, int surfaceWidth, int surfaceHeight);
+
PAGXViewModel* viewModel = nullptr;
GPUDrawable* drawable = nullptr;
+ // Last wall-clock time (microseconds) when the animation was advanced.
+ // Set to -1 on pause/stop so the first advance after resume starts from zero delta.
+ int64_t lastAdvanceTimeUs = -1;
+ // Last seek position in microseconds, used to avoid redundant seeks when progress is unchanged.
+ // Set to -1 on play-state transition so the first paused frame after a transition always seeks.
+ int64_t lastSeekTimeUs = -1;
+ // Tracks the playback state from the previous flush to detect transitions.
+ bool lastIsPlaying = false;
+ // Tracks the playback generation from the previous flush to detect content reloads, so a freshly
+ // loaded scene re-seats to its start instead of inheriting the previous file's advance timers.
+ uint64_t lastGeneration = 0;
+ // Guards against posting duplicate finish notifications every frame after a non-looping
+ // animation ends, until the main thread flips the playing state off.
+ bool playbackFinishNotified = false;
};
} // namespace pag
diff --git a/viewer/src/rendering/pagx/PAGXView.cpp b/viewer/src/rendering/pagx/PAGXView.cpp
index 37ba32ebfb..245165515a 100644
--- a/viewer/src/rendering/pagx/PAGXView.cpp
+++ b/viewer/src/rendering/pagx/PAGXView.cpp
@@ -82,7 +82,8 @@ void PAGXView::onSizeChangedDelayHandled() {
}
void PAGXView::flush() const {
- if (viewModel->hasContent()) {
+ if (viewModel->hasContent() && viewModel->isPlaying()) {
+ viewModel->markNeedsRender();
triggerFlush();
}
}
diff --git a/viewer/src/rendering/pagx/PAGXViewModel.cpp b/viewer/src/rendering/pagx/PAGXViewModel.cpp
index 697dec5fac..7bd1414856 100644
--- a/viewer/src/rendering/pagx/PAGXViewModel.cpp
+++ b/viewer/src/rendering/pagx/PAGXViewModel.cpp
@@ -18,11 +18,11 @@
#include "rendering/pagx/PAGXViewModel.h"
#include
+#include
#include
#include
#include "pag/pag.h"
#include "pagx/PAGXImporter.h"
-#include "renderer/LayerBuilder.h"
namespace pag {
@@ -150,14 +150,54 @@ void PAGXViewModel::markNeedsRender() {
needsRender = true;
}
+void PAGXViewModel::updateProgressFromRender(double newProgress, uint64_t generation) {
+ QMetaObject::invokeMethod(this, "applyProgressFromRender", Qt::QueuedConnection,
+ Q_ARG(double, newProgress),
+ Q_ARG(quint64, static_cast(generation)));
+}
+
+void PAGXViewModel::applyProgressFromRender(double newProgress, quint64 generation) {
+ // Ignore a progress update from an earlier playback session; content may have been reloaded.
+ if (generation != playbackGeneration.load()) {
+ return;
+ }
+ if (std::abs(progress - newProgress) < 1e-9) {
+ return;
+ }
+ progress = newProgress;
+ Q_EMIT progressChanged(progress);
+}
+
+void PAGXViewModel::notifyPlaybackFinished(uint64_t generation) {
+ QMetaObject::invokeMethod(this, "handlePlaybackFinished", Qt::QueuedConnection,
+ Q_ARG(quint64, static_cast(generation)));
+}
+
+void PAGXViewModel::handlePlaybackFinished(quint64 generation) {
+ // Ignore a finish event from an earlier playback: if the user restarted playback after the
+ // event was queued, playbackGeneration has advanced and this notification is stale.
+ if (generation != playbackGeneration.load()) {
+ return;
+ }
+ setIsPlaying(false);
+}
+
PAGXViewModel::RenderState PAGXViewModel::getRenderState() {
std::lock_guard lock(renderMutex);
- return {displayList, pagxRootLayer, pagxWidth, pagxHeight};
+ return {scene,
+ defaultAnimation,
+ defaultLoopMode,
+ pagxWidth,
+ pagxHeight,
+ isPlaying_.load(),
+ progress,
+ pendingSeek.exchange(false),
+ playbackGeneration.load()};
}
bool PAGXViewModel::hasContent() {
std::lock_guard lock(renderMutex);
- return displayList != nullptr;
+ return scene != nullptr;
}
void PAGXViewModel::setIsPlaying(bool isPlaying) {
@@ -167,11 +207,15 @@ void PAGXViewModel::setIsPlaying(bool isPlaying) {
if (isPlaying_ == isPlaying) {
return;
}
- isPlaying_ = isPlaying;
- Q_EMIT isPlayingChanged(isPlaying);
if (isPlaying) {
- Q_EMIT requestFlush();
+ playbackGeneration++;
}
+ isPlaying_ = isPlaying;
+ Q_EMIT isPlayingChanged(isPlaying);
+ // Request a render on every transition, not just on play: the render thread needs one flush to
+ // observe the state change (re-arm or seek the timeline and update its transition tracker).
+ needsRender = true;
+ Q_EMIT requestFlush();
}
void PAGXViewModel::setProgress(double newProgress) {
@@ -179,6 +223,7 @@ void PAGXViewModel::setProgress(double newProgress) {
return;
}
progress = newProgress;
+ pendingSeek = true;
Q_EMIT progressChanged(progress);
needsRender = true;
Q_EMIT requestFlush();
@@ -205,24 +250,21 @@ bool PAGXViewModel::loadFile(const QString& filePath) {
}
document->applyLayout();
- auto newContentLayer = pagx::LayerBuilder::Build(document.get());
- if (newContentLayer == nullptr) {
+ auto newScene = pagx::PAGScene::Make(document);
+ if (newScene == nullptr) {
{
std::lock_guard lock(renderMutex);
clearContent();
+ // Reset playback state so a prior playing session does not leave the controls stuck; with a
+ // null timeline this stops playback, zeroes the frame counters, and bumps the generation.
+ updateAnimationState();
}
Q_EMIT filePathChanged("");
Q_EMIT pagxDocumentChanged(nullptr);
+ emitContentStateReset();
return false;
}
- auto newContainer = tgfx::Layer::Make();
- newContainer->addChild(newContentLayer);
-
- auto newDisplayList = std::make_shared();
- newDisplayList->root()->addChild(newContainer);
-
- // Store XML content for later update to XmlLinesModel
auto xmlString = QString::fromUtf8(reinterpret_cast(byteData->data()),
static_cast(byteData->length()));
@@ -231,18 +273,17 @@ bool PAGXViewModel::loadFile(const QString& filePath) {
clearContent();
currentFilePath = strPath;
pagxDocument = document;
+ scene = newScene;
+ resolveDefaultAnimation(document);
pagxWidth = static_cast(document->width);
pagxHeight = static_cast(document->height);
- pagxRootLayer = newContainer;
updateAnimationState();
- displayList = std::move(newDisplayList);
needsRender = true;
}
Q_EMIT filePathChanged(QString::fromLocal8Bit(strPath.data()));
Q_EMIT widthChanged(pagxWidth);
Q_EMIT heightChanged(pagxHeight);
- Q_EMIT totalFrameChanged();
- Q_EMIT hasAnimationChanged(hasAnimation());
+ emitContentStateReset();
Q_EMIT preferredSizeChanged();
Q_EMIT editableTextLayerCountChanged(0);
Q_EMIT editableImageLayerCountChanged(0);
@@ -290,17 +331,53 @@ void PAGXViewModel::previousFrame() {
void PAGXViewModel::clearContent() {
pagxDocument = nullptr;
- pagxRootLayer = nullptr;
- displayList = nullptr;
+ scene = nullptr;
+ defaultAnimation = nullptr;
+ defaultLoopMode = pagx::LoopMode::Once;
+}
+
+void PAGXViewModel::resolveDefaultAnimation(const std::shared_ptr& document) {
+ defaultAnimation = nullptr;
+ defaultLoopMode = pagx::LoopMode::Once;
+ if (!document->animations.empty()) {
+ auto* firstAnim = document->animations[0];
+ if (firstAnim != nullptr && firstAnim->nodeType() == pagx::NodeType::Animation) {
+ auto* anim = static_cast(firstAnim);
+ defaultAnimation = scene->getAnimation(anim->id);
+ defaultLoopMode = anim->loop;
+ }
+ }
+}
+
+void PAGXViewModel::emitContentStateReset() {
+ Q_EMIT progressChanged(0.0);
+ Q_EMIT isPlayingChanged(hasAnimation());
+ Q_EMIT totalFrameChanged();
+ Q_EMIT hasAnimationChanged(hasAnimation());
}
void PAGXViewModel::updateAnimationState() {
- // TODO: Read timeline properties from PAGXDocument when available.
- totalFrames = 1;
- frameRate = 0.0f;
+ if (defaultAnimation != nullptr && defaultAnimation->duration() > 0) {
+ auto durationUs = defaultAnimation->duration();
+ auto rate = defaultAnimation->frameRate();
+ totalFrames =
+ static_cast(std::round(static_cast(durationUs) * rate / 1000000.0));
+ if (totalFrames < 1) {
+ totalFrames = 1;
+ }
+ frameRate = rate;
+ progressPerFrame = 1.0 / static_cast(totalFrames);
+ } else {
+ totalFrames = 1;
+ frameRate = 0.0f;
+ progressPerFrame = 1.0;
+ }
progress = 0.0;
- progressPerFrame = 1.0;
- isPlaying_ = false;
+ // Bump the generation on every content rebuild so any progress or finish notification still
+ // queued from the previously loaded content is treated as stale and dropped on the main thread,
+ // even when the new content does not animate.
+ playbackGeneration++;
+ isPlaying_ = hasAnimation();
}
XmlLinesModel* PAGXViewModel::linesModel() const {
@@ -316,30 +393,25 @@ QString PAGXViewModel::applyXmlChanges(const QString& newXml) {
}
document->applyLayout();
- auto newContentLayer = pagx::LayerBuilder::Build(document.get());
- if (newContentLayer == nullptr) {
- return tr("Failed to build layer from XML document");
+ auto newScene = pagx::PAGScene::Make(document);
+ if (newScene == nullptr) {
+ return tr("Failed to build PAGScene from XML document");
}
- auto newContainer = tgfx::Layer::Make();
- newContainer->addChild(newContentLayer);
-
- auto newDisplayList = std::make_shared();
- newDisplayList->root()->addChild(newContainer);
-
{
std::lock_guard lock(renderMutex);
pagxDocument = document;
pagxWidth = static_cast(document->width);
pagxHeight = static_cast(document->height);
- pagxRootLayer = newContainer;
+ scene = newScene;
+ resolveDefaultAnimation(document);
updateAnimationState();
- displayList = std::move(newDisplayList);
needsRender = true;
}
Q_EMIT widthChanged(pagxWidth);
Q_EMIT heightChanged(pagxHeight);
+ emitContentStateReset();
Q_EMIT contentSizeChanged();
Q_EMIT pagxDocumentChanged(pagxDocument);
Q_EMIT requestFlush();
diff --git a/viewer/src/rendering/pagx/PAGXViewModel.h b/viewer/src/rendering/pagx/PAGXViewModel.h
index 8fa10e066d..4083271f8f 100644
--- a/viewer/src/rendering/pagx/PAGXViewModel.h
+++ b/viewer/src/rendering/pagx/PAGXViewModel.h
@@ -20,18 +20,18 @@
#include
#include
+#include "pagx/PAGAnimation.h"
+#include "pagx/PAGScene.h"
#include "pagx/PAGXDocument.h"
#include "rendering/ContentViewModel.h"
#include "rendering/pagx/XmlLinesModel.h"
-#include "tgfx/layers/DisplayList.h"
-#include "tgfx/layers/Layer.h"
class QQuickWindow;
namespace pag {
/**
- * ViewModel for PAGX format content. Owns PAGXDocument and DisplayList,
+ * ViewModel for PAGX format content. Owns PAGXDocument and PAGScene with PAGTimeline,
* and manages playback state for PAGX content.
*/
class PAGXViewModel : public ContentViewModel {
@@ -72,20 +72,50 @@ class PAGXViewModel : public ContentViewModel {
Q_INVOKABLE QString saveXmlToFile(const QString& xml);
struct RenderState {
- std::shared_ptr displayList;
- // A transform container whose single child is the layer built by LayerBuilder.
- std::shared_ptr rootLayer;
+ std::shared_ptr scene;
+ std::shared_ptr animation;
+ pagx::LoopMode loopMode = pagx::LoopMode::Once;
int contentWidth = 0;
int contentHeight = 0;
+ bool isPlaying = false;
+ double progress = 0.0;
+ bool seekRequested = false;
+ uint64_t generation = 0;
};
void setWindow(QQuickWindow* window);
bool takeNeedsRender();
void markNeedsRender();
- // Returns a snapshot of render state with shared ownership; safe to use without holding any lock.
+ /**
+ * Returns a snapshot of render state with shared ownership; the render thread may then operate on
+ * the snapshot's scene/timeline without holding renderMutex.
+ *
+ * This is safe even though PAGScene/PAGTimeline are not thread-safe, because each content load
+ * builds brand-new scene/timeline objects (never reused) and swaps them into the members under
+ * renderMutex. The main thread performs all of its accesses to a given object (build,
+ * getAnimation, updateAnimationState) inside that lock and never touches the object again
+ * after the swap. The render thread reaches an object only via this snapshot, whose lock
+ * establishes a happens-before ordering with the main thread's setup. So any single
+ * scene/timeline is accessed by the main thread first (under the lock) and by the render thread
+ * afterwards, never concurrently: the caller-serialization the classes require is satisfied.
+ */
RenderState getRenderState();
bool hasContent();
+ /**
+ * Updates playback progress from any thread; the signal is emitted on the main thread. The
+ * generation identifies the playback session the update belongs to, so an update from a session
+ * that ended (e.g. after a content reload) is ignored on the main thread.
+ */
+ void updateProgressFromRender(double newProgress, uint64_t generation);
+
+ /**
+ * Signals from any thread that the animation for the given playback generation has reached its
+ * end and stopped; the playing state is cleared on the main thread only if the generation is
+ * still current.
+ */
+ void notifyPlaybackFinished(uint64_t generation);
+
Q_SIGNAL void pagxDocumentChanged(std::shared_ptr pagxDocument);
/**
@@ -96,15 +126,33 @@ class PAGXViewModel : public ContentViewModel {
private:
void clearContent();
+ void resolveDefaultAnimation(const std::shared_ptr& document);
void updateAnimationState();
+ // Emits the content-state reset signals shared by loadFile (both success and failure paths) and
+ // applyXmlChanges: progress, playing, total-frame and has-animation. Centralized so a new content
+ // load cannot silently forget to refresh one of them. Call after updateAnimationState() so the
+ // emitted values reflect the freshly loaded (or cleared) content.
+ void emitContentStateReset();
+
+ // Applies a progress update on the main thread. Invoked by name from updateProgressFromRender via
+ // a queued connection so the signal is always emitted on the main thread. The generation
+ // identifies the playback session; a stale update from a session that ended is ignored.
+ Q_SLOT void applyProgressFromRender(double newProgress, quint64 generation);
+
+ // Clears the playing state on the main thread. Invoked by name from notifyPlaybackFinished via a
+ // queued connection when the animation reaches its end. The generation identifies the playback
+ // session the notification belongs to, so a stale event from a finished session is ignored.
+ Q_SLOT void handlePlaybackFinished(quint64 generation);
+
QQuickWindow* window = nullptr;
std::atomic_bool needsRender = false;
+ std::atomic pendingSeek = false;
std::mutex renderMutex = {};
std::shared_ptr pagxDocument = nullptr;
- // A transform container whose single child is the layer built by LayerBuilder.
- std::shared_ptr pagxRootLayer = nullptr;
- std::shared_ptr displayList = nullptr;
+ std::shared_ptr scene = nullptr;
+ std::shared_ptr defaultAnimation = nullptr;
+ pagx::LoopMode defaultLoopMode = pagx::LoopMode::Once;
int pagxWidth = 0;
int pagxHeight = 0;
std::string currentFilePath = {};
@@ -112,9 +160,12 @@ class PAGXViewModel : public ContentViewModel {
QString pendingXmlContent = {};
int64_t totalFrames = 1;
float frameRate = 0.0f;
- double progress = 0.0;
+ std::atomic progress = 0.0;
double progressPerFrame = 1.0;
std::atomic isPlaying_ = false;
+ // Incremented each time playback starts. Stamped onto finish notifications so a notification from
+ // a prior session can be distinguished from the current one and dropped if the user restarted.
+ std::atomic playbackGeneration = 0;
};
} // namespace pag