From 6736f5d4bfbef5561b31a8512c3faabe178baa99 Mon Sep 17 00:00:00 2001 From: markffan Date: Mon, 13 Jul 2026 11:02:18 +0800 Subject: [PATCH 1/2] Add PAGX animation playback with profiler frame histogram and render flush refactoring. --- viewer/assets/qml/Profiler.qml | 254 +++++++++---------- viewer/assets/translation/Chinese.ts | 16 +- viewer/src/profiling/PAGRunTimeDataModel.cpp | 25 +- viewer/src/rendering/pagx/PAGXRenderer.cpp | 176 +++++++++++-- viewer/src/rendering/pagx/PAGXRenderer.h | 32 ++- viewer/src/rendering/pagx/PAGXView.cpp | 3 +- viewer/src/rendering/pagx/PAGXViewModel.cpp | 124 ++++++--- viewer/src/rendering/pagx/PAGXViewModel.h | 68 ++++- 8 files changed, 485 insertions(+), 213 deletions(-) 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..6540acf949 100644 --- a/viewer/assets/translation/Chinese.ts +++ b/viewer/assets/translation/Chinese.ts @@ -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..9787971f73 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,28 @@ 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()) { + auto* anim = pagxDocument->animations[0]; + // 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; + } + 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..7f71ac7da2 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,49 @@ 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); + // Detect play-state transitions to reset the advance/seek trackers so a paused→resumed cycle + // does not count the pause time as animation delta, and the next paused frame always re-seeks. + bool playStateChanged = state.isPlaying != lastIsPlaying; + if (playStateChanged) { + lastIsPlaying = state.isPlaying; + 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.timeline != nullptr && state.timeline->duration() > 0) { + advancePlayback(state, playStateChanged); + } else if (state.timeline != nullptr && state.timeline->duration() > 0) { + seekPaused(state, playStateChanged); + } + + metrics.currentFrame = computeProfileFrame(state); + + if (state.contentWidth > 0 && state.contentHeight > 0) { + applyFitTransform(state, tgfxSurface->width(), tgfxSurface->height()); + } - auto imageStart = tgfx::Clock::Now(); + 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) { context->submit(std::move(recording)); } - metrics.imageDecodeTime = tgfx::Clock::Now() - imageStart; + 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 +120,111 @@ IContentRenderer::RenderMetrics PAGXRenderer::flush() { return metrics; } +void PAGXRenderer::advancePlayback(PAGXViewModel::RenderState& state, bool playStateChanged) { + // On a fresh play request, re-arm the timeline. PAGTimeline gates advance() on an internal + // playing flag that a non-looping animation clears once it reaches the end, so replaying after + // it finished requires rewinding to the start and calling play() again. + if (playStateChanged) { + // Seek to the requested progress so playback starts from the position the UI shows, + // regardless of where the timeline was left. A finished timeline is instead 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. state.progress is in [0, 1]. + auto durationUs = state.timeline->duration(); + int64_t targetUs = state.timeline->currentTime() >= durationUs + ? 0 + : static_cast(state.progress * static_cast(durationUs)); + state.timeline->setCurrentTime(targetUs); + state.timeline->play(); + } + auto now = tgfx::Clock::Now(); + if (lastAdvanceTimeUs < 0) { + lastAdvanceTimeUs = now; + } + int64_t deltaUs = now - lastAdvanceTimeUs; + lastAdvanceTimeUs = now; + if (deltaUs > 0) { + state.timeline->advanceAndApply(deltaUs); + state.scene->advanceAndApply(deltaUs); + } + // Report the current playback progress back to the main thread for UI updates. + auto durationUs = state.timeline->duration(); + auto currentUs = state.timeline->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 clears its playing flag once it reaches the end. Notify the main + // thread so it flips the UI back to the paused state and stops requesting further renders. + if (!state.timeline->isPlaying() && !playbackFinishNotified) { + playbackFinishNotified = true; + viewModel->notifyPlaybackFinished(state.generation); + } +} + +void PAGXRenderer::seekPaused(PAGXViewModel::RenderState& state, bool playStateChanged) { + auto durationUs = state.timeline->duration(); + if (playStateChanged) { + // Just paused: 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, rather than seeking back to the stale + // progress (which would visibly jump the frame backward). + auto currentUs = state.timeline->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.timeline->setCurrentTime(targetUs); + state.timeline->apply(); + } + } +} + +int64_t PAGXRenderer::computeProfileFrame(const PAGXViewModel::RenderState& state) const { + if (state.timeline == nullptr || state.timeline->duration() <= 0) { + return -1; + } + auto rate = state.timeline->frameRate(); + if (rate <= 0) { + return -1; + } + auto durationUs = state.timeline->duration(); + auto currentUs = state.timeline->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..484c079b1d 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,37 @@ 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. + // playStateChanged is true on the flush that first observes a paused→playing transition, which + // re-arms the timeline (seek to the shown progress or rewind a finished one, then play). + void advancePlayback(PAGXViewModel::RenderState& state, bool playStateChanged); + + // Handles the paused timeline: on the pause transition 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 playStateChanged); + + // 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; + // 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..379bd30dd6 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,47 @@ 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, defaultTimeline, pagxWidth, pagxHeight, isPlaying_.load(), + progress, playbackGeneration.load()}; } bool PAGXViewModel::hasContent() { std::lock_guard lock(renderMutex); - return displayList != nullptr; + return scene != nullptr; } void PAGXViewModel::setIsPlaying(bool isPlaying) { @@ -167,11 +200,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) { @@ -205,24 +242,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 +265,17 @@ bool PAGXViewModel::loadFile(const QString& filePath) { clearContent(); currentFilePath = strPath; pagxDocument = document; + scene = newScene; + defaultTimeline = scene->getDefaultTimeline(); 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 +323,39 @@ void PAGXViewModel::previousFrame() { void PAGXViewModel::clearContent() { pagxDocument = nullptr; - pagxRootLayer = nullptr; - displayList = nullptr; + scene = nullptr; + defaultTimeline = nullptr; +} + +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 (defaultTimeline != nullptr && defaultTimeline->duration() > 0) { + auto durationUs = defaultTimeline->duration(); + auto rate = defaultTimeline->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 +371,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; + defaultTimeline = scene->getDefaultTimeline(); 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..39527c8255 100644 --- a/viewer/src/rendering/pagx/PAGXViewModel.h +++ b/viewer/src/rendering/pagx/PAGXViewModel.h @@ -20,18 +20,18 @@ #include #include +#include "pagx/PAGScene.h" +#include "pagx/PAGTimeline.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,48 @@ 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 timeline; int contentWidth = 0; int contentHeight = 0; + bool isPlaying = false; + double progress = 0.0; + 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, + * getDefaultTimeline, 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); /** @@ -98,13 +126,28 @@ class PAGXViewModel : public ContentViewModel { void clearContent(); 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::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 defaultTimeline = nullptr; int pagxWidth = 0; int pagxHeight = 0; std::string currentFilePath = {}; @@ -112,9 +155,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 From f5497a784bba0105970f3e76c7176b1c24bf9f9f Mon Sep 17 00:00:00 2001 From: markffan Date: Tue, 21 Jul 2026 15:40:20 +0800 Subject: [PATCH 2/2] Fix playback correctness issues and extract animation resolution helper. --- viewer/assets/translation/Chinese.ts | 14 +-- viewer/src/profiling/PAGRunTimeDataModel.cpp | 36 ++++-- viewer/src/rendering/pagx/PAGXRenderer.cpp | 123 +++++++++++-------- viewer/src/rendering/pagx/PAGXRenderer.h | 17 ++- viewer/src/rendering/pagx/PAGXViewModel.cpp | 38 ++++-- viewer/src/rendering/pagx/PAGXViewModel.h | 13 +- 6 files changed, 153 insertions(+), 88 deletions(-) diff --git a/viewer/assets/translation/Chinese.ts b/viewer/assets/translation/Chinese.ts index 6540acf949..76bff4816b 100644 --- a/viewer/assets/translation/Chinese.ts +++ b/viewer/assets/translation/Chinese.ts @@ -449,12 +449,12 @@ 建议将视频序列帧数量控制在两个以内 - + Node Distribution 节点分布 - + nodes 个节点 @@ -602,7 +602,7 @@ pag::PAGXViewModel - + Failed to parse XML: invalid syntax or structure 解析 XML 失败:语法或结构无效 @@ -611,22 +611,22 @@ 从 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 9787971f73..94db0996ee 100644 --- a/viewer/src/profiling/PAGRunTimeDataModel.cpp +++ b/viewer/src/profiling/PAGRunTimeDataModel.cpp @@ -130,20 +130,30 @@ void PAGRunTimeDataModel::setPAGFile(std::shared_ptr pagFile) { void PAGRunTimeDataModel::setPAGXDocument(std::shared_ptr pagxDocument) { if (pagxDocument != nullptr && !pagxDocument->animations.empty()) { - auto* anim = pagxDocument->animations[0]; - // 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)); + // 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 { - totalFrame = 0; - } - if (totalFrame < 1) { + // 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; diff --git a/viewer/src/rendering/pagx/PAGXRenderer.cpp b/viewer/src/rendering/pagx/PAGXRenderer.cpp index 7f71ac7da2..8801831733 100644 --- a/viewer/src/rendering/pagx/PAGXRenderer.cpp +++ b/viewer/src/rendering/pagx/PAGXRenderer.cpp @@ -76,20 +76,24 @@ IContentRenderer::RenderMetrics PAGXRenderer::flush() { return metrics; } - // Detect play-state transitions to reset the advance/seek trackers so a paused→resumed cycle - // does not count the pause time as animation delta, and the next paused frame always re-seeks. + // 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; - if (playStateChanged) { + bool generationChanged = state.generation != lastGeneration; + bool reseat = playStateChanged || generationChanged; + if (reseat) { lastIsPlaying = state.isPlaying; + lastGeneration = state.generation; lastAdvanceTimeUs = -1; lastSeekTimeUs = -1; playbackFinishNotified = false; } - if (state.isPlaying && state.timeline != nullptr && state.timeline->duration() > 0) { - advancePlayback(state, playStateChanged); - } else if (state.timeline != nullptr && state.timeline->duration() > 0) { - seekPaused(state, playStateChanged); + 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); } metrics.currentFrame = computeProfileFrame(state); @@ -105,9 +109,11 @@ IContentRenderer::RenderMetrics PAGXRenderer::flush() { return metrics; } auto recording = pagx::Record(context, state.scene, pagSurface, true); - if (recording) { - context->submit(std::move(recording)); + 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. @@ -120,21 +126,25 @@ IContentRenderer::RenderMetrics PAGXRenderer::flush() { return metrics; } -void PAGXRenderer::advancePlayback(PAGXViewModel::RenderState& state, bool playStateChanged) { - // On a fresh play request, re-arm the timeline. PAGTimeline gates advance() on an internal - // playing flag that a non-looping animation clears once it reaches the end, so replaying after - // it finished requires rewinding to the start and calling play() again. - if (playStateChanged) { +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 timeline is instead 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. state.progress is in [0, 1]. - auto durationUs = state.timeline->duration(); - int64_t targetUs = state.timeline->currentTime() >= durationUs - ? 0 - : static_cast(state.progress * static_cast(durationUs)); - state.timeline->setCurrentTime(targetUs); - state.timeline->play(); + // 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) { @@ -143,12 +153,12 @@ void PAGXRenderer::advancePlayback(PAGXViewModel::RenderState& state, bool playS int64_t deltaUs = now - lastAdvanceTimeUs; lastAdvanceTimeUs = now; if (deltaUs > 0) { - state.timeline->advanceAndApply(deltaUs); + state.animation->advanceAndApply(deltaUs); state.scene->advanceAndApply(deltaUs); } // Report the current playback progress back to the main thread for UI updates. - auto durationUs = state.timeline->duration(); - auto currentUs = state.timeline->currentTime(); + 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; @@ -157,52 +167,65 @@ void PAGXRenderer::advancePlayback(PAGXViewModel::RenderState& state, bool playS renderProgress = 1.0; } viewModel->updateProgressFromRender(renderProgress, state.generation); - // A non-looping animation clears its playing flag once it reaches the end. Notify the main + // 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. - if (!state.timeline->isPlaying() && !playbackFinishNotified) { + // 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 playStateChanged) { - auto durationUs = state.timeline->duration(); - if (playStateChanged) { - // Just paused: 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, rather than seeking back to the stale - // progress (which would visibly jump the frame backward). - auto currentUs = state.timeline->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; +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); } - 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.timeline->setCurrentTime(targetUs); - state.timeline->apply(); + state.animation->setCurrentTime(targetUs); + state.animation->apply(); } } } int64_t PAGXRenderer::computeProfileFrame(const PAGXViewModel::RenderState& state) const { - if (state.timeline == nullptr || state.timeline->duration() <= 0) { + if (state.animation == nullptr || state.animation->duration() <= 0) { return -1; } - auto rate = state.timeline->frameRate(); + auto rate = state.animation->frameRate(); if (rate <= 0) { return -1; } - auto durationUs = state.timeline->duration(); - auto currentUs = state.timeline->currentTime(); + 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); diff --git a/viewer/src/rendering/pagx/PAGXRenderer.h b/viewer/src/rendering/pagx/PAGXRenderer.h index 484c079b1d..bb2da15f3c 100644 --- a/viewer/src/rendering/pagx/PAGXRenderer.h +++ b/viewer/src/rendering/pagx/PAGXRenderer.h @@ -40,13 +40,15 @@ class PAGXRenderer : public IContentRenderer { 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. - // playStateChanged is true on the flush that first observes a paused→playing transition, which - // re-arms the timeline (seek to the shown progress or rewind a finished one, then play). - void advancePlayback(PAGXViewModel::RenderState& state, bool playStateChanged); + // 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 the pause transition 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 playStateChanged); + // 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). @@ -66,6 +68,9 @@ class PAGXRenderer : public IContentRenderer { 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; diff --git a/viewer/src/rendering/pagx/PAGXViewModel.cpp b/viewer/src/rendering/pagx/PAGXViewModel.cpp index 379bd30dd6..7bd1414856 100644 --- a/viewer/src/rendering/pagx/PAGXViewModel.cpp +++ b/viewer/src/rendering/pagx/PAGXViewModel.cpp @@ -184,8 +184,15 @@ void PAGXViewModel::handlePlaybackFinished(quint64 generation) { PAGXViewModel::RenderState PAGXViewModel::getRenderState() { std::lock_guard lock(renderMutex); - return {scene, defaultTimeline, pagxWidth, pagxHeight, isPlaying_.load(), - progress, playbackGeneration.load()}; + return {scene, + defaultAnimation, + defaultLoopMode, + pagxWidth, + pagxHeight, + isPlaying_.load(), + progress, + pendingSeek.exchange(false), + playbackGeneration.load()}; } bool PAGXViewModel::hasContent() { @@ -216,6 +223,7 @@ void PAGXViewModel::setProgress(double newProgress) { return; } progress = newProgress; + pendingSeek = true; Q_EMIT progressChanged(progress); needsRender = true; Q_EMIT requestFlush(); @@ -266,7 +274,7 @@ bool PAGXViewModel::loadFile(const QString& filePath) { currentFilePath = strPath; pagxDocument = document; scene = newScene; - defaultTimeline = scene->getDefaultTimeline(); + resolveDefaultAnimation(document); pagxWidth = static_cast(document->width); pagxHeight = static_cast(document->height); updateAnimationState(); @@ -324,7 +332,21 @@ void PAGXViewModel::previousFrame() { void PAGXViewModel::clearContent() { pagxDocument = nullptr; scene = nullptr; - defaultTimeline = 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() { @@ -335,9 +357,9 @@ void PAGXViewModel::emitContentStateReset() { } void PAGXViewModel::updateAnimationState() { - if (defaultTimeline != nullptr && defaultTimeline->duration() > 0) { - auto durationUs = defaultTimeline->duration(); - auto rate = defaultTimeline->frameRate(); + 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) { @@ -382,7 +404,7 @@ QString PAGXViewModel::applyXmlChanges(const QString& newXml) { pagxWidth = static_cast(document->width); pagxHeight = static_cast(document->height); scene = newScene; - defaultTimeline = scene->getDefaultTimeline(); + resolveDefaultAnimation(document); updateAnimationState(); needsRender = true; } diff --git a/viewer/src/rendering/pagx/PAGXViewModel.h b/viewer/src/rendering/pagx/PAGXViewModel.h index 39527c8255..4083271f8f 100644 --- a/viewer/src/rendering/pagx/PAGXViewModel.h +++ b/viewer/src/rendering/pagx/PAGXViewModel.h @@ -20,8 +20,8 @@ #include #include +#include "pagx/PAGAnimation.h" #include "pagx/PAGScene.h" -#include "pagx/PAGTimeline.h" #include "pagx/PAGXDocument.h" #include "rendering/ContentViewModel.h" #include "rendering/pagx/XmlLinesModel.h" @@ -73,11 +73,13 @@ class PAGXViewModel : public ContentViewModel { struct RenderState { std::shared_ptr scene; - std::shared_ptr timeline; + 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; }; @@ -91,7 +93,7 @@ class PAGXViewModel : public ContentViewModel { * 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, - * getDefaultTimeline, updateAnimationState) inside that lock and never touches the object again + * 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 @@ -124,6 +126,7 @@ 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 @@ -144,10 +147,12 @@ class PAGXViewModel : public ContentViewModel { QQuickWindow* window = nullptr; std::atomic_bool needsRender = false; + std::atomic pendingSeek = false; std::mutex renderMutex = {}; std::shared_ptr pagxDocument = nullptr; std::shared_ptr scene = nullptr; - std::shared_ptr defaultTimeline = nullptr; + std::shared_ptr defaultAnimation = nullptr; + pagx::LoopMode defaultLoopMode = pagx::LoopMode::Once; int pagxWidth = 0; int pagxHeight = 0; std::string currentFilePath = {};