From e43171521d301bd856a3e054b93dbac7e3e5b323 Mon Sep 17 00:00:00 2001 From: DingVero Date: Thu, 14 May 2026 13:30:06 +0800 Subject: [PATCH] update --- CMakeLists.txt | 2 +- client/core/CMakeLists.txt | 59 + client/core/animation/AnimationEvaluator.cpp | 180 ++ client/core/animation/AnimationEvaluator.h | 31 + client/core/domain/Project.h | 172 +- client/core/eval/ProjectEvaluator.cpp | 234 +- client/core/eval/ProjectEvaluator.h | 5 +- client/core/image/ImageDecodeConfig.h | 60 + client/core/image/ImageFileLoader.cpp | 99 + client/core/image/ImageFileLoader.h | 29 + client/core/large_image/VipsBackend.cpp | 271 ++ client/core/large_image/VipsBackend.h | 39 + client/core/library/EntityJson.cpp | 20 +- client/core/net/ModelServerClient.cpp | 62 + client/core/net/ModelServerClient.h | 15 + client/core/persistence/AnimationBinary.cpp | 204 ++ client/core/persistence/AnimationBinary.h | 18 + .../core/persistence/AnimationBundleStore.cpp | 162 + .../core/persistence/AnimationBundleStore.h | 20 + .../core/persistence/AsyncProjectWriter.cpp | 139 + client/core/persistence/AsyncProjectWriter.h | 51 + client/core/persistence/EntityBundleStore.cpp | 362 +++ client/core/persistence/EntityBundleStore.h | 22 + .../core/persistence/EntityPayloadBinary.cpp | 215 +- client/core/persistence/EntityPayloadBinary.h | 4 +- client/core/persistence/JsonFileAtomic.cpp | 62 + client/core/persistence/JsonFileAtomic.h | 20 + .../persistence/PersistentBinaryObject.cpp | 4 +- .../core/persistence/PersistentBinaryObject.h | 6 + client/core/workspace/ProjectWorkspace.cpp | 2747 +++++++++++------ client/core/workspace/ProjectWorkspace.h | 80 +- client/gui/CMakeLists.txt | 11 + client/gui/app/AppStyle.cpp | 561 ++++ client/gui/app/AppStyle.h | 11 + client/gui/app/main.cpp | 16 +- .../dialogs/AnimationSchemeSettingsDialog.cpp | 90 + .../dialogs/AnimationSchemeSettingsDialog.h | 37 + client/gui/dialogs/BlackholeResolveDialog.cpp | 70 +- client/gui/dialogs/BlackholeResolveDialog.h | 1 - client/gui/dialogs/EntityFinalizeDialog.cpp | 5 +- client/gui/dialogs/EntityIntroPopup.cpp | 6 +- client/gui/dialogs/FrameAnimationDialog.cpp | 411 ++- client/gui/dialogs/FrameAnimationDialog.h | 5 + client/gui/dialogs/ImageCropDialog.cpp | 524 +++- client/gui/dialogs/ImageCropDialog.h | 3 + client/gui/dialogs/InpaintPreviewDialog.cpp | 4 +- client/gui/editor/EditorCanvas.cpp | 1480 ++++++--- client/gui/editor/EditorCanvas.h | 72 +- client/gui/editor/EntityCutoutUtils.cpp | 18 +- client/gui/library/ResourceLibraryDock.cpp | 2 +- client/gui/main_window/MainWindow.cpp | 2351 ++++++++++---- client/gui/main_window/MainWindow.h | 53 +- client/gui/params/ParamControls.cpp | 160 +- client/gui/params/ParamControls.h | 42 +- .../gui/props/BackgroundPropertySection.cpp | 18 +- client/gui/props/BlackholePropertySection.cpp | 12 +- client/gui/props/CameraPropertySection.cpp | 32 +- client/gui/props/CameraPropertySection.h | 2 +- client/gui/props/EntityPropertySection.cpp | 201 +- client/gui/props/EntityPropertySection.h | 36 +- client/gui/props/HotspotPropertySection.cpp | 151 + client/gui/props/HotspotPropertySection.h | 36 + client/gui/props/ToolPropertySection.cpp | 57 +- client/gui/props/ToolPropertySection.h | 5 +- client/gui/timeline/TimelineEditorModel.h | 62 + client/gui/timeline/TimelineWidget.cpp | 76 +- client/gui/timeline/TimelineWidget.h | 13 +- client/gui/widgets/CompactNumericSpinBox.cpp | 34 + client/gui/widgets/CompactNumericSpinBox.h | 19 + client/gui/widgets/PropertyPanelControls.cpp | 82 + client/gui/widgets/PropertyPanelControls.h | 46 + python_server/app/__init__.py | 1 + python_server/app/deps.py | 8 + python_server/app/main.py | 13 + python_server/app/routes/__init__.py | 1 + python_server/app/routes/animate.py | 135 + python_server/app/routes/depth.py | 32 + python_server/app/routes/inpaint.py | 47 + python_server/app/routes/meta.py | 93 + python_server/app/routes/segment.py | 84 + python_server/app/schemas.py | 93 + python_server/app/services/__init__.py | 1 + .../app/services/animation_frames.py | 115 + python_server/app/services/depth_io.py | 22 + python_server/app/services/image_io.py | 25 + python_server/app/services/predictors.py | 108 + python_server/model/Inpaint/inpaint_loader.py | 44 +- .../model/Inpaint/lama_torchscript.py | 105 + python_server/model/Seg/seg_loader.py | 37 + python_server/requirements.txt | 82 + python_server/server.py | 484 +-- 91 files changed, 10485 insertions(+), 3254 deletions(-) create mode 100644 client/core/animation/AnimationEvaluator.cpp create mode 100644 client/core/animation/AnimationEvaluator.h create mode 100644 client/core/image/ImageDecodeConfig.h create mode 100644 client/core/image/ImageFileLoader.cpp create mode 100644 client/core/image/ImageFileLoader.h create mode 100644 client/core/large_image/VipsBackend.cpp create mode 100644 client/core/large_image/VipsBackend.h create mode 100644 client/core/persistence/AnimationBinary.cpp create mode 100644 client/core/persistence/AnimationBinary.h create mode 100644 client/core/persistence/AnimationBundleStore.cpp create mode 100644 client/core/persistence/AnimationBundleStore.h create mode 100644 client/core/persistence/AsyncProjectWriter.cpp create mode 100644 client/core/persistence/AsyncProjectWriter.h create mode 100644 client/core/persistence/EntityBundleStore.cpp create mode 100644 client/core/persistence/EntityBundleStore.h create mode 100644 client/core/persistence/JsonFileAtomic.cpp create mode 100644 client/core/persistence/JsonFileAtomic.h create mode 100644 client/gui/app/AppStyle.cpp create mode 100644 client/gui/app/AppStyle.h create mode 100644 client/gui/dialogs/AnimationSchemeSettingsDialog.cpp create mode 100644 client/gui/dialogs/AnimationSchemeSettingsDialog.h create mode 100644 client/gui/props/HotspotPropertySection.cpp create mode 100644 client/gui/props/HotspotPropertySection.h create mode 100644 client/gui/timeline/TimelineEditorModel.h create mode 100644 client/gui/widgets/CompactNumericSpinBox.cpp create mode 100644 client/gui/widgets/CompactNumericSpinBox.h create mode 100644 client/gui/widgets/PropertyPanelControls.cpp create mode 100644 client/gui/widgets/PropertyPanelControls.h create mode 100644 python_server/app/__init__.py create mode 100644 python_server/app/deps.py create mode 100644 python_server/app/main.py create mode 100644 python_server/app/routes/__init__.py create mode 100644 python_server/app/routes/animate.py create mode 100644 python_server/app/routes/depth.py create mode 100644 python_server/app/routes/inpaint.py create mode 100644 python_server/app/routes/meta.py create mode 100644 python_server/app/routes/segment.py create mode 100644 python_server/app/schemas.py create mode 100644 python_server/app/services/__init__.py create mode 100644 python_server/app/services/animation_frames.py create mode 100644 python_server/app/services/depth_io.py create mode 100644 python_server/app/services/image_io.py create mode 100644 python_server/app/services/predictors.py create mode 100644 python_server/model/Inpaint/lama_torchscript.py create mode 100644 python_server/requirements.txt diff --git a/CMakeLists.txt b/CMakeLists.txt index 0db2cb9..3b43d40 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,7 +26,7 @@ else() endif() # 尝试优先使用 Qt6,其次 Qt5 -set(QT_REQUIRED_COMPONENTS Widgets Gui Core Network) +set(QT_REQUIRED_COMPONENTS Widgets Gui Core Network Concurrent) find_package(Qt6 COMPONENTS ${QT_REQUIRED_COMPONENTS} QUIET) if(Qt6_FOUND) diff --git a/client/core/CMakeLists.txt b/client/core/CMakeLists.txt index ed80190..aad9a7e 100644 --- a/client/core/CMakeLists.txt +++ b/client/core/CMakeLists.txt @@ -1,11 +1,21 @@ # 模块:domain、persistence、workspace、depth、animation(时间采样) set(CORE_ROOT ${CMAKE_CURRENT_SOURCE_DIR}) +option(LT_WITH_VIPS "Use libvips for GB-scale PNG/JPEG (libvips-dev / pkg-config vips)" ON) + set(CORE_SOURCES ${CORE_ROOT}/domain/Project.cpp + ${CORE_ROOT}/image/ImageFileLoader.cpp + ${CORE_ROOT}/large_image/VipsBackend.cpp ${CORE_ROOT}/workspace/ProjectWorkspace.cpp ${CORE_ROOT}/persistence/PersistentBinaryObject.cpp + ${CORE_ROOT}/persistence/AnimationBinary.cpp ${CORE_ROOT}/persistence/EntityPayloadBinary.cpp + ${CORE_ROOT}/persistence/JsonFileAtomic.cpp + ${CORE_ROOT}/persistence/EntityBundleStore.cpp + ${CORE_ROOT}/persistence/AnimationBundleStore.cpp + ${CORE_ROOT}/persistence/AsyncProjectWriter.cpp + ${CORE_ROOT}/animation/AnimationEvaluator.cpp ${CORE_ROOT}/animation/AnimationSampling.cpp ${CORE_ROOT}/depth/DepthService.cpp ${CORE_ROOT}/net/ModelServerClient.cpp @@ -17,11 +27,20 @@ set(CORE_SOURCES ) set(CORE_HEADERS + ${CORE_ROOT}/image/ImageDecodeConfig.h + ${CORE_ROOT}/image/ImageFileLoader.h + ${CORE_ROOT}/large_image/VipsBackend.h ${CORE_ROOT}/domain/EntityIntro.h ${CORE_ROOT}/domain/Project.h ${CORE_ROOT}/workspace/ProjectWorkspace.h ${CORE_ROOT}/persistence/PersistentBinaryObject.h + ${CORE_ROOT}/persistence/AnimationBinary.h ${CORE_ROOT}/persistence/EntityPayloadBinary.h + ${CORE_ROOT}/persistence/JsonFileAtomic.h + ${CORE_ROOT}/persistence/EntityBundleStore.h + ${CORE_ROOT}/persistence/AnimationBundleStore.h + ${CORE_ROOT}/persistence/AsyncProjectWriter.h + ${CORE_ROOT}/animation/AnimationEvaluator.h ${CORE_ROOT}/animation/AnimationSampling.h ${CORE_ROOT}/depth/DepthService.h ${CORE_ROOT}/net/ModelServerClient.h @@ -47,4 +66,44 @@ target_link_libraries(core ${QT_PACKAGE}::Core ${QT_PACKAGE}::Gui ${QT_PACKAGE}::Network + ${QT_PACKAGE}::Concurrent ) + +# libvips:优先 IMPORTED_TARGET;失败则用 pkg-config 变量(部分环境下更稳) +set(LT_VIPS_ENABLED OFF) +if(LT_WITH_VIPS) + find_package(PkgConfig QUIET) + if(PKG_CONFIG_FOUND) + pkg_check_modules(VIPS_PC IMPORTED_TARGET vips) + if(TARGET PkgConfig::VIPS_PC) + target_compile_definitions(core PRIVATE LT_HAVE_VIPS=1) + target_link_libraries(core PUBLIC PkgConfig::VIPS_PC) + set(LT_VIPS_ENABLED ON) + message(STATUS "libvips: enabled via IMPORTED_TARGET (LT_HAVE_VIPS)") + else() + pkg_check_modules(VIPS_LEGACY QUIET vips) + if(VIPS_LEGACY_FOUND) + target_compile_definitions(core PRIVATE LT_HAVE_VIPS=1) + target_include_directories(core PRIVATE ${VIPS_LEGACY_INCLUDE_DIRS}) + target_link_directories(core PUBLIC ${VIPS_LEGACY_LIBRARY_DIRS}) + target_link_libraries(core PUBLIC ${VIPS_LEGACY_LIBRARIES}) + if(VIPS_LEGACY_CFLAGS_OTHER) + separate_arguments(_vips_cflags UNIX_COMMAND "${VIPS_LEGACY_CFLAGS_OTHER}") + target_compile_options(core PRIVATE ${_vips_cflags}) + endif() + set(LT_VIPS_ENABLED ON) + message(STATUS "libvips: enabled via pkg-config variables (LT_HAVE_VIPS)") + endif() + endif() + endif() +endif() +if(LT_WITH_VIPS AND NOT LT_VIPS_ENABLED) + message( + WARNING + "未找到 libvips(无 pkg-config 模块 vips),已关闭 LT_HAVE_VIPS,大图仍走 Qt 解码。\n" + " Debian/Ubuntu: sudo apt install pkg-config libvips-dev\n" + " Fedora: sudo dnf install pkgconf-pkg-config vips-devel\n" + " 安装后终端执行: pkg-config --modversion vips (应打印版本号)\n" + " 若已安装仍失败: export PKG_CONFIG_PATH=\"/usr/lib/x86_64-linux-gnu/pkgconfig:\$PKG_CONFIG_PATH\"" + ) +endif() diff --git a/client/core/animation/AnimationEvaluator.cpp b/client/core/animation/AnimationEvaluator.cpp new file mode 100644 index 0000000..b555cef --- /dev/null +++ b/client/core/animation/AnimationEvaluator.cpp @@ -0,0 +1,180 @@ +#include "animation/AnimationEvaluator.h" + +#include + +namespace core { +namespace { + +QVector sortedKeys(const QVector& keys) { + QVector out = keys; + std::sort(out.begin(), out.end(), [](const auto& a, const auto& b) { return a.frame < b.frame; }); + return out; +} + +bool sampleBool(const QVector& keys, int frame, bool fallback) { + auto sorted = sortedKeys(keys); + bool out = fallback; + for (const auto& k : sorted) { + if (k.frame <= frame) out = k.boolValue; + else break; + } + return out; +} + +QString sampleString(const QVector& keys, int frame, const QString& fallback = {}) { + auto sorted = sortedKeys(keys); + QString out = fallback; + for (const auto& k : sorted) { + if (k.frame <= frame && !k.stringValue.isEmpty()) out = k.stringValue; + else if (k.frame > frame) break; + } + return out; +} + +QByteArray sampleBytes(const QVector& keys, int frame, const QByteArray& fallback = {}) { + auto sorted = sortedKeys(keys); + QByteArray out = fallback; + for (const auto& k : sorted) { + if (k.frame <= frame && !k.bytesValue.isEmpty()) out = k.bytesValue; + else if (k.frame > frame) break; + } + return out; +} + +double sampleDouble(const QVector& keys, + int frame, + double fallback, + Project::AnimationInterpolation interpolation) { + auto sorted = sortedKeys(keys); + if (sorted.isEmpty()) return fallback; + if (interpolation == Project::AnimationInterpolation::Hold) { + double out = fallback; + for (const auto& k : sorted) { + if (k.frame <= frame) out = k.numberValue; + else break; + } + return out; + } + if (frame <= sorted.front().frame) return sorted.front().numberValue; + if (frame >= sorted.back().frame) return sorted.back().numberValue; + for (int i = 0; i + 1 < sorted.size(); ++i) { + const auto& a = sorted[i]; + const auto& b = sorted[i + 1]; + if (frame >= a.frame && frame <= b.frame) { + if (a.frame == b.frame) return a.numberValue; + const double t = double(frame - a.frame) / double(b.frame - a.frame); + return a.numberValue + (b.numberValue - a.numberValue) * t; + } + } + return sorted.back().numberValue; +} + +QPointF sampleVec2(const QVector& keys, + int frame, + const QPointF& fallback, + Project::AnimationInterpolation interpolation) { + auto sorted = sortedKeys(keys); + if (sorted.isEmpty()) return fallback; + if (interpolation == Project::AnimationInterpolation::Hold) { + QPointF out = fallback; + for (const auto& k : sorted) { + if (k.frame <= frame) out = k.vec2Value; + else break; + } + return out; + } + if (frame <= sorted.front().frame) return sorted.front().vec2Value; + if (frame >= sorted.back().frame) return sorted.back().vec2Value; + for (int i = 0; i + 1 < sorted.size(); ++i) { + const auto& a = sorted[i]; + const auto& b = sorted[i + 1]; + if (frame >= a.frame && frame <= b.frame) { + if (a.frame == b.frame) return a.vec2Value; + const double t = double(frame - a.frame) / double(b.frame - a.frame); + return QPointF(a.vec2Value.x() + (b.vec2Value.x() - a.vec2Value.x()) * t, + a.vec2Value.y() + (b.vec2Value.y() - a.vec2Value.y()) * t); + } + } + return sorted.back().vec2Value; +} + +} // namespace + +AnimationSample evaluateAnimation(const Project::Animation& animation, int frame) { + AnimationSample out; + const int f = animation.loop && animation.lengthFrames > 0 + ? (std::max(0, frame) % animation.lengthFrames) + : std::clamp(frame, 0, std::max(0, animation.lengthFrames - 1)); + + for (const auto& t : animation.tracks) { + if (t.targetId.isEmpty() || t.keys.isEmpty()) continue; + if (t.targetKind == Project::AnimationTargetKind::Entity) { + switch (t.property) { + case Project::AnimationProperty::Position: + out.entityPosition.insert(t.targetId, sampleVec2(t.keys, f, QPointF(), t.interpolation)); + break; + case Project::AnimationProperty::UserScale: + out.entityUserScale.insert(t.targetId, sampleDouble(t.keys, f, 1.0, t.interpolation)); + break; + case Project::AnimationProperty::DepthScale: + out.entityDepthScale01.insert(t.targetId, sampleDouble(t.keys, f, 0.5, t.interpolation)); + break; + case Project::AnimationProperty::Sprite: + // 贴图仅支持二进制 PNG bytes(不兼容路径关键帧)。 + if (t.valueType == Project::AnimationValueType::PngBytes) { + out.entitySpritePng.insert(t.targetId, sampleBytes(t.keys, f)); + } + break; + case Project::AnimationProperty::Visibility: + out.entityVisibility.insert(t.targetId, sampleBool(t.keys, f, true)); + break; + case Project::AnimationProperty::Priority: + out.entityPriority.insert(t.targetId, sampleDouble(t.keys, f, 1.0, t.interpolation)); + break; + default: + break; + } + } else if (t.targetKind == Project::AnimationTargetKind::Tool) { + if (t.property == Project::AnimationProperty::Position) { + out.toolPosition.insert(t.targetId, sampleVec2(t.keys, f, QPointF(), t.interpolation)); + } else if (t.property == Project::AnimationProperty::Visibility) { + out.toolVisibility.insert(t.targetId, sampleBool(t.keys, f, true)); + } else if (t.property == Project::AnimationProperty::Priority) { + out.toolPriority.insert(t.targetId, sampleDouble(t.keys, f, 10.0, t.interpolation)); + } + } else if (t.targetKind == Project::AnimationTargetKind::Camera) { + if (t.property == Project::AnimationProperty::Position) { + out.cameraPosition.insert(t.targetId, sampleVec2(t.keys, f, QPointF(), t.interpolation)); + } else if (t.property == Project::AnimationProperty::CameraScale) { + out.cameraScale.insert(t.targetId, sampleDouble(t.keys, f, 1.0, t.interpolation)); + } + } + } + return out; +} + +QVector spriteFramesForEntity(const Project::Animation& animation, + const QString& entityId) { + QVector out; + for (const auto& t : animation.tracks) { + if (t.targetKind != Project::AnimationTargetKind::Entity || + t.targetId != entityId || + t.property != Project::AnimationProperty::Sprite) { + continue; + } + out.reserve(out.size() + t.keys.size()); + for (const auto& k : t.keys) { + if (t.valueType == Project::AnimationValueType::PngBytes) { + if (!k.bytesValue.isEmpty()) { + out.push_back(Project::Entity::ImageFrame{k.frame, QString()}); + } + } else if (t.valueType == Project::AnimationValueType::ImagePath) { + // 旧格式:不再支持路径型 sprite 关键帧(按“无/回退默认贴图”处理) + } + } + } + std::sort(out.begin(), out.end(), [](const auto& a, const auto& b) { return a.frame < b.frame; }); + return out; +} + +} // namespace core diff --git a/client/core/animation/AnimationEvaluator.h b/client/core/animation/AnimationEvaluator.h new file mode 100644 index 0000000..9e382d4 --- /dev/null +++ b/client/core/animation/AnimationEvaluator.h @@ -0,0 +1,31 @@ +#pragma once + +#include "domain/Project.h" + +#include +#include +#include + +namespace core { + +struct AnimationSample { + QHash entityPosition; + QHash entityUserScale; + QHash entityDepthScale01; + QHash entitySpritePng; + QHash entityVisibility; + QHash entityPriority; + + QHash toolPosition; + QHash toolVisibility; + QHash toolPriority; + + QHash cameraPosition; + QHash cameraScale; +}; + +[[nodiscard]] AnimationSample evaluateAnimation(const Project::Animation& animation, int frame); +[[nodiscard]] QVector spriteFramesForEntity(const Project::Animation& animation, + const QString& entityId); + +} // namespace core diff --git a/client/core/domain/Project.h b/client/core/domain/Project.h index c033777..5b92ebb 100644 --- a/client/core/domain/Project.h +++ b/client/core/domain/Project.h @@ -2,9 +2,11 @@ #include "domain/EntityIntro.h" +#include #include #include #include +#include #include #include @@ -59,9 +61,16 @@ public: bool blackholeVisible = true; // 背景空缺修复方案:copy_background / use_original_background / model_inpaint(预留) QString blackholeResolvedBy; + /// 修复结果相对项目根路径;与 blackholeOverlayRect 一起用于画布叠加,不修改 background 原图 + QString blackholeOverlayPath; + /// 覆盖层在背景图坐标系中的像素矩形;宽或高 <=0 表示无 + QRect blackholeOverlayRect; QPointF originWorld; int depth = 0; // 0..255 + int priority = 1; // 0=背景之上最底层;越大越靠上(同优先级再按距离缩放/深度) QString imagePath; // 相对路径,例如 "assets/entities/entity-1.png" + QByteArray defaultImagePng; // 默认贴图 PNG bytes(不兼容旧路径工程时此为唯一来源) + QByteArray runtimeImagePng; // 运行时求值后的 PNG bytes(不序列化) QPointF imageTopLeftWorld; // 贴图左上角 world 坐标 // 人为整体缩放,与深度驱动的距离缩放相乘(画布中 visualScale = distanceScale * userScale; // distanceScale 在有 distanceScaleCalibMult 时为 (0.5+depth01)/calib,使抠图处为 1.0) @@ -73,8 +82,7 @@ public: // 约定:对话气泡等 UI 元素默认打开。 bool ignoreDistanceScale = false; - // 父子关系:当 parentId 非空时,实体会保持相对父实体的偏移(world 坐标)。 - // parentOffsetWorld 表示「childOrigin - parentOrigin」在 world 中的偏移。 + // 父子关系:在「原点已与形心对齐」时,parentOffsetWorld 表示子形心相对父形心的世界偏移;否则为子原点相对父原点的偏移(与求值器一致)。 QString parentId; QPointF parentOffsetWorld; @@ -97,9 +105,6 @@ public: // v2:project.json 仅存 id + payload,几何与动画在 entityPayloadPath(.hfe)中。 QString entityPayloadPath; // 例如 "assets/entities/entity-1.hfe" - // 仅打开 v1 项目时由 JSON 的 animationBundle 填入,用于合并旧 .anim;保存 v2 前应为空。 - QString legacyAnimSidecarPath; - QVector locationKeys; QVector depthScaleKeys; QVector userScaleKeys; @@ -128,6 +133,7 @@ public: // 基准位置(无关键帧时使用) QPointF originWorld; + int priority = 10; // 默认高于实体(实体默认 1);越大越靠上 QVector locationKeys; // 可见性轨道:布尔关键帧(显示/隐藏);渲染时会被解释为“10 帧淡入淡出”。 @@ -166,96 +172,79 @@ public: void setActiveCameraId(const QString& id) { m_activeCameraId = id; } const QString& activeCameraId() const { return m_activeCameraId; } - // —— 动画系统(Blender/NLA 风格简化版,工程级)—— - struct AnimationClip { + // —— 动画系统(Godot AnimationPlayer 风格简化版)—— + enum class AnimationTargetKind { Entity, Tool, Camera }; + enum class AnimationProperty { Position, UserScale, Sprite, Visibility, DepthScale, CameraScale, Priority }; + enum class AnimationValueType { Vec2, Double, Bool, ImagePath, PngBytes }; + enum class AnimationInterpolation { Hold, Linear }; + + struct AnimationKey { + int frame = 0; + QPointF vec2Value; + double numberValue = 0.0; + bool boolValue = true; + QString stringValue; + QByteArray bytesValue; + }; + + struct AnimationTrack { + QString id; + AnimationTargetKind targetKind = AnimationTargetKind::Entity; + QString targetId; + AnimationProperty property = AnimationProperty::Position; + AnimationValueType valueType = AnimationValueType::Vec2; + AnimationInterpolation interpolation = AnimationInterpolation::Linear; + QVector keys; + }; + + struct Animation { QString id; QString name; - - // Entity channels (keyed by entity id) - QHash> entityLocationKeys; - QHash> entityUserScaleKeys; - QHash> entityImageFrames; - QHash> entityVisibilityKeys; - - // Tool channels (keyed by tool id) - QHash> toolLocationKeys; - QHash> toolVisibilityKeys; - - QHash> cameraLocationKeys; - QHash> cameraScaleKeys; + QString payloadPath; // 例如 "assets/animations/animation-1.hfa" + int fps = 30; + int lengthFrames = kClipFixedFrames; + bool loop = true; + QVector tracks; }; - struct NlaStrip { + void setAnimations(const QVector& animations) { m_animations = animations; } + const QVector& animations() const { return m_animations; } + + void setActiveAnimationId(const QString& id) { m_activeAnimationId = id; } + const QString& activeAnimationId() const { return m_activeAnimationId; } + + const Animation* findAnimationById(const QString& id) const { + for (const auto& a : m_animations) { + if (a.id == id) return &a; + } + return nullptr; + } + Animation* findAnimationById(const QString& id) { + for (auto& a : m_animations) { + if (a.id == id) return &a; + } + return nullptr; + } + const Animation* activeAnimationOrNull() const { + const Animation* a = findAnimationById(m_activeAnimationId); + if (a) return a; + return m_animations.isEmpty() ? nullptr : &m_animations.front(); + } + Animation* activeAnimationOrNull() { + Animation* a = findAnimationById(m_activeAnimationId); + if (a) return a; + return m_animations.isEmpty() ? nullptr : &m_animations.front(); + } + + struct PresentationHotspot { QString id; - QString clipId; - int startSlot = 0; // slot index; 1 slot = kClipFixedFrames frames - int slotLen = 1; // currently fixed to 1; reserved for future - bool enabled = true; - bool muted = false; + QPointF centerWorld; + QString targetAnimationId; }; - struct NlaTrack { - QString id; - QString name; - bool muted = false; - bool solo = false; - QVector strips; - }; - - struct AnimationScheme { - QString id; - QString name; - QVector tracks; - }; - - void setAnimationClips(const QVector& clips) { m_clips = clips; } - const QVector& animationClips() const { return m_clips; } - - void setAnimationSchemes(const QVector& schemes) { m_schemes = schemes; } - const QVector& animationSchemes() const { return m_schemes; } - - void setActiveSchemeId(const QString& id) { m_activeSchemeId = id; } - const QString& activeSchemeId() const { return m_activeSchemeId; } - - void setSelectedStripId(const QString& id) { m_selectedStripId = id; } - const QString& selectedStripId() const { return m_selectedStripId; } - - const AnimationScheme* findSchemeById(const QString& id) const { - for (const auto& s : m_schemes) { - if (s.id == id) return &s; - } - return nullptr; - } - AnimationScheme* findSchemeById(const QString& id) { - for (auto& s : m_schemes) { - if (s.id == id) return &s; - } - return nullptr; - } - - const AnimationClip* findClipById(const QString& id) const { - for (const auto& c : m_clips) { - if (c.id == id) return &c; - } - return nullptr; - } - AnimationClip* findClipById(const QString& id) { - for (auto& c : m_clips) { - if (c.id == id) return &c; - } - return nullptr; - } - - const AnimationScheme* activeSchemeOrNull() const { - const AnimationScheme* s = findSchemeById(m_activeSchemeId); - if (s) return s; - return m_schemes.isEmpty() ? nullptr : &m_schemes.front(); - } - AnimationScheme* activeSchemeOrNull() { - AnimationScheme* s = findSchemeById(m_activeSchemeId); - if (s) return s; - return m_schemes.isEmpty() ? nullptr : &m_schemes.front(); - } + void setPresentationHotspots(const QVector& v) { m_presentationHotspots = v; } + const QVector& presentationHotspots() const { return m_presentationHotspots; } + QVector& presentationHotspotsRef() { return m_presentationHotspots; } private: QString m_name; @@ -271,10 +260,9 @@ private: QVector m_cameras; QString m_activeCameraId; - QVector m_clips; - QVector m_schemes; - QString m_activeSchemeId; - QString m_selectedStripId; + QVector m_animations; + QString m_activeAnimationId; + QVector m_presentationHotspots; }; } // namespace core diff --git a/client/core/eval/ProjectEvaluator.cpp b/client/core/eval/ProjectEvaluator.cpp index 9ffb186..1f50327 100644 --- a/client/core/eval/ProjectEvaluator.cpp +++ b/client/core/eval/ProjectEvaluator.cpp @@ -1,8 +1,10 @@ #include "eval/ProjectEvaluator.h" +#include "animation/AnimationEvaluator.h" #include "animation/AnimationSampling.h" #include +#include #include namespace core::eval { @@ -15,68 +17,6 @@ struct NodeRef { int index = -1; }; -QPointF sampledOriginForEntity(const core::Project::Entity& e, - const core::Project::AnimationClip* clipOrNull, - int localFrame) { - if (clipOrNull && clipOrNull->entityLocationKeys.contains(e.id)) { - const auto& keys = clipOrNull->entityLocationKeys.value(e.id); - return core::sampleLocation(keys, localFrame, e.originWorld, core::KeyInterpolation::Linear); - } - return core::sampleLocation(e.locationKeys, localFrame, e.originWorld, core::KeyInterpolation::Linear); -} - -QPointF sampledRelativeForEntity(const core::Project::Entity& e, - const core::Project::AnimationClip* clipOrNull, - int localFrame) { - if (clipOrNull && clipOrNull->entityLocationKeys.contains(e.id)) { - const auto& keys = clipOrNull->entityLocationKeys.value(e.id); - return core::sampleLocation(keys, localFrame, e.parentOffsetWorld, core::KeyInterpolation::Linear); - } - return core::sampleLocation(e.locationKeys, localFrame, e.parentOffsetWorld, core::KeyInterpolation::Linear); -} - -QPointF sampledOriginForTool(const core::Project::Tool& t, - const core::Project::AnimationClip* clipOrNull, - int localFrame) { - if (clipOrNull && clipOrNull->toolLocationKeys.contains(t.id)) { - const auto& keys = clipOrNull->toolLocationKeys.value(t.id); - return core::sampleLocation(keys, localFrame, t.originWorld, core::KeyInterpolation::Linear); - } - return core::sampleLocation(t.locationKeys, localFrame, t.originWorld, core::KeyInterpolation::Linear); -} - -QPointF sampledCenterForCamera(const core::Project::Camera& c, - const core::Project::AnimationClip* clipOrNull, - int localFrame) { - if (clipOrNull && clipOrNull->cameraLocationKeys.contains(c.id)) { - const auto& keys = clipOrNull->cameraLocationKeys.value(c.id); - return core::sampleLocation(keys, localFrame, c.centerWorld, core::KeyInterpolation::Linear); - } - return core::sampleLocation(c.locationKeys, localFrame, c.centerWorld, core::KeyInterpolation::Linear); -} - -double sampledViewScaleForCamera(const core::Project::Camera& c, - const core::Project::AnimationClip* clipOrNull, - int localFrame) { - if (clipOrNull && clipOrNull->cameraScaleKeys.contains(c.id)) { - const auto& keys = clipOrNull->cameraScaleKeys.value(c.id); - if (!keys.isEmpty()) { - return core::sampleUserScale(keys, localFrame, c.viewScale, core::KeyInterpolation::Linear); - } - } - return core::sampleUserScale(c.scaleKeys, localFrame, c.viewScale, core::KeyInterpolation::Linear); -} - -QPointF sampledRelativeForTool(const core::Project::Tool& t, - const core::Project::AnimationClip* clipOrNull, - int localFrame) { - if (clipOrNull && clipOrNull->toolLocationKeys.contains(t.id)) { - const auto& keys = clipOrNull->toolLocationKeys.value(t.id); - return core::sampleLocation(keys, localFrame, t.parentOffsetWorld, core::KeyInterpolation::Linear); - } - return core::sampleLocation(t.locationKeys, localFrame, t.parentOffsetWorld, core::KeyInterpolation::Linear); -} - struct VisKey { int frame = 0; bool value = true; @@ -144,82 +84,10 @@ double opacityFromBoolKeys(const QVector& keysR return state ? 1.0 : 0.0; } -struct StripEvalCtx { - const core::Project::AnimationScheme* scheme = nullptr; - const core::Project::NlaStrip* strip = nullptr; - const core::Project::AnimationClip* clip = nullptr; - int slot = 0; - int localFrame = 0; // 0..kClipFixedFrames-1 -}; - -static const core::Project::NlaStrip* findStripById(const core::Project::AnimationScheme& scheme, const QString& id) { - if (id.isEmpty()) return nullptr; - for (const auto& tr : scheme.tracks) { - for (const auto& st : tr.strips) { - if (st.id == id) return &st; - } - } - return nullptr; -} - -static bool trackIsEffectivelyMuted(const core::Project::AnimationScheme& scheme, const core::Project::NlaTrack& t) { - // 若有任意 solo=true,则只有 solo 的 track 生效(且仍受自身 muted 控制) - bool anySolo = false; - for (const auto& tr : scheme.tracks) { - if (tr.solo) { - anySolo = true; - break; - } - } - if (anySolo && !t.solo) { - return true; - } - return t.muted; -} - -static const core::Project::NlaStrip* pickStripAtSlot(const core::Project::AnimationScheme& scheme, int slot) { - const core::Project::NlaStrip* chosen = nullptr; - for (const auto& tr : scheme.tracks) { - if (trackIsEffectivelyMuted(scheme, tr)) continue; - for (const auto& st : tr.strips) { - if (!st.enabled || st.muted) continue; - const int a = st.startSlot; - const int b = st.startSlot + std::max(1, st.slotLen); - if (slot >= a && slot < b) { - chosen = &st; // 轨道顺序靠后的覆盖靠前的(更接近“上层”) - } - } - } - return chosen; -} - -static StripEvalCtx resolveStripCtx(const core::Project& project, int globalFrame) { - StripEvalCtx ctx; - const auto* scheme = project.activeSchemeOrNull(); - if (!scheme) { - ctx.localFrame = std::max(0, globalFrame); - return ctx; - } - ctx.scheme = scheme; - const int g = std::max(0, globalFrame); - ctx.slot = g / core::Project::kClipFixedFrames; - ctx.localFrame = g % core::Project::kClipFixedFrames; - - const core::Project::NlaStrip* st = findStripById(*scheme, project.selectedStripId()); - // 若选中条带不覆盖当前 slot,则退回自动挑选 - if (!st || ctx.slot < st->startSlot || ctx.slot >= (st->startSlot + std::max(1, st->slotLen)) || !st->enabled || st->muted) { - st = pickStripAtSlot(*scheme, ctx.slot); - } - ctx.strip = st; - if (st) { - ctx.clip = project.findClipById(st->clipId); - } - return ctx; -} - } // namespace -ResolvedProjectFrame evaluateAtFrame(const core::Project& project, int frame, int fadeFrames) { +ResolvedProjectFrame evaluateAtFrame(const core::Project& project, int frame, int fadeFrames, + const QString& animationId) { ResolvedProjectFrame out; const auto& ents = project.entities(); const auto& tools = project.tools(); @@ -228,9 +96,15 @@ ResolvedProjectFrame evaluateAtFrame(const core::Project& project, int frame, in out.tools.reserve(tools.size()); out.cameras.reserve(cams.size()); - const StripEvalCtx ctx = resolveStripCtx(project, frame); - const int localFrame = ctx.localFrame; - const core::Project::AnimationClip* clip = ctx.clip; + const int localFrame = std::max(0, frame); + const core::Project::Animation* activeAnimation = nullptr; + if (animationId.isEmpty()) { + activeAnimation = project.activeAnimationOrNull(); + } else { + activeAnimation = project.findAnimationById(animationId); + } + const core::AnimationSample animSample = + activeAnimation ? core::evaluateAnimation(*activeAnimation, frame) : core::AnimationSample{}; QHash index; index.reserve(ents.size() + tools.size()); @@ -262,8 +136,8 @@ ResolvedProjectFrame evaluateAtFrame(const core::Project& project, int frame, in // cycle:降级为自身采样 origin const NodeRef r = index.value(id); QPointF o; - if (r.kind == NodeRef::Kind::Entity) o = sampledOriginForEntity(ents[r.index], clip, localFrame); - else o = sampledOriginForTool(tools[r.index], clip, localFrame); + if (r.kind == NodeRef::Kind::Entity) o = ents[r.index].originWorld; + else o = tools[r.index].originWorld; resolvedOrigin.insert(id, o); return o; } @@ -275,20 +149,23 @@ ResolvedProjectFrame evaluateAtFrame(const core::Project& project, int frame, in if (r.kind == NodeRef::Kind::Entity) { const auto& e = ents[r.index]; parentId = e.parentId; - selfSampled = sampledOriginForEntity(e, clip, localFrame); + selfSampled = e.parentId.isEmpty() ? e.originWorld : e.parentOffsetWorld; + if (animSample.entityPosition.contains(e.id)) { + selfSampled = animSample.entityPosition.value(e.id); + } } else { const auto& t = tools[r.index]; parentId = t.parentId; - selfSampled = sampledOriginForTool(t, clip, localFrame); + selfSampled = t.parentId.isEmpty() ? t.originWorld : t.parentOffsetWorld; + if (animSample.toolPosition.contains(t.id)) { + selfSampled = animSample.toolPosition.value(t.id); + } } QPointF outO = selfSampled; if (!parentId.isEmpty() && index.contains(parentId)) { const QPointF po = resolve(parentId); - const QPointF rel = (r.kind == NodeRef::Kind::Entity) - ? sampledRelativeForEntity(ents[r.index], clip, localFrame) - : sampledRelativeForTool(tools[r.index], clip, localFrame); - outO = po + rel; + outO = po + selfSampled; } resolving.insert(id, false); @@ -308,50 +185,69 @@ ResolvedProjectFrame evaluateAtFrame(const core::Project& project, int frame, in for (int i = 0; i < ents.size(); ++i) { core::Project::Entity e = ents[i]; const QPointF base = e.originWorld; - const QPointF ro = (!e.id.isEmpty()) ? resolve(e.id) : sampledOriginForEntity(e, clip, localFrame); + const QPointF ro = (!e.id.isEmpty()) ? resolve(e.id) : e.originWorld; const QPointF delta = ro - base; e.originWorld = ro; e.imageTopLeftWorld += delta; - // Clip channels: userScale / imagePath(迁移后仍能逐帧显示) - if (clip && clip->entityUserScaleKeys.contains(e.id)) { - const auto& keys = clip->entityUserScaleKeys.value(e.id); - e.userScale = core::sampleUserScale(keys, localFrame, e.userScale, core::KeyInterpolation::Linear); + if (animSample.entityUserScale.contains(e.id)) { + e.userScale = animSample.entityUserScale.value(e.id); } - if (clip && clip->entityImageFrames.contains(e.id)) { - const auto& frames = clip->entityImageFrames.value(e.id); - e.imagePath = core::sampleImagePath(frames, localFrame, e.imagePath); + if (animSample.entityDepthScale01.contains(e.id)) { + const double v01 = std::clamp(animSample.entityDepthScale01.value(e.id), 0.0, 1.0); + e.depth = int(std::lround(v01 * 255.0)); + } + e.runtimeImagePng.clear(); + if (animSample.entitySpritePng.contains(e.id)) { + const QByteArray png = animSample.entitySpritePng.value(e.id); + if (!png.isEmpty()) { + e.runtimeImagePng = png; + } + } + if (e.runtimeImagePng.isEmpty() && !e.defaultImagePng.isEmpty()) { + e.runtimeImagePng = e.defaultImagePng; } - QVector visKeys = e.visibilityKeys; - if (clip && clip->entityVisibilityKeys.contains(e.id)) { - visKeys = clip->entityVisibilityKeys.value(e.id); + if (animSample.entityPriority.contains(e.id)) { + const double p = animSample.entityPriority.value(e.id); + e.priority = std::clamp(int(std::lround(p)), -1000000, 1000000); } - const double op = opacityWithDefault(visKeys, e.visible); - out.entities.push_back(ResolvedEntity{e, op}); + + const double animOp = animSample.entityVisibility.contains(e.id) + ? (animSample.entityVisibility.value(e.id) ? 1.0 : 0.0) + : (e.visible ? 1.0 : 0.0); + out.entities.push_back(ResolvedEntity{e, animOp}); } // Tools:resolved origin + opacity(可见性轨道) for (int i = 0; i < tools.size(); ++i) { core::Project::Tool t = tools[i]; const QPointF base = t.originWorld; - const QPointF ro = (!t.id.isEmpty()) ? resolve(t.id) : sampledOriginForTool(t, clip, localFrame); + const QPointF ro = (!t.id.isEmpty()) ? resolve(t.id) : t.originWorld; const QPointF delta = ro - base; t.originWorld = ro; // parentOffsetWorld 已包含相对关系,不在这里改 - QVector visKeys = t.visibilityKeys; - if (clip && clip->toolVisibilityKeys.contains(t.id)) { - visKeys = clip->toolVisibilityKeys.value(t.id); - } - const double op = opacityWithDefault(visKeys, t.visible); (void)delta; - out.tools.push_back(ResolvedTool{t, op}); + if (animSample.toolPriority.contains(t.id)) { + const double p = animSample.toolPriority.value(t.id); + t.priority = std::clamp(int(std::lround(p)), -1000000, 1000000); + } + const double animOp = animSample.toolVisibility.contains(t.id) + ? (animSample.toolVisibility.value(t.id) ? 1.0 : 0.0) + : (t.visible ? 1.0 : 0.0); + out.tools.push_back(ResolvedTool{t, animOp}); } for (const auto& c : cams) { core::Project::Camera cam = c; - cam.centerWorld = sampledCenterForCamera(c, clip, localFrame); - cam.viewScale = sampledViewScaleForCamera(c, clip, localFrame); + cam.centerWorld = c.centerWorld; + cam.viewScale = c.viewScale; + if (animSample.cameraPosition.contains(c.id)) { + cam.centerWorld = animSample.cameraPosition.value(c.id); + } + if (animSample.cameraScale.contains(c.id)) { + cam.viewScale = animSample.cameraScale.value(c.id); + } out.cameras.push_back(ResolvedCamera{std::move(cam)}); } diff --git a/client/core/eval/ProjectEvaluator.h b/client/core/eval/ProjectEvaluator.h index 13fd82f..6073426 100644 --- a/client/core/eval/ProjectEvaluator.h +++ b/client/core/eval/ProjectEvaluator.h @@ -29,8 +29,9 @@ struct ResolvedProjectFrame { QVector cameras; }; -/// 逐帧求值:处理父子跟随与工具可见性淡入淡出。 -ResolvedProjectFrame evaluateAtFrame(const core::Project& project, int frame, int fadeFrames = 10); +/// 逐帧求值:处理父子跟随与工具可见性淡入淡出。animationId 为空时使用 activeAnimationId(含既有回退)。 +ResolvedProjectFrame evaluateAtFrame(const core::Project& project, int frame, int fadeFrames = 10, + const QString& animationId = QString()); } // namespace core::eval diff --git a/client/core/image/ImageDecodeConfig.h b/client/core/image/ImageDecodeConfig.h new file mode 100644 index 0000000..2424aab --- /dev/null +++ b/client/core/image/ImageDecodeConfig.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +namespace core { +namespace image_decode { + +/// 提高 Qt6 解码像素上限,避免大图被 reader 直接拒绝(单位 MB) +inline void prepareLargeImageReader() { +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + QImageReader::setAllocationLimit(4096); +#endif +} + +/// 裁剪对话框等「仅预览」场景:像素上限(libvips 缩略与 Qt 回退共用) +[[maybe_unused]] constexpr qint64 kPreviewMaxPixels = 16LL * 1000 * 1000; + +/// 编辑器 / 帧图替换:缩略像素上限(libvips 与 Qt 回退共用) +[[maybe_unused]] constexpr qint64 kWorkspaceMaxPixels = 48LL * 1000 * 1000; + +/// 在 read() 前调用:若像素数超过 maxPixels,则 setScaledSize 按比例缩小(不解码全图) +inline void downscaleReaderIfExceeds(QImageReader& reader, qint64 maxPixels) { + const QSize sz = reader.size(); + if (!sz.isValid() || sz.width() <= 0 || sz.height() <= 0) { + return; + } + const qint64 pixels = qint64(sz.width()) * qint64(sz.height()); + if (pixels <= maxPixels) { + return; + } + const double s = std::sqrt(double(maxPixels) / double(pixels)); + const int nw = std::max(1, int(std::lround(sz.width() * s))); + const int nh = std::max(1, int(std::lround(sz.height() * s))); + reader.setScaledSize(QSize(nw, nh)); +} + +/// 将 rect 从 fromSize 坐标系线性映射到 toSize(例如预览图上的选区 → 原图逻辑像素) +inline QRect mapRectBetweenSizes(const QRect& rect, const QSize& fromSize, const QSize& toSize) { + if (!rect.isValid() || fromSize.width() <= 0 || fromSize.height() <= 0 || toSize.width() <= 0 || + toSize.height() <= 0) { + return {}; + } + const qreal sx = qreal(toSize.width()) / qreal(fromSize.width()); + const qreal sy = qreal(toSize.height()) / qreal(fromSize.height()); + const int x0 = int(std::floor(qreal(rect.left()) * sx)); + const int y0 = int(std::floor(qreal(rect.top()) * sy)); + const int x1 = int(std::ceil(qreal(rect.right() + 1) * sx)); + const int y1 = int(std::ceil(qreal(rect.bottom() + 1) * sy)); + QRect out(x0, y0, x1 - x0, y1 - y0); + return out.normalized().intersected(QRect(0, 0, toSize.width(), toSize.height())); +} + +} // namespace image_decode +} // namespace core diff --git a/client/core/image/ImageFileLoader.cpp b/client/core/image/ImageFileLoader.cpp new file mode 100644 index 0000000..e611cfa --- /dev/null +++ b/client/core/image/ImageFileLoader.cpp @@ -0,0 +1,99 @@ +#include "image/ImageFileLoader.h" + +#include "image/ImageDecodeConfig.h" +#include "large_image/VipsBackend.h" + +#include + +namespace core { +namespace image_file { + +bool probeImagePixelSize(const QString& absolutePath, QSize* outSize) { + if (!outSize || absolutePath.isEmpty()) { + return false; + } + if (VipsBackend::isAvailable() && VipsBackend::probeSize(absolutePath, outSize)) { + return outSize->isValid() && outSize->width() > 0 && outSize->height() > 0; + } + core::image_decode::prepareLargeImageReader(); + QImageReader reader(absolutePath); + reader.setAutoTransform(true); + *outSize = reader.size(); + return outSize->isValid() && outSize->width() > 0 && outSize->height() > 0; +} + +QImage loadImageLimited(const QString& absolutePath, qint64 maxPixelBudget) { + if (absolutePath.isEmpty()) { + return {}; + } + QImage viaVips; + if (VipsBackend::isAvailable() && + VipsBackend::loadThumbnailQImage(absolutePath, maxPixelBudget, &viaVips) && !viaVips.isNull()) { + return viaVips; + } + core::image_decode::prepareLargeImageReader(); + QImageReader reader(absolutePath); + reader.setAutoTransform(true); + core::image_decode::downscaleReaderIfExceeds(reader, maxPixelBudget); + return reader.read(); +} + +bool loadRegionToQImage(const QString& absolutePath, const QRect& rectLogical, int maxOutputWidth, int maxOutputHeight, + QImage* out) { + if (!out || absolutePath.isEmpty() || maxOutputWidth < 32 || maxOutputHeight < 32) { + return false; + } + out->detach(); + *out = QImage(); + if (VipsBackend::isAvailable() && + VipsBackend::loadRegionToQImage(absolutePath, rectLogical, maxOutputWidth, maxOutputHeight, out) && + !out->isNull()) { + return true; + } + QSize logical; + if (!probeImagePixelSize(absolutePath, &logical)) { + return false; + } + const QRect r = rectLogical.intersected(QRect(0, 0, logical.width(), logical.height())); + if (r.isEmpty()) { + return false; + } + QImage full = loadImageLimited(absolutePath, core::image_decode::kWorkspaceMaxPixels); + if (full.isNull()) { + return false; + } + const QRect r2 = core::image_decode::mapRectBetweenSizes(r, logical, full.size()); + QImage piece = full.copy(r2); + if (piece.isNull()) { + return false; + } + // 与 vips 路径保持一致:输出永不超过 maxOutput*,避免后续绘制阶段二次缩放导致明显“发糊”。 + if (piece.width() > maxOutputWidth || piece.height() > maxOutputHeight) { + piece = piece.scaled(QSize(maxOutputWidth, maxOutputHeight), Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + *out = piece; + return !out->isNull(); +} + +bool loadRegionToQImageExact(const QString& absolutePath, const QRect& rectLogical, QImage* out) { + if (!out || absolutePath.isEmpty()) { + return false; + } + out->detach(); + *out = QImage(); + QSize logical; + if (!probeImagePixelSize(absolutePath, &logical)) { + return false; + } + const QRect r = rectLogical.intersected(QRect(0, 0, logical.width(), logical.height())); + if (r.isEmpty()) { + return false; + } + // 1:1 输出:maxOutput 设为 region 本身大小,避免 vips/Qt 管线自动缩放导致坐标不一致。 + const int w = std::max(1, r.width()); + const int h = std::max(1, r.height()); + return loadRegionToQImage(absolutePath, r, w, h, out); +} + +} // namespace image_file +} // namespace core diff --git a/client/core/image/ImageFileLoader.h b/client/core/image/ImageFileLoader.h new file mode 100644 index 0000000..1b51b81 --- /dev/null +++ b/client/core/image/ImageFileLoader.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include +#include + +#include + +namespace core { +namespace image_file { + +/// 仅读元数据(宽高),不解码像素;优先 libvips,否则 Qt QImageReader::size。 +[[nodiscard]] bool probeImagePixelSize(const QString& absolutePath, QSize* outSize); + +/// 统一大图解码管线:libvips(可用)按像素预算缩小后再解码为 QImage,否则 Qt。 +[[nodiscard]] QImage loadImageLimited(const QString& absolutePath, qint64 maxPixelBudget); + +/// 仅解码源图中矩形区域(逻辑像素坐标);超过 maxOutput* 则缩放该区域。 +/// libvips 可用时走随机访问裁剪;否则整图降采样回退后再拷贝矩形。 +[[nodiscard]] bool loadRegionToQImage(const QString& absolutePath, const QRect& rectLogical, int maxOutputWidth, + int maxOutputHeight, QImage* out); + +/// 仅解码源图中矩形区域(逻辑像素坐标),并保证输出为 1:1 像素(不缩放)。 +/// 用于抠图/像素对齐等需要严格坐标一致的场景。 +[[nodiscard]] bool loadRegionToQImageExact(const QString& absolutePath, const QRect& rectLogical, QImage* out); + +} // namespace image_file +} // namespace core diff --git a/client/core/large_image/VipsBackend.cpp b/client/core/large_image/VipsBackend.cpp new file mode 100644 index 0000000..3029da6 --- /dev/null +++ b/client/core/large_image/VipsBackend.cpp @@ -0,0 +1,271 @@ +// 必须在任何 Qt 头(会定义宏 signals)之前包含 vips/glib +#ifdef LT_HAVE_VIPS +# include +#endif + +#include "large_image/VipsBackend.h" + +#include +#include +#include + +#include +#include +#include + +namespace core { + +#ifdef LT_HAVE_VIPS + +namespace { + +bool gReady = false; + +QByteArray encodedPath(const QString& path) { + return QFile::encodeName(path); +} + +bool saveVipsImageToPngFile(VipsImage* img, const QString& destPath) { + if (!img) { + return false; + } + const QByteArray dest = encodedPath(destPath); + if (vips_pngsave(img, dest.constData(), "compression", 3, nullptr) != 0) { + return false; + } + return true; +} + +} // namespace + +bool VipsBackend::init(const char* argv0) { + if (gReady) { + return true; + } + if (vips_init(argv0) != 0) { + return false; + } + gReady = true; + return true; +} + +void VipsBackend::shutdown() { + if (!gReady) { + return; + } + vips_shutdown(); + gReady = false; +} + +bool VipsBackend::isAvailable() { + return gReady; +} + +bool VipsBackend::probeSize(const QString& path, QSize* out) { + if (!gReady || !out) { + return false; + } + const QByteArray enc = encodedPath(path); + if (enc.isEmpty()) { + return false; + } + VipsImage* in = vips_image_new_from_file(enc.constData(), "access", VIPS_ACCESS_SEQUENTIAL, nullptr); + if (!in) { + return false; + } + const int w = vips_image_get_width(in); + const int h = vips_image_get_height(in); + g_object_unref(in); + if (w <= 0 || h <= 0) { + return false; + } + *out = QSize(w, h); + return true; +} + +bool VipsBackend::loadThumbnailQImage(const QString& path, qint64 maxPixels, QImage* out) { + if (!gReady || !out || maxPixels < 1024) { + return false; + } + const QByteArray enc = encodedPath(path); + if (enc.isEmpty()) { + return false; + } + + QSize sz; + if (!probeSize(path, &sz)) { + return false; + } + const qint64 pixels = qint64(sz.width()) * qint64(sz.height()); + if (pixels <= 0) { + return false; + } + + VipsImage* thumb = nullptr; + if (pixels <= maxPixels) { + thumb = vips_image_new_from_file(enc.constData(), "access", VIPS_ACCESS_SEQUENTIAL, nullptr); + if (!thumb) { + return false; + } + } else { + const int targetW = + std::max(1, int(std::floor(std::sqrt(double(maxPixels) * double(sz.width()) / double(sz.height()))))); + if (vips_thumbnail(enc.constData(), &thumb, targetW, nullptr) != 0) { + return false; + } + } + + void* buf = nullptr; + size_t len = 0; + if (vips_pngsave_buffer(thumb, &buf, &len, "compression", 3, nullptr) != 0) { + g_object_unref(thumb); + return false; + } + g_object_unref(thumb); + + const bool ok = out->loadFromData(static_cast(buf), static_cast(len), "PNG"); + g_free(buf); + return ok && !out->isNull(); +} + +bool VipsBackend::loadRegionToQImage(const QString& sourcePath, const QRect& rectInSource, int maxOutputWidth, + int maxOutputHeight, QImage* out) { + if (!gReady || !out || maxOutputWidth < 32 || maxOutputHeight < 32) { + return false; + } + out->detach(); + *out = QImage(); + + const QByteArray src = encodedPath(sourcePath); + if (src.isEmpty()) { + return false; + } + + VipsImage* in = vips_image_new_from_file(src.constData(), "access", VIPS_ACCESS_RANDOM, nullptr); + if (!in) { + return false; + } + + const int iw = vips_image_get_width(in); + const int ih = vips_image_get_height(in); + const QRect bounds(0, 0, iw, ih); + QRect r = rectInSource.normalized().intersected(bounds); + if (r.width() <= 0 || r.height() <= 0) { + g_object_unref(in); + return false; + } + + VipsImage* cropped = nullptr; + if (vips_crop(in, &cropped, r.left(), r.top(), r.width(), r.height(), nullptr) != 0) { + g_object_unref(in); + return false; + } + g_object_unref(in); + + const int cw = r.width(); + const int ch = r.height(); + VipsImage* toEncode = cropped; + + // 视口解码:输出尺寸不应超过 maxOutput*,否则后续绘制阶段再缩小会引入明显的线性滤波“发糊”。 + // 这里始终把区域降采样到不超过 maxOutput*(保持宽高比),让 vips 在解码阶段完成高质量缩放。 + double scale = 1.0; + if (cw > maxOutputWidth || ch > maxOutputHeight) { + const double sx = static_cast(maxOutputWidth) / static_cast(cw); + const double sy = static_cast(maxOutputHeight) / static_cast(ch); + scale = std::min({sx, sy, 1.0}); + } + if (scale < 0.9999) { + VipsImage* resized = nullptr; + if (vips_resize(cropped, &resized, scale, "kernel", VIPS_KERNEL_LANCZOS3, nullptr) != 0) { + g_object_unref(cropped); + return false; + } + g_object_unref(cropped); + toEncode = resized; + } + + void* buf = nullptr; + size_t len = 0; + if (vips_pngsave_buffer(toEncode, &buf, &len, "compression", 3, nullptr) != 0) { + g_object_unref(toEncode); + return false; + } + g_object_unref(toEncode); + + const bool ok = out->loadFromData(static_cast(buf), static_cast(len), "PNG"); + g_free(buf); + return ok && !out->isNull(); +} + +bool VipsBackend::saveRectToPng(const QString& sourcePath, const QRect& rectInSource, const QString& destPngPath) { + if (!gReady) { + return false; + } + const QByteArray src = encodedPath(sourcePath); + if (src.isEmpty()) { + return false; + } + + VipsImage* in = vips_image_new_from_file(src.constData(), "access", VIPS_ACCESS_RANDOM, nullptr); + if (!in) { + return false; + } + + const int iw = vips_image_get_width(in); + const int ih = vips_image_get_height(in); + const QRect bounds(0, 0, iw, ih); + QRect r = rectInSource.normalized().intersected(bounds); + if (r.width() <= 0 || r.height() <= 0) { + g_object_unref(in); + return false; + } + + VipsImage* toSave = in; + VipsImage* cropped = nullptr; + if (r != bounds) { + if (vips_crop(in, &cropped, r.left(), r.top(), r.width(), r.height(), nullptr) != 0) { + g_object_unref(in); + return false; + } + toSave = cropped; + } + + const bool ok = saveVipsImageToPngFile(toSave, destPngPath); + if (cropped) { + g_object_unref(cropped); + } + g_object_unref(in); + return ok; +} + +#else // !LT_HAVE_VIPS + +bool VipsBackend::init(const char*) { + return false; +} + +void VipsBackend::shutdown() {} + +bool VipsBackend::isAvailable() { + return false; +} + +bool VipsBackend::probeSize(const QString&, QSize*) { + return false; +} + +bool VipsBackend::loadThumbnailQImage(const QString&, qint64, QImage*) { + return false; +} + +bool VipsBackend::saveRectToPng(const QString&, const QRect&, const QString&) { + return false; +} + +bool VipsBackend::loadRegionToQImage(const QString&, const QRect&, int, int, QImage*) { + return false; +} + +#endif // LT_HAVE_VIPS + +} // namespace core diff --git a/client/core/large_image/VipsBackend.h b/client/core/large_image/VipsBackend.h new file mode 100644 index 0000000..dea93cc --- /dev/null +++ b/client/core/large_image/VipsBackend.h @@ -0,0 +1,39 @@ +#pragma once + +#include + +#include +#include +#include + +namespace core { + +/// 基于 libvips 的大图 I/O:随机访问裁剪、流式写 PNG、缩略预览,避免整图进内存。 +/// 未编译 libvips 时 API 返回 false,由调用方回退到 Qt。 +class VipsBackend { +public: + /// 须在 QApplication 创建后调用一次,argv0 建议为 argv[0]。 + static bool init(const char* argv0); + static void shutdown(); + + static bool isAvailable(); + + /// 打开文件读取宽高(轻量,不解码全图) + static bool probeSize(const QString& path, QSize* out); + + /// 生成预览图(总像素约不超过 maxPixels),用于裁剪对话框 / 画布 + static bool loadThumbnailQImage(const QString& path, qint64 maxPixels, QImage* out); + + /// 将原图中矩形区域(像素坐标,含边界)无损写入 PNG;区域为整图时直接流式转存。 + /// 适合 GB 级 PNG,不构建整幅 QImage。 + static bool saveRectToPng(const QString& sourcePath, const QRect& rectInSource, const QString& destPngPath); + + /// 裁剪原图矩形区域;若超过 maxOutput* 则按比例缩小(不降采样整图,只处理该区域)。 + static bool loadRegionToQImage(const QString& sourcePath, const QRect& rectInSource, int maxOutputWidth, + int maxOutputHeight, QImage* out); + +private: + VipsBackend() = default; +}; + +} // namespace core diff --git a/client/core/library/EntityJson.cpp b/client/core/library/EntityJson.cpp index 35c9fbd..a2afcfb 100644 --- a/client/core/library/EntityJson.cpp +++ b/client/core/library/EntityJson.cpp @@ -2,7 +2,9 @@ #include #include +#include #include +#include namespace core::library { @@ -70,6 +72,15 @@ QJsonObject entityToJson(const core::Project::Entity& e) { o.insert(QStringLiteral("blackholeId"), e.blackholeId); o.insert(QStringLiteral("blackholeVisible"), e.blackholeVisible); o.insert(QStringLiteral("blackholeResolvedBy"), e.blackholeResolvedBy); + o.insert(QStringLiteral("blackholeOverlayPath"), e.blackholeOverlayPath); + { + QJsonObject r; + r.insert(QStringLiteral("x"), e.blackholeOverlayRect.x()); + r.insert(QStringLiteral("y"), e.blackholeOverlayRect.y()); + r.insert(QStringLiteral("w"), e.blackholeOverlayRect.width()); + r.insert(QStringLiteral("h"), e.blackholeOverlayRect.height()); + o.insert(QStringLiteral("blackholeOverlayRect"), r); + } o.insert(QStringLiteral("originWorld"), pointToJson(e.originWorld)); o.insert(QStringLiteral("depth"), e.depth); o.insert(QStringLiteral("imagePath"), e.imagePath); @@ -81,7 +92,6 @@ QJsonObject entityToJson(const core::Project::Entity& e) { o.insert(QStringLiteral("parentOffsetWorld"), pointToJson(e.parentOffsetWorld)); o.insert(QStringLiteral("entityPayloadPath"), e.entityPayloadPath); - o.insert(QStringLiteral("legacyAnimSidecarPath"), e.legacyAnimSidecarPath); o.insert(QStringLiteral("locationKeys"), vecToJson( @@ -161,6 +171,13 @@ bool entityFromJson(const QJsonObject& o, core::Project::Entity& out) { } e.blackholeVisible = o.value(QStringLiteral("blackholeVisible")).toBool(true); e.blackholeResolvedBy = o.value(QStringLiteral("blackholeResolvedBy")).toString(); + e.blackholeOverlayPath = o.value(QStringLiteral("blackholeOverlayPath")).toString(); + { + const QJsonObject r = o.value(QStringLiteral("blackholeOverlayRect")).toObject(); + e.blackholeOverlayRect = + QRect(r.value(QStringLiteral("x")).toInt(0), r.value(QStringLiteral("y")).toInt(0), + r.value(QStringLiteral("w")).toInt(0), r.value(QStringLiteral("h")).toInt(0)); + } { QPointF p; if (!pointFromJson(o.value(QStringLiteral("originWorld")), p)) { @@ -189,7 +206,6 @@ bool entityFromJson(const QJsonObject& o, core::Project::Entity& out) { e.parentOffsetWorld = p; } e.entityPayloadPath = o.value(QStringLiteral("entityPayloadPath")).toString(); - e.legacyAnimSidecarPath = o.value(QStringLiteral("legacyAnimSidecarPath")).toString(); auto parseKeyframesVec2 = [&](const QString& key, QVector& dst) -> bool { dst.clear(); diff --git a/client/core/net/ModelServerClient.cpp b/client/core/net/ModelServerClient.cpp index ffe2017..0943f2e 100644 --- a/client/core/net/ModelServerClient.cpp +++ b/client/core/net/ModelServerClient.cpp @@ -10,6 +10,8 @@ #include #include +#include + namespace core { ModelServerClient::ModelServerClient(QObject* parent) @@ -90,6 +92,7 @@ QNetworkReply* ModelServerClient::segmentSamPromptAsync( QNetworkReply* ModelServerClient::inpaintAsync( const QByteArray& cropRgbPngBytes, const QByteArray& maskPngBytes, + const QString& modelName, const QString& prompt, const QString& negativePrompt, double strength, @@ -119,6 +122,9 @@ QNetworkReply* ModelServerClient::inpaintAsync( QJsonObject payload; payload.insert(QStringLiteral("image_b64"), QString::fromLatin1(cropRgbPngBytes.toBase64())); payload.insert(QStringLiteral("mask_b64"), QString::fromLatin1(maskPngBytes.toBase64())); + if (!modelName.trimmed().isEmpty()) { + payload.insert(QStringLiteral("model_name"), modelName.trimmed()); + } payload.insert(QStringLiteral("prompt"), prompt); payload.insert(QStringLiteral("negative_prompt"), negativePrompt); payload.insert(QStringLiteral("strength"), strength); @@ -128,6 +134,62 @@ QNetworkReply* ModelServerClient::inpaintAsync( return m_nam->post(req, body); } +QNetworkReply* ModelServerClient::animateCharacterSequenceAsync( + const QByteArray& characterPngBytes, + const QString& prompt, + const QString& negativePrompt, + const QString& backgroundColor, + int backgroundTolerance, + int frameCount, + int numInferenceSteps, + double guidanceScale, + int maxSide, + int seed, + QString* outImmediateError +) { + if (outImmediateError) { + outImmediateError->clear(); + } + if (!m_baseUrl.isValid() || m_baseUrl.isEmpty()) { + if (outImmediateError) *outImmediateError = QStringLiteral("后端地址无效。"); + return nullptr; + } + if (characterPngBytes.isEmpty()) { + if (outImmediateError) *outImmediateError = QStringLiteral("角色 PNG 为空。"); + return nullptr; + } + if (prompt.trimmed().isEmpty()) { + if (outImmediateError) *outImmediateError = QStringLiteral("动画提示词不能为空。"); + return nullptr; + } + if (frameCount <= 0) { + if (outImmediateError) *outImmediateError = QStringLiteral("帧数必须大于 0。"); + return nullptr; + } + + const QUrl url = m_baseUrl.resolved(QUrl(QStringLiteral("/animate/character_sequence"))); + QNetworkRequest req(url); + req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); + + QJsonObject payload; + payload.insert(QStringLiteral("image_b64"), QString::fromLatin1(characterPngBytes.toBase64())); + payload.insert(QStringLiteral("model_name"), QStringLiteral("animatediff")); + payload.insert(QStringLiteral("prompt"), prompt.trimmed()); + payload.insert(QStringLiteral("negative_prompt"), negativePrompt); + payload.insert(QStringLiteral("background_color"), backgroundColor.trimmed().isEmpty() + ? QStringLiteral("#00FF00") + : backgroundColor.trimmed()); + payload.insert(QStringLiteral("background_tolerance"), std::clamp(backgroundTolerance, 0, 255)); + payload.insert(QStringLiteral("video_length"), frameCount); + payload.insert(QStringLiteral("num_inference_steps"), std::max(1, numInferenceSteps)); + payload.insert(QStringLiteral("guidance_scale"), guidanceScale); + payload.insert(QStringLiteral("max_side"), std::clamp(maxSide, 128, 2048)); + payload.insert(QStringLiteral("seed"), seed); + + const QByteArray body = QJsonDocument(payload).toJson(QJsonDocument::Compact); + return m_nam->post(req, body); +} + bool ModelServerClient::computeDepthPng8( const QByteArray& imageBytes, QByteArray& outPngBytes, diff --git a/client/core/net/ModelServerClient.h b/client/core/net/ModelServerClient.h index eb4b08e..a0506f9 100644 --- a/client/core/net/ModelServerClient.h +++ b/client/core/net/ModelServerClient.h @@ -41,12 +41,27 @@ public: QNetworkReply* inpaintAsync( const QByteArray& cropRgbPngBytes, const QByteArray& maskPngBytes, + const QString& modelName, const QString& prompt, const QString& negativePrompt, double strength, int maxSide, QString* outImmediateError = nullptr); + // POST /animate/character_sequence,JSON 响应由调用方解析(success / frames[] / fps / error)。 + QNetworkReply* animateCharacterSequenceAsync( + const QByteArray& characterPngBytes, + const QString& prompt, + const QString& negativePrompt, + const QString& backgroundColor, + int backgroundTolerance, + int frameCount, + int numInferenceSteps, + double guidanceScale, + int maxSide, + int seed, + QString* outImmediateError = nullptr); + private: QNetworkAccessManager* m_nam = nullptr; QUrl m_baseUrl; diff --git a/client/core/persistence/AnimationBinary.cpp b/client/core/persistence/AnimationBinary.cpp new file mode 100644 index 0000000..414b3bf --- /dev/null +++ b/client/core/persistence/AnimationBinary.cpp @@ -0,0 +1,204 @@ +#include "persistence/AnimationBinary.h" + +#include "persistence/PersistentBinaryObject.h" + +#include +#include + +#include + +namespace core { +namespace { + +qint32 toInt(Project::AnimationTargetKind v) { + return static_cast(v); +} + +qint32 toInt(Project::AnimationProperty v) { + return static_cast(v); +} + +qint32 toInt(Project::AnimationValueType v) { + return static_cast(v); +} + +qint32 toInt(Project::AnimationInterpolation v) { + return static_cast(v); +} + +Project::AnimationTargetKind targetKindFromInt(qint32 v) { + if (v == toInt(Project::AnimationTargetKind::Tool)) return Project::AnimationTargetKind::Tool; + if (v == toInt(Project::AnimationTargetKind::Camera)) return Project::AnimationTargetKind::Camera; + return Project::AnimationTargetKind::Entity; +} + +Project::AnimationProperty propertyFromInt(qint32 v) { + switch (v) { + case 1: return Project::AnimationProperty::UserScale; + case 2: return Project::AnimationProperty::Sprite; + case 3: return Project::AnimationProperty::Visibility; + case 4: return Project::AnimationProperty::DepthScale; + case 5: return Project::AnimationProperty::CameraScale; + default: return Project::AnimationProperty::Position; + } +} + +Project::AnimationValueType valueTypeFromInt(qint32 v) { + switch (v) { + case 1: return Project::AnimationValueType::Double; + case 2: return Project::AnimationValueType::Bool; + case 3: return Project::AnimationValueType::ImagePath; + case 4: return Project::AnimationValueType::PngBytes; + default: return Project::AnimationValueType::Vec2; + } +} + +Project::AnimationInterpolation interpolationFromInt(qint32 v) { + return v == 0 ? Project::AnimationInterpolation::Hold : Project::AnimationInterpolation::Linear; +} + +void sortAndDedupe(QVector& keys) { + std::sort(keys.begin(), keys.end(), [](const auto& a, const auto& b) { + return a.frame < b.frame; + }); + QVector out; + out.reserve(keys.size()); + for (const auto& k : keys) { + if (!out.isEmpty() && out.last().frame == k.frame) { + out.last() = k; + } else { + out.push_back(k); + } + } + keys = std::move(out); +} + +class AnimationRecord final : public PersistentBinaryObject { +public: + explicit AnimationRecord(const Project::Animation& animation) : m_src(&animation), m_dst(nullptr) {} + explicit AnimationRecord(Project::Animation& animation) : m_src(nullptr), m_dst(&animation) {} + + quint32 recordMagic() const override { return AnimationBinary::kMagicAnimation; } + quint32 recordFormatVersion() const override { return AnimationBinary::kAnimationVersion; } + + void writeBody(QDataStream& ds) const override { + const Project::Animation& a = *m_src; + ds << a.id << a.name << a.payloadPath; + ds << qint32(a.fps) << qint32(a.lengthFrames) << bool(a.loop); + ds << qint32(a.tracks.size()); + for (const auto& t : a.tracks) { + ds << t.id << toInt(t.targetKind) << t.targetId << toInt(t.property) + << toInt(t.valueType) << toInt(t.interpolation); + ds << qint32(t.keys.size()); + for (const auto& k : t.keys) { + ds << qint32(k.frame); + switch (t.valueType) { + case Project::AnimationValueType::Vec2: + ds << double(k.vec2Value.x()) << double(k.vec2Value.y()); + break; + case Project::AnimationValueType::Double: + ds << double(k.numberValue); + break; + case Project::AnimationValueType::Bool: + ds << bool(k.boolValue); + break; + case Project::AnimationValueType::ImagePath: + ds << k.stringValue; + break; + case Project::AnimationValueType::PngBytes: + ds << k.bytesValue; + break; + } + } + } + } + + bool readBody(QDataStream& ds) override { + Project::Animation a; + qint32 fps = 30; + qint32 length = Project::kClipFixedFrames; + bool loop = true; + ds >> a.id >> a.name >> a.payloadPath; + ds >> fps >> length >> loop; + if (ds.status() != QDataStream::Ok || a.id.isEmpty()) return false; + a.fps = std::max(1, int(fps)); + a.lengthFrames = std::max(1, int(length)); + a.loop = loop; + + qint32 nTracks = 0; + ds >> nTracks; + if (ds.status() != QDataStream::Ok || nTracks < 0 || nTracks > 100000) return false; + a.tracks.reserve(nTracks); + for (qint32 i = 0; i < nTracks; ++i) { + Project::AnimationTrack t; + qint32 kind = 0; + qint32 prop = 0; + qint32 valueType = 0; + qint32 interpolation = 1; + ds >> t.id >> kind >> t.targetId >> prop >> valueType >> interpolation; + if (ds.status() != QDataStream::Ok || t.targetId.isEmpty()) return false; + t.targetKind = targetKindFromInt(kind); + t.property = propertyFromInt(prop); + t.valueType = valueTypeFromInt(valueType); + t.interpolation = interpolationFromInt(interpolation); + + qint32 nKeys = 0; + ds >> nKeys; + if (ds.status() != QDataStream::Ok || nKeys < 0 || nKeys > 1000000) return false; + t.keys.reserve(nKeys); + for (qint32 kidx = 0; kidx < nKeys; ++kidx) { + Project::AnimationKey k; + qint32 frame = 0; + ds >> frame; + k.frame = int(frame); + switch (t.valueType) { + case Project::AnimationValueType::Vec2: { + double x = 0.0; + double y = 0.0; + ds >> x >> y; + k.vec2Value = QPointF(x, y); + break; + } + case Project::AnimationValueType::Double: + ds >> k.numberValue; + break; + case Project::AnimationValueType::Bool: + ds >> k.boolValue; + break; + case Project::AnimationValueType::ImagePath: + ds >> k.stringValue; + break; + case Project::AnimationValueType::PngBytes: + ds >> k.bytesValue; + break; + } + if (ds.status() != QDataStream::Ok) return false; + t.keys.push_back(k); + } + sortAndDedupe(t.keys); + a.tracks.push_back(t); + } + + *m_dst = std::move(a); + return true; + } + +private: + const Project::Animation* m_src; + Project::Animation* m_dst; +}; + +} // namespace + +bool AnimationBinary::save(const QString& absolutePath, const Project::Animation& animation) { + if (absolutePath.isEmpty() || animation.id.isEmpty()) { + return false; + } + return AnimationRecord(animation).saveToFile(absolutePath); +} + +bool AnimationBinary::load(const QString& absolutePath, Project::Animation& animation) { + return AnimationRecord(animation).loadFromFile(absolutePath); +} + +} // namespace core diff --git a/client/core/persistence/AnimationBinary.h b/client/core/persistence/AnimationBinary.h new file mode 100644 index 0000000..56c5c86 --- /dev/null +++ b/client/core/persistence/AnimationBinary.h @@ -0,0 +1,18 @@ +#pragma once + +#include "domain/Project.h" + +#include + +namespace core { + +class AnimationBinary { +public: + static constexpr quint32 kMagicAnimation = 0x4846414E; // 'HFAN' + static constexpr quint32 kAnimationVersion = 2; + + static bool save(const QString& absolutePath, const Project::Animation& animation); + static bool load(const QString& absolutePath, Project::Animation& animation); +}; + +} // namespace core diff --git a/client/core/persistence/AnimationBundleStore.cpp b/client/core/persistence/AnimationBundleStore.cpp new file mode 100644 index 0000000..609f5ad --- /dev/null +++ b/client/core/persistence/AnimationBundleStore.cpp @@ -0,0 +1,162 @@ +#include "persistence/AnimationBundleStore.h" + +#include "persistence/JsonFileAtomic.h" + +#include +#include +#include +#include + +namespace core::persistence { +namespace { + +QJsonObject keyToJson(const Project::AnimationKey& k, Project::AnimationValueType t) { + QJsonObject o; + o.insert("frame", k.frame); + switch (t) { + case Project::AnimationValueType::Vec2: + o.insert("x", k.vec2Value.x()); + o.insert("y", k.vec2Value.y()); + break; + case Project::AnimationValueType::Double: + o.insert("value", k.numberValue); + break; + case Project::AnimationValueType::Bool: + o.insert("value", k.boolValue); + break; + case Project::AnimationValueType::ImagePath: + o.insert("value", k.stringValue); + break; + case Project::AnimationValueType::PngBytes: + o.insert("value", QString::fromLatin1(k.bytesValue.toBase64())); + break; + } + return o; +} + +bool keyFromJson(const QJsonObject& o, Project::AnimationValueType t, Project::AnimationKey& out) { + out = Project::AnimationKey{}; + out.frame = o.value("frame").toInt(0); + switch (t) { + case Project::AnimationValueType::Vec2: + out.vec2Value = QPointF(o.value("x").toDouble(0.0), o.value("y").toDouble(0.0)); + break; + case Project::AnimationValueType::Double: + out.numberValue = o.value("value").toDouble(0.0); + break; + case Project::AnimationValueType::Bool: + out.boolValue = o.value("value").toBool(true); + break; + case Project::AnimationValueType::ImagePath: + out.stringValue = o.value("value").toString(); + break; + case Project::AnimationValueType::PngBytes: { + const QByteArray b64 = o.value("value").toString().toLatin1(); + out.bytesValue = QByteArray::fromBase64(b64); + break; + } + } + return true; +} + +} // namespace + +QString AnimationBundleStore::bundleRelativeDir(const QString& animationId) { + if (animationId.isEmpty()) return {}; + return QStringLiteral("assets/animations/%1").arg(animationId); +} + +bool AnimationBundleStore::save(const QString& projectDirAbs, const Project::Animation& animation) { + if (projectDirAbs.isEmpty() || animation.id.isEmpty()) { + return false; + } + const QString relDir = bundleRelativeDir(animation.id); + if (relDir.isEmpty()) return false; + const QString absDir = QDir(projectDirAbs).filePath(relDir); + QDir().mkpath(absDir); + + QJsonObject root; + root.insert("id", animation.id); + root.insert("name", animation.name); + root.insert("fps", animation.fps); + root.insert("lengthFrames", animation.lengthFrames); + root.insert("loop", animation.loop); + + QJsonArray tracks; + for (const auto& t : animation.tracks) { + QJsonObject to; + to.insert("id", t.id); + to.insert("targetKind", int(t.targetKind)); + to.insert("targetId", t.targetId); + to.insert("property", int(t.property)); + to.insert("valueType", int(t.valueType)); + to.insert("interpolation", int(t.interpolation)); + QJsonArray keys; + for (const auto& k : t.keys) { + keys.append(keyToJson(k, t.valueType)); + } + to.insert("keys", keys); + tracks.append(to); + } + root.insert("tracks", tracks); + + return writeJsonAtomic(QDir(absDir).filePath(QStringLiteral("anim.json")), root, true); +} + +bool AnimationBundleStore::load(const QString& projectDirAbs, Project::Animation& inOutAnimation) { + if (projectDirAbs.isEmpty() || inOutAnimation.id.isEmpty()) { + return false; + } + const QString relDir = inOutAnimation.payloadPath; + if (relDir.isEmpty()) return false; + const QString absDir = QDir(projectDirAbs).filePath(relDir); + if (!QFileInfo(absDir).exists()) return false; + + QJsonObject root; + if (!readJsonObject(QDir(absDir).filePath(QStringLiteral("anim.json")), root)) { + return false; + } + const QString id = root.value("id").toString(); + if (id.isEmpty() || id != inOutAnimation.id) { + return false; + } + Project::Animation a = inOutAnimation; + a.name = root.value("name").toString(); + a.fps = std::max(1, root.value("fps").toInt(30)); + a.lengthFrames = std::max(1, root.value("lengthFrames").toInt(Project::kClipFixedFrames)); + a.loop = root.value("loop").toBool(true); + + a.tracks.clear(); + const QJsonValue tracksVal = root.value("tracks"); + if (tracksVal.isArray()) { + for (const auto& it : tracksVal.toArray()) { + if (!it.isObject()) continue; + const QJsonObject to = it.toObject(); + Project::AnimationTrack t; + t.id = to.value("id").toString(); + t.targetKind = Project::AnimationTargetKind(to.value("targetKind").toInt(0)); + t.targetId = to.value("targetId").toString(); + t.property = Project::AnimationProperty(to.value("property").toInt(0)); + t.valueType = Project::AnimationValueType(to.value("valueType").toInt(0)); + t.interpolation = Project::AnimationInterpolation(to.value("interpolation").toInt(1)); + const QJsonValue keysVal = to.value("keys"); + if (keysVal.isArray()) { + for (const auto& kv : keysVal.toArray()) { + if (!kv.isObject()) continue; + Project::AnimationKey k; + keyFromJson(kv.toObject(), t.valueType, k); + t.keys.push_back(k); + } + } + if (!t.targetId.isEmpty()) { + a.tracks.push_back(t); + } + } + } + + inOutAnimation = std::move(a); + return true; +} + +} // namespace core::persistence + diff --git a/client/core/persistence/AnimationBundleStore.h b/client/core/persistence/AnimationBundleStore.h new file mode 100644 index 0000000..3ab0263 --- /dev/null +++ b/client/core/persistence/AnimationBundleStore.h @@ -0,0 +1,20 @@ +#pragma once + +#include "domain/Project.h" + +#include + +namespace core::persistence { + +/// 动画 payload 的目录 bundle(v7 起): +/// assets/animations// +/// anim.json // 元数据 + 所有 track(后续可拆成按 track 分块) +struct AnimationBundleStore { + static QString bundleRelativeDir(const QString& animationId); + + static bool save(const QString& projectDirAbs, const Project::Animation& animation); + static bool load(const QString& projectDirAbs, Project::Animation& inOutAnimation); +}; + +} // namespace core::persistence + diff --git a/client/core/persistence/AsyncProjectWriter.cpp b/client/core/persistence/AsyncProjectWriter.cpp new file mode 100644 index 0000000..f76dc96 --- /dev/null +++ b/client/core/persistence/AsyncProjectWriter.cpp @@ -0,0 +1,139 @@ +#include "persistence/AsyncProjectWriter.h" + +#include "persistence/AnimationBundleStore.h" +#include "persistence/EntityBundleStore.h" + +#include +#include +#include +#include +#include + +namespace core::persistence { + +AsyncProjectWriter::AsyncProjectWriter(QObject* parent) + : QObject(parent) { + m_debounce = new QTimer(this); + m_debounce->setSingleShot(true); + m_debounce->setInterval(80); + connect(m_debounce, &QTimer::timeout, this, [this]() { scheduleDrain(); }); +} + +void AsyncProjectWriter::setProjectDir(const QString& projectDirAbs) { + m_projectDir = projectDirAbs; +} + +void AsyncProjectWriter::requestWriteEntity(const Project::Entity& entity) { + if (m_projectDir.isEmpty() || entity.id.isEmpty()) { + return; + } + { + std::lock_guard lock(m_mutex); + m_pendingEntities.insert(entity.id, entity); + } + if (m_debounce) { + m_debounce->start(); + } +} + +void AsyncProjectWriter::requestWriteAnimation(const Project::Animation& animation) { + if (m_projectDir.isEmpty() || animation.id.isEmpty()) { + return; + } + { + std::lock_guard lock(m_mutex); + m_pendingAnimations.insert(animation.id, animation); + } + if (m_debounce) { + m_debounce->start(); + } +} + +void AsyncProjectWriter::scheduleDrain() { + const QString dir = m_projectDir; + if (dir.isEmpty()) { + return; + } + + QHash ents; + QHash anims; + { + std::lock_guard lock(m_mutex); + ents = std::move(m_pendingEntities); + anims = std::move(m_pendingAnimations); + m_pendingEntities.clear(); + m_pendingAnimations.clear(); + } + if (ents.isEmpty() && anims.isEmpty()) { + return; + } + + m_outstandingDrains.fetch_add(1, std::memory_order_acq_rel); + + (void)QtConcurrent::run([this, dir, ents = std::move(ents), anims = std::move(anims)]() mutable { + bool anyFail = false; + for (auto it = ents.begin(); it != ents.end(); ++it) { + const Project::Entity& e = it.value(); + if (!EntityBundleStore::save(dir, e)) { + anyFail = true; + QMetaObject::invokeMethod(this, [this, id = e.id]() { emit entityWriteFailed(id); }, + Qt::QueuedConnection); + } + } + for (auto it = anims.begin(); it != anims.end(); ++it) { + const Project::Animation& a = it.value(); + if (!AnimationBundleStore::save(dir, a)) { + anyFail = true; + QMetaObject::invokeMethod(this, [this, id = a.id]() { emit animationWriteFailed(id); }, + Qt::QueuedConnection); + } + } + Q_UNUSED(anyFail); + m_outstandingDrains.fetch_sub(1, std::memory_order_acq_rel); + }); + + // drain 期间若又有新请求,再开一轮 + { + std::lock_guard lock(m_mutex); + if (!m_pendingEntities.isEmpty() || !m_pendingAnimations.isEmpty()) { + if (m_debounce) { + m_debounce->start(); + } + } + } +} + +void AsyncProjectWriter::flushBlocking(int timeoutMs) { + if (m_projectDir.isEmpty()) { + return; + } + if (m_debounce) { + m_debounce->stop(); + } + + QMetaObject::invokeMethod( + this, + [this]() { + scheduleDrain(); + }, + Qt::QueuedConnection); + + QTimer killer; + killer.setSingleShot(true); + killer.start(std::max(0, timeoutMs)); + while (killer.isActive()) { + bool idle = false; + { + std::lock_guard lock(m_mutex); + idle = + m_pendingEntities.isEmpty() && m_pendingAnimations.isEmpty() && + m_outstandingDrains.load(std::memory_order_acquire) == 0; + } + if (idle) { + break; + } + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + } +} + +} // namespace core::persistence diff --git a/client/core/persistence/AsyncProjectWriter.h b/client/core/persistence/AsyncProjectWriter.h new file mode 100644 index 0000000..65abc41 --- /dev/null +++ b/client/core/persistence/AsyncProjectWriter.h @@ -0,0 +1,51 @@ +#pragma once + +#include "domain/Project.h" + +#include +#include +#include +#include +#include + +#include +#include + +namespace core::persistence { + +/// 后台写盘队列:合并/防抖,避免 UI 线程同步写文件。 +/// 目前以“实体/动画为单位”合并;后续会细化到块级 dirty bits(meta/anim/intro/png...)。 +class AsyncProjectWriter final : public QObject { + Q_OBJECT +public: + explicit AsyncProjectWriter(QObject* parent = nullptr); + + void setProjectDir(const QString& projectDirAbs); + + /// enqueue:线程安全由调用方保证在 UI 线程调用(本类内部用 singleShot 防抖到 writer 线程)。 + void requestWriteEntity(const Project::Entity& entity); + void requestWriteAnimation(const Project::Animation& animation); + + /// 阻塞等待:用于 close/退出时确保落盘完成。 + void flushBlocking(int timeoutMs = 30000); + +signals: + void entityWriteFailed(const QString& entityId); + void animationWriteFailed(const QString& animationId); + +private: + void scheduleDrain(); + + QString m_projectDir; + QTimer* m_debounce = nullptr; + + std::mutex m_mutex; + // 最新快照:同 id 多次更新只写最后一次 + QHash m_pendingEntities; + QHash m_pendingAnimations; + + std::atomic m_outstandingDrains{0}; +}; + +} // namespace core::persistence + diff --git a/client/core/persistence/EntityBundleStore.cpp b/client/core/persistence/EntityBundleStore.cpp new file mode 100644 index 0000000..266f456 --- /dev/null +++ b/client/core/persistence/EntityBundleStore.cpp @@ -0,0 +1,362 @@ +#include "persistence/EntityBundleStore.h" + +#include "persistence/JsonFileAtomic.h" + +#include +#include +#include +#include + +namespace core::persistence { +namespace { + +QJsonArray pointsToJson(const QVector& pts) { + QJsonArray arr; + for (const auto& p : pts) { + QJsonObject o; + o.insert("x", p.x()); + o.insert("y", p.y()); + arr.append(o); + } + return arr; +} + +bool pointsFromJson(const QJsonValue& v, QVector& out) { + out.clear(); + if (!v.isArray()) return true; + const QJsonArray arr = v.toArray(); + out.reserve(arr.size()); + for (const auto& it : arr) { + if (!it.isObject()) continue; + const QJsonObject o = it.toObject(); + out.push_back(QPointF(o.value("x").toDouble(0.0), o.value("y").toDouble(0.0))); + } + return true; +} + +template +QJsonArray boolKeysToJson(const QVector& keys) { + QJsonArray arr; + for (const auto& k : keys) { + QJsonObject o; + o.insert("frame", k.frame); + o.insert("value", k.value); + arr.append(o); + } + return arr; +} + +QJsonArray vec2KeysToJson(const QVector& keys) { + QJsonArray arr; + for (const auto& k : keys) { + QJsonObject o; + o.insert("frame", k.frame); + o.insert("x", k.value.x()); + o.insert("y", k.value.y()); + arr.append(o); + } + return arr; +} + +QJsonArray floatKeysToJson(const QVector& keys) { + QJsonArray arr; + for (const auto& k : keys) { + QJsonObject o; + o.insert("frame", k.frame); + o.insert("value", k.value); + arr.append(o); + } + return arr; +} + +QJsonArray doubleKeysToJson(const QVector& keys) { + QJsonArray arr; + for (const auto& k : keys) { + QJsonObject o; + o.insert("frame", k.frame); + o.insert("value", k.value); + arr.append(o); + } + return arr; +} + +QJsonArray imageFramesToJson(const QVector& keys) { + QJsonArray arr; + for (const auto& k : keys) { + QJsonObject o; + o.insert("frame", k.frame); + o.insert("path", k.imagePath); + arr.append(o); + } + return arr; +} + +template +bool boolKeysFromJson(const QJsonValue& v, QVector& out) { + out.clear(); + if (!v.isArray()) return true; + const QJsonArray arr = v.toArray(); + out.reserve(arr.size()); + for (const auto& it : arr) { + if (!it.isObject()) continue; + const QJsonObject o = it.toObject(); + KeyT k; + k.frame = o.value("frame").toInt(0); + k.value = o.value("value").toBool(true); + out.push_back(k); + } + return true; +} + +bool vec2KeysFromJson(const QJsonValue& v, QVector& out) { + out.clear(); + if (!v.isArray()) return true; + const QJsonArray arr = v.toArray(); + out.reserve(arr.size()); + for (const auto& it : arr) { + if (!it.isObject()) continue; + const QJsonObject o = it.toObject(); + Project::Entity::KeyframeVec2 k; + k.frame = o.value("frame").toInt(0); + k.value = QPointF(o.value("x").toDouble(0.0), o.value("y").toDouble(0.0)); + out.push_back(k); + } + return true; +} + +bool floatKeysFromJson(const QJsonValue& v, QVector& out) { + out.clear(); + if (!v.isArray()) return true; + const QJsonArray arr = v.toArray(); + out.reserve(arr.size()); + for (const auto& it : arr) { + if (!it.isObject()) continue; + const QJsonObject o = it.toObject(); + Project::Entity::KeyframeFloat01 k; + k.frame = o.value("frame").toInt(0); + k.value = o.value("value").toDouble(0.5); + out.push_back(k); + } + return true; +} + +bool doubleKeysFromJson(const QJsonValue& v, QVector& out) { + out.clear(); + if (!v.isArray()) return true; + const QJsonArray arr = v.toArray(); + out.reserve(arr.size()); + for (const auto& it : arr) { + if (!it.isObject()) continue; + const QJsonObject o = it.toObject(); + Project::Entity::KeyframeDouble k; + k.frame = o.value("frame").toInt(0); + k.value = o.value("value").toDouble(1.0); + out.push_back(k); + } + return true; +} + +bool imageFramesFromJson(const QJsonValue& v, QVector& out) { + out.clear(); + if (!v.isArray()) return true; + const QJsonArray arr = v.toArray(); + out.reserve(arr.size()); + for (const auto& it : arr) { + if (!it.isObject()) continue; + const QJsonObject o = it.toObject(); + Project::Entity::ImageFrame k; + k.frame = o.value("frame").toInt(0); + k.imagePath = o.value("path").toString(); + out.push_back(k); + } + return true; +} + +QJsonObject introToJson(const core::EntityIntroContent& intro) { + QJsonObject o; + o.insert("title", intro.title); + o.insert("bodyText", intro.bodyText); + o.insert("videoPathRelative", intro.videoPathRelative); + QJsonArray imgs; + for (const auto& p : intro.imagePathsRelative) imgs.append(p); + o.insert("imagePathsRelative", imgs); + return o; +} + +bool introFromJson(const QJsonObject& o, core::EntityIntroContent& out) { + out = core::EntityIntroContent{}; + out.title = o.value("title").toString(); + out.bodyText = o.value("bodyText").toString(); + out.videoPathRelative = o.value("videoPathRelative").toString(); + const QJsonValue imgsVal = o.value("imagePathsRelative"); + if (imgsVal.isArray()) { + for (const auto& it : imgsVal.toArray()) { + const QString p = it.toString(); + if (!p.isEmpty()) out.imagePathsRelative.push_back(p); + } + } + return true; +} + +} // namespace + +QString EntityBundleStore::bundleRelativeDir(const QString& entityId) { + if (entityId.isEmpty()) return {}; + return QStringLiteral("assets/entities/%1").arg(entityId); +} + +bool EntityBundleStore::save(const QString& projectDirAbs, const Project::Entity& entity) { + if (projectDirAbs.isEmpty() || entity.id.isEmpty()) { + return false; + } + const QString relDir = bundleRelativeDir(entity.id); + if (relDir.isEmpty()) return false; + const QString absDir = QDir(projectDirAbs).filePath(relDir); + QDir().mkpath(absDir); + + // meta.json + QJsonObject meta; + meta.insert("id", entity.id); + meta.insert("displayName", entity.displayName); + meta.insert("visible", entity.visible); + meta.insert("originX", entity.originWorld.x()); + meta.insert("originY", entity.originWorld.y()); + meta.insert("depth", entity.depth); + meta.insert("priority", entity.priority); + meta.insert("imagePath", entity.imagePath); + meta.insert("imageTopLeftX", entity.imageTopLeftWorld.x()); + meta.insert("imageTopLeftY", entity.imageTopLeftWorld.y()); + meta.insert("userScale", entity.userScale); + meta.insert("distanceScaleCalibMult", entity.distanceScaleCalibMult); + meta.insert("ignoreDistanceScale", entity.ignoreDistanceScale); + meta.insert("parentId", entity.parentId); + meta.insert("parentOffsetX", entity.parentOffsetWorld.x()); + meta.insert("parentOffsetY", entity.parentOffsetWorld.y()); + + meta.insert("polygonLocal", pointsToJson(entity.polygonLocal)); + meta.insert("cutoutPolygonWorld", pointsToJson(entity.cutoutPolygonWorld)); + + meta.insert("blackholeId", entity.blackholeId); + meta.insert("blackholeVisible", entity.blackholeVisible); + meta.insert("blackholeResolvedBy", entity.blackholeResolvedBy); + meta.insert("blackholeOverlayPath", entity.blackholeOverlayPath); + meta.insert("blackholeOverlayRectX", entity.blackholeOverlayRect.x()); + meta.insert("blackholeOverlayRectY", entity.blackholeOverlayRect.y()); + meta.insert("blackholeOverlayRectW", entity.blackholeOverlayRect.width()); + meta.insert("blackholeOverlayRectH", entity.blackholeOverlayRect.height()); + + if (!writeJsonAtomic(QDir(absDir).filePath(QStringLiteral("meta.json")), meta, true)) { + return false; + } + + // anim.json + QJsonObject anim; + anim.insert("locationKeys", vec2KeysToJson(entity.locationKeys)); + anim.insert("depthScaleKeys", floatKeysToJson(entity.depthScaleKeys)); + anim.insert("userScaleKeys", doubleKeysToJson(entity.userScaleKeys)); + anim.insert("visibilityKeys", boolKeysToJson(entity.visibilityKeys)); + anim.insert("imageFrames", imageFramesToJson(entity.imageFrames)); + if (!writeJsonAtomic(QDir(absDir).filePath(QStringLiteral("anim.json")), anim, true)) { + return false; + } + + // intro.json + if (!writeJsonAtomic(QDir(absDir).filePath(QStringLiteral("intro.json")), introToJson(entity.intro), true)) { + return false; + } + + // default.png (可为空) + const QString pngAbs = QDir(absDir).filePath(QStringLiteral("default.png")); + if (!entity.defaultImagePng.isEmpty()) { + if (!writeBytesAtomic(pngAbs, entity.defaultImagePng)) { + return false; + } + } else { + // 清空时删掉,避免读到旧图 + if (QFileInfo::exists(pngAbs)) { + QFile::remove(pngAbs); + } + } + + return true; +} + +bool EntityBundleStore::load(const QString& projectDirAbs, Project::Entity& inOutEntity) { + if (projectDirAbs.isEmpty() || inOutEntity.id.isEmpty()) { + return false; + } + const QString relDir = inOutEntity.entityPayloadPath; + if (relDir.isEmpty()) return false; + const QString absDir = QDir(projectDirAbs).filePath(relDir); + if (!QFileInfo(absDir).exists()) return false; + + QJsonObject meta; + if (!readJsonObject(QDir(absDir).filePath(QStringLiteral("meta.json")), meta)) { + return false; + } + const QString id = meta.value("id").toString(); + if (id.isEmpty() || id != inOutEntity.id) { + return false; + } + + Project::Entity e = inOutEntity; // 保留 stub:id/payloadPath/visible 等必要字段会被覆盖 + e.displayName = meta.value("displayName").toString(); + e.visible = meta.value("visible").toBool(true); + e.originWorld = QPointF(meta.value("originX").toDouble(0.0), meta.value("originY").toDouble(0.0)); + e.depth = meta.value("depth").toInt(0); + e.priority = meta.value("priority").toInt(1); + e.imagePath = meta.value("imagePath").toString(); + e.imageTopLeftWorld = QPointF(meta.value("imageTopLeftX").toDouble(0.0), meta.value("imageTopLeftY").toDouble(0.0)); + e.userScale = meta.value("userScale").toDouble(1.0); + e.distanceScaleCalibMult = meta.value("distanceScaleCalibMult").toDouble(0.0); + e.ignoreDistanceScale = meta.value("ignoreDistanceScale").toBool(false); + e.parentId = meta.value("parentId").toString(); + e.parentOffsetWorld = QPointF(meta.value("parentOffsetX").toDouble(0.0), meta.value("parentOffsetY").toDouble(0.0)); + + pointsFromJson(meta.value("polygonLocal"), e.polygonLocal); + pointsFromJson(meta.value("cutoutPolygonWorld"), e.cutoutPolygonWorld); + + e.blackholeId = meta.value("blackholeId").toString(); + e.blackholeVisible = meta.value("blackholeVisible").toBool(true); + e.blackholeResolvedBy = meta.value("blackholeResolvedBy").toString(); + e.blackholeOverlayPath = meta.value("blackholeOverlayPath").toString(); + e.blackholeOverlayRect = QRect(meta.value("blackholeOverlayRectX").toInt(0), + meta.value("blackholeOverlayRectY").toInt(0), + meta.value("blackholeOverlayRectW").toInt(0), + meta.value("blackholeOverlayRectH").toInt(0)); + + QJsonObject anim; + if (!readJsonObject(QDir(absDir).filePath(QStringLiteral("anim.json")), anim)) { + return false; + } + vec2KeysFromJson(anim.value("locationKeys"), e.locationKeys); + floatKeysFromJson(anim.value("depthScaleKeys"), e.depthScaleKeys); + doubleKeysFromJson(anim.value("userScaleKeys"), e.userScaleKeys); + boolKeysFromJson(anim.value("visibilityKeys"), e.visibilityKeys); + imageFramesFromJson(anim.value("imageFrames"), e.imageFrames); + + QJsonObject intro; + if (!readJsonObject(QDir(absDir).filePath(QStringLiteral("intro.json")), intro)) { + return false; + } + introFromJson(intro, e.intro); + + // default.png + const QString pngAbs = QDir(absDir).filePath(QStringLiteral("default.png")); + if (QFileInfo::exists(pngAbs)) { + QFile f(pngAbs); + if (f.open(QIODevice::ReadOnly)) { + e.defaultImagePng = f.readAll(); + } else { + return false; + } + } else { + e.defaultImagePng.clear(); + } + + inOutEntity = std::move(e); + return true; +} + +} // namespace core::persistence + diff --git a/client/core/persistence/EntityBundleStore.h b/client/core/persistence/EntityBundleStore.h new file mode 100644 index 0000000..5438afb --- /dev/null +++ b/client/core/persistence/EntityBundleStore.h @@ -0,0 +1,22 @@ +#pragma once + +#include "domain/Project.h" + +#include + +namespace core::persistence { + +/// 实体 payload 的目录 bundle 结构(v7 起): +/// assets/entities// +/// meta.json // 静态字段(几何/层级/黑洞等) +/// anim.json // 关键帧轨道(location/depthScale/userScale/visibility/priority/sprite frames) +/// intro.json // intro +/// default.png // 默认贴图(可为空) +struct EntityBundleStore { + static QString bundleRelativeDir(const QString& entityId); + + static bool save(const QString& projectDirAbs, const Project::Entity& entity); + static bool load(const QString& projectDirAbs, Project::Entity& inOutEntity); +}; + +} // namespace core::persistence diff --git a/client/core/persistence/EntityPayloadBinary.cpp b/client/core/persistence/EntityPayloadBinary.cpp index f6de3ef..7234e77 100644 --- a/client/core/persistence/EntityPayloadBinary.cpp +++ b/client/core/persistence/EntityPayloadBinary.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -235,7 +236,9 @@ public: const Project::Entity& entity = *m_src; ds << entity.id; ds << qint32(entity.depth); + ds << qint32(entity.priority); ds << entity.imagePath; + ds << entity.defaultImagePng; ds << double(entity.originWorld.x()) << double(entity.originWorld.y()); ds << double(entity.imageTopLeftWorld.x()) << double(entity.imageTopLeftWorld.y()); @@ -249,7 +252,6 @@ public: ds << double(pt.x()) << double(pt.y()); } - writeAnimationBlock(ds, entity, true); ds << entity.displayName << double(entity.userScale) << double(entity.distanceScaleCalibMult); ds << bool(entity.ignoreDistanceScale); ds << entity.parentId; @@ -267,14 +269,65 @@ public: : entity.blackholeId; ds << holeId; ds << entity.blackholeResolvedBy; + ds << entity.blackholeOverlayPath; + ds << qint32(entity.blackholeOverlayRect.x()) << qint32(entity.blackholeOverlayRect.y()) + << qint32(entity.blackholeOverlayRect.width()) << qint32(entity.blackholeOverlayRect.height()); } bool readBody(QDataStream& ds) override { Q_ASSERT(m_dst != nullptr); + const quint32 fileVer = loadedRecordFormatVersion(); Project::Entity tmp; - if (!readEntityPayloadV1(ds, tmp, true)) { + ds >> tmp.id; + qint32 depth = 0; + ds >> depth; + tmp.depth = static_cast(depth); + qint32 pri = 1; + if (fileVer >= 12) { + ds >> pri; + } + tmp.priority = int(pri); + ds >> tmp.imagePath; + if (fileVer >= 13) { + ds >> tmp.defaultImagePng; + } else { + tmp.defaultImagePng.clear(); + } + double ox = 0.0; + double oy = 0.0; + double itlx = 0.0; + double itly = 0.0; + ds >> ox >> oy >> itlx >> itly; + tmp.originWorld = QPointF(ox, oy); + tmp.imageTopLeftWorld = QPointF(itlx, itly); + + qint32 nLocal = 0; + ds >> nLocal; + if (ds.status() != QDataStream::Ok || nLocal < 0 || nLocal > 1000000) return false; + tmp.polygonLocal.reserve(nLocal); + for (qint32 i = 0; i < nLocal; ++i) { + double x = 0.0; + double y = 0.0; + ds >> x >> y; + if (ds.status() != QDataStream::Ok) return false; + tmp.polygonLocal.push_back(QPointF(x, y)); + } + + qint32 nCut = 0; + ds >> nCut; + if (ds.status() != QDataStream::Ok || nCut < 0 || nCut > 1000000) return false; + tmp.cutoutPolygonWorld.reserve(nCut); + for (qint32 i = 0; i < nCut; ++i) { + double x = 0.0; + double y = 0.0; + ds >> x >> y; + if (ds.status() != QDataStream::Ok) return false; + tmp.cutoutPolygonWorld.push_back(QPointF(x, y)); + } + if (tmp.id.isEmpty() || tmp.polygonLocal.isEmpty()) { return false; } + QString dn; double us = 1.0; double cal = 0.0; @@ -298,27 +351,23 @@ public: tmp.parentOffsetWorld = QPointF(pox, poy); // v7:实体可见性关键帧 + tmp.visibilityKeys.clear(); qint32 nVis = 0; ds >> nVis; - if (ds.status() != QDataStream::Ok) { + if (ds.status() != QDataStream::Ok || nVis < 0 || nVis > 1000000) { return false; } - tmp.visibilityKeys.clear(); - if (nVis > 0) { - tmp.visibilityKeys.reserve(nVis); - for (qint32 i = 0; i < nVis; ++i) { - qint32 fr = 0; - bool val = true; - ds >> fr >> val; - if (ds.status() != QDataStream::Ok) { - return false; - } - core::Project::ToolKeyframeBool k; - k.frame = int(fr); - k.value = val; - tmp.visibilityKeys.push_back(k); + tmp.visibilityKeys.reserve(nVis); + for (qint32 i = 0; i < nVis; ++i) { + qint32 fr = 0; + bool vis = true; + ds >> fr >> vis; + if (ds.status() != QDataStream::Ok) { + return false; } + tmp.visibilityKeys.push_back(Project::ToolKeyframeBool{int(fr), vis}); } + if (!readIntroBlock(ds, tmp.intro)) { return false; } @@ -332,6 +381,17 @@ public: tmp.blackholeVisible = holeVisible; tmp.blackholeId = holeId.isEmpty() ? QStringLiteral("blackhole-%1").arg(tmp.id) : holeId; tmp.blackholeResolvedBy = resolvedBy; + QString overlayPath; + qint32 rectX = 0; + qint32 rectY = 0; + qint32 ow = 0; + qint32 oh = 0; + ds >> overlayPath >> rectX >> rectY >> ow >> oh; + if (ds.status() != QDataStream::Ok) { + return false; + } + tmp.blackholeOverlayPath = overlayPath; + tmp.blackholeOverlayRect = QRect(int(rectX), int(rectY), int(ow), int(oh)); *m_dst = std::move(tmp); return true; } @@ -376,126 +436,7 @@ bool EntityPayloadBinary::save(const QString& absolutePath, const Project::Entit } bool EntityPayloadBinary::load(const QString& absolutePath, Project::Entity& entity) { - QFile f(absolutePath); - if (!f.open(QIODevice::ReadOnly)) { - return false; - } - QDataStream ds(&f); - ds.setVersion(QDataStream::Qt_5_15); - quint32 magic = 0; - quint32 ver = 0; - ds >> magic >> ver; - if (ds.status() != QDataStream::Ok || magic != kMagicPayload) { - return false; - } - if (ver != 1 && ver != 2 && ver != 3 && ver != 4 && ver != 5 && ver != 6 && ver != 7 && ver != 8 && ver != 9) { - return false; - } - Project::Entity tmp; - if (!readEntityPayloadV1(ds, tmp, ver >= 3)) { - return false; - } - if (ver >= 2) { - QString dn; - double us = 1.0; - ds >> dn >> us; - if (ds.status() != QDataStream::Ok) { - return false; - } - tmp.displayName = dn; - tmp.userScale = std::clamp(us, 1e-3, 1e3); - if (ver >= 4) { - double cal = 0.0; - ds >> cal; - if (ds.status() != QDataStream::Ok) { - return false; - } - tmp.distanceScaleCalibMult = (cal > 0.0) ? std::clamp(cal, 1e-6, 10.0) : 0.0; - } - if (ver >= 6) { - bool ign = false; - QString pid; - double pox = 0.0; - double poy = 0.0; - ds >> ign >> pid >> pox >> poy; - if (ds.status() != QDataStream::Ok) { - return false; - } - tmp.ignoreDistanceScale = ign; - tmp.parentId = pid; - tmp.parentOffsetWorld = QPointF(pox, poy); - } else { - tmp.ignoreDistanceScale = false; - tmp.parentId.clear(); - tmp.parentOffsetWorld = QPointF(); - } - if (ver >= 7) { - qint32 nVis = 0; - ds >> nVis; - if (ds.status() != QDataStream::Ok) { - return false; - } - tmp.visibilityKeys.clear(); - if (nVis > 0) { - tmp.visibilityKeys.reserve(nVis); - for (qint32 i = 0; i < nVis; ++i) { - qint32 fr = 0; - bool val = true; - ds >> fr >> val; - if (ds.status() != QDataStream::Ok) { - return false; - } - core::Project::ToolKeyframeBool k; - k.frame = int(fr); - k.value = val; - tmp.visibilityKeys.push_back(k); - } - } - } else { - tmp.visibilityKeys.clear(); - } - if (ver >= 5) { - if (!readIntroBlock(ds, tmp.intro)) { - return false; - } - } - if (ver >= 8) { - bool holeVisible = true; - QString holeId; - ds >> holeVisible >> holeId; - if (ds.status() != QDataStream::Ok) { - return false; - } - tmp.blackholeVisible = holeVisible; - tmp.blackholeId = holeId.isEmpty() ? QStringLiteral("blackhole-%1").arg(tmp.id) : holeId; - if (ver >= 9) { - QString resolvedBy; - ds >> resolvedBy; - if (ds.status() != QDataStream::Ok) { - return false; - } - tmp.blackholeResolvedBy = resolvedBy; - } else { - tmp.blackholeResolvedBy = QStringLiteral("pending"); - } - } else { - tmp.blackholeVisible = true; - tmp.blackholeId = QStringLiteral("blackhole-%1").arg(tmp.id); - tmp.blackholeResolvedBy = QStringLiteral("pending"); - } - } else { - tmp.displayName.clear(); - tmp.userScale = 1.0; - tmp.ignoreDistanceScale = false; - tmp.parentId.clear(); - tmp.parentOffsetWorld = QPointF(); - tmp.visibilityKeys.clear(); - tmp.blackholeVisible = true; - tmp.blackholeId = QStringLiteral("blackhole-%1").arg(tmp.id); - tmp.blackholeResolvedBy = QStringLiteral("pending"); - } - entity = std::move(tmp); - return true; + return EntityBinaryRecord(entity).loadFromFile(absolutePath); } bool EntityPayloadBinary::loadLegacyAnimFile(const QString& absolutePath, Project::Entity& entity) { diff --git a/client/core/persistence/EntityPayloadBinary.h b/client/core/persistence/EntityPayloadBinary.h index aa716b0..1575b28 100644 --- a/client/core/persistence/EntityPayloadBinary.h +++ b/client/core/persistence/EntityPayloadBinary.h @@ -7,12 +7,12 @@ namespace core { // 实体完整数据(几何 + 贴图路径 + 动画轨道)的二进制格式,与 project.json v2 的 payload 字段对应。 -// 贴图 PNG 仍单独存放在 assets/entities/,本文件不嵌入像素。 +// 默认贴图 PNG 存在本文件中(PNG bytes),不兼容旧路径工程。 // 具体读写通过继承 PersistentBinaryObject 的适配器类完成(见 EntityPayloadBinary.cpp)。 class EntityPayloadBinary { public: static constexpr quint32 kMagicPayload = 0x48464550; // 'HFEP' - static constexpr quint32 kPayloadVersion = 9; // v9:追加 blackholeResolvedBy + static constexpr quint32 kPayloadVersion = 13; // v13:默认贴图存 PNG bytes // 旧版独立动画文件(仍用于打开 v1 项目时合并) static constexpr quint32 kMagicLegacyAnim = 0x48465441; // 'HFTA' diff --git a/client/core/persistence/JsonFileAtomic.cpp b/client/core/persistence/JsonFileAtomic.cpp new file mode 100644 index 0000000..70d9319 --- /dev/null +++ b/client/core/persistence/JsonFileAtomic.cpp @@ -0,0 +1,62 @@ +#include "persistence/JsonFileAtomic.h" + +#include +#include +#include + +namespace core::persistence { + +bool writeBytesAtomic(const QString& absolutePath, const QByteArray& bytes) { + if (absolutePath.isEmpty()) { + return false; + } + const QString parent = QFileInfo(absolutePath).absolutePath(); + if (!QFileInfo(parent).exists()) { + QDir().mkpath(parent); + } + const QString tmpAbs = absolutePath + QStringLiteral(".tmp"); + if (QFileInfo::exists(tmpAbs)) { + QFile::remove(tmpAbs); + } + QFile f(tmpAbs); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + return false; + } + const bool ok = f.write(bytes) == bytes.size(); + f.close(); + if (!ok || f.error() != QFile::NoError) { + QFile::remove(tmpAbs); + return false; + } + QFile::remove(absolutePath); + if (!QFile::rename(tmpAbs, absolutePath)) { + QFile::remove(tmpAbs); + return false; + } + return true; +} + +bool writeJsonAtomic(const QString& absolutePath, const QJsonObject& obj, bool pretty) { + const QJsonDocument doc(obj); + const QByteArray bytes = doc.toJson(pretty ? QJsonDocument::Indented : QJsonDocument::Compact); + return writeBytesAtomic(absolutePath, bytes); +} + +bool readJsonObject(const QString& absolutePath, QJsonObject& out) { + out = QJsonObject(); + QFile f(absolutePath); + if (!f.open(QIODevice::ReadOnly)) { + return false; + } + const QByteArray data = f.readAll(); + QJsonParseError err; + const QJsonDocument doc = QJsonDocument::fromJson(data, &err); + if (err.error != QJsonParseError::NoError || !doc.isObject()) { + return false; + } + out = doc.object(); + return true; +} + +} // namespace core::persistence + diff --git a/client/core/persistence/JsonFileAtomic.h b/client/core/persistence/JsonFileAtomic.h new file mode 100644 index 0000000..8f88965 --- /dev/null +++ b/client/core/persistence/JsonFileAtomic.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include +#include +#include + +namespace core::persistence { + +/// 原子写 JSON:写入 .tmp 后 rename 覆盖,避免截断导致下次打开“损坏”。 +bool writeJsonAtomic(const QString& absolutePath, const QJsonObject& obj, bool pretty = true); + +/// 读取 JSON 文件(必须为 object)。失败返回 false。 +bool readJsonObject(const QString& absolutePath, QJsonObject& out); + +/// 原子写 bytes:写入 .tmp 后 rename 覆盖。 +bool writeBytesAtomic(const QString& absolutePath, const QByteArray& bytes); + +} // namespace core::persistence + diff --git a/client/core/persistence/PersistentBinaryObject.cpp b/client/core/persistence/PersistentBinaryObject.cpp index 4193fa6..22f9627 100644 --- a/client/core/persistence/PersistentBinaryObject.cpp +++ b/client/core/persistence/PersistentBinaryObject.cpp @@ -48,9 +48,11 @@ bool PersistentBinaryObject::loadFromFile(const QString& absolutePath) { quint32 magic = 0; quint32 version = 0; ds >> magic >> version; - if (ds.status() != QDataStream::Ok || magic != recordMagic() || version != recordFormatVersion()) { + const quint32 cur = recordFormatVersion(); + if (ds.status() != QDataStream::Ok || magic != recordMagic() || version < 1 || version > cur) { return false; } + m_loadedVersion = version; return readBody(ds); } diff --git a/client/core/persistence/PersistentBinaryObject.h b/client/core/persistence/PersistentBinaryObject.h index 4c336f9..5da9f4c 100644 --- a/client/core/persistence/PersistentBinaryObject.h +++ b/client/core/persistence/PersistentBinaryObject.h @@ -22,6 +22,12 @@ protected: virtual quint32 recordFormatVersion() const = 0; virtual void writeBody(QDataStream& ds) const = 0; virtual bool readBody(QDataStream& ds) = 0; + + /// 读取文件时解析到的版本号(<= recordFormatVersion)。用于向后兼容读取分支。 + [[nodiscard]] quint32 loadedRecordFormatVersion() const { return m_loadedVersion; } + +private: + quint32 m_loadedVersion = 0; }; } // namespace core diff --git a/client/core/workspace/ProjectWorkspace.cpp b/client/core/workspace/ProjectWorkspace.cpp index 57796dc..e1aeb9c 100644 --- a/client/core/workspace/ProjectWorkspace.cpp +++ b/client/core/workspace/ProjectWorkspace.cpp @@ -1,7 +1,19 @@ +/* +*/ + #include "workspace/ProjectWorkspace.h" +#include "image/ImageDecodeConfig.h" +#include "image/ImageFileLoader.h" +#include "large_image/VipsBackend.h" +#include "animation/AnimationEvaluator.h" #include "animation/AnimationSampling.h" #include "eval/ProjectEvaluator.h" +#include "persistence/AnimationBinary.h" #include "persistence/EntityPayloadBinary.h" +#include "persistence/AnimationBundleStore.h" +#include "persistence/EntityBundleStore.h" +#include "persistence/JsonFileAtomic.h" +#include "persistence/AsyncProjectWriter.h" #include "depth/DepthService.h" #include "net/ModelServerClient.h" @@ -13,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -22,13 +35,140 @@ #include #include #include +#include #include +#include namespace core { namespace { +static bool isNoneAnimationActive(const Project& project) { + return project.activeAnimationId() == QStringLiteral("none"); +} + +static bool presentationHotspotsEqual(const QVector& a, + const QVector& b) { + if (a.size() != b.size()) + return false; + for (int i = 0; i < a.size(); ++i) { + if (a[i].id != b[i].id || a[i].targetAnimationId != b[i].targetAnimationId) + return false; + if (!qFuzzyCompare(static_cast(a[i].centerWorld.x()), + static_cast(b[i].centerWorld.x())) || + !qFuzzyCompare(static_cast(a[i].centerWorld.y()), + static_cast(b[i].centerWorld.y()))) { + return false; + } + } + return true; +} + +static QString firstNonNoneAnimationId(const Project& p) { + for (const auto& a : p.animations()) { + if (a.id != QStringLiteral("none")) + return a.id; + } + return {}; +} + +/// 移除热点里指向不存在动画方案的 targetAnimationId(回退为第一项非 none,若无则清空) +static void sanitizePresentationHotspotTargets(Project& p) { + const QString fb = firstNonNoneAnimationId(p); + QVector hs = p.presentationHotspots(); + bool changed = false; + for (auto& h : hs) { + if (h.targetAnimationId.isEmpty() || h.targetAnimationId == QStringLiteral("none")) + continue; + if (!p.findAnimationById(h.targetAnimationId)) { + h.targetAnimationId = fb; + changed = true; + } + } + if (changed) + p.setPresentationHotspots(hs); +} + +QString sanitizedEntityIdForAsset(const QString& id) { + QString s = id; + const QString bad = QStringLiteral("\\/:*?\"<>|"); + for (QChar c : bad) { + s.replace(c, QLatin1Char('_')); + } + return s.isEmpty() ? QStringLiteral("entity") : s; +} + +bool writeBlackholeOverlayPng(const QString& projectDir, const QString& entityId, + const QString& previousOverlayRel, const QImage& patch, + const QRect& rectBg, QString* outRelativePath) { + if (projectDir.isEmpty() || patch.isNull() || !rectBg.isValid() || rectBg.width() <= 0 || + rectBg.height() <= 0 || !outRelativePath) { + return false; + } + QImage p = patch; + if (p.size() != rectBg.size()) { + p = p.scaled(rectBg.size(), Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + } + if (p.format() != QImage::Format_ARGB32_Premultiplied) { + p = p.convertToFormat(QImage::Format_ARGB32_Premultiplied); + } + + const QString rel = + QStringLiteral("assets/blackhole_overlays/overlay-%1.png").arg(sanitizedEntityIdForAsset(entityId)); + const QString abs = QDir(projectDir).filePath(rel); + QDir().mkpath(QFileInfo(abs).absolutePath()); + + if (!previousOverlayRel.isEmpty() && previousOverlayRel != rel) { + QFile::remove(QDir(projectDir).filePath(previousOverlayRel)); + } + + QImageWriter writer(abs); + writer.setFormat(QByteArrayLiteral("png")); + writer.setCompression(1); + if (!writer.write(p)) { + return false; + } + *outRelativePath = rel; + return true; +} + +bool entityStaticEquals(const Project::Entity& a, const Project::Entity& b) { + return a.id == b.id && + a.displayName == b.displayName && + a.visible == b.visible && + a.polygonLocal == b.polygonLocal && + a.cutoutPolygonWorld == b.cutoutPolygonWorld && + a.blackholeId == b.blackholeId && + a.blackholeVisible == b.blackholeVisible && + a.blackholeResolvedBy == b.blackholeResolvedBy && + a.blackholeOverlayPath == b.blackholeOverlayPath && + a.blackholeOverlayRect == b.blackholeOverlayRect && + a.originWorld == b.originWorld && + a.depth == b.depth && + a.priority == b.priority && + a.imagePath == b.imagePath && + a.imageTopLeftWorld == b.imageTopLeftWorld && + qFuzzyCompare(a.userScale + 1.0, b.userScale + 1.0) && + qFuzzyCompare(a.distanceScaleCalibMult + 1.0, b.distanceScaleCalibMult + 1.0) && + a.ignoreDistanceScale == b.ignoreDistanceScale && + a.parentId == b.parentId && + a.parentOffsetWorld == b.parentOffsetWorld && + a.entityPayloadPath == b.entityPayloadPath && + a.intro.title == b.intro.title && + a.intro.bodyText == b.intro.bodyText && + a.intro.imagePathsRelative == b.intro.imagePathsRelative && + a.intro.videoPathRelative == b.intro.videoPathRelative; +} + +// +void removeBlackholeOverlayFile(const QString& projectDir, const QString& rel) { + if (projectDir.isEmpty() || rel.isEmpty()) { + return; + } + QFile::remove(QDir(projectDir).filePath(rel)); +} + QPointF polygonCentroidFromWorldPoints(const QVector& poly) { if (poly.size() < 3) { return poly.isEmpty() ? QPointF() : poly.front(); @@ -62,17 +202,6 @@ QPointF polygonCentroidFromWorldPoints(const QVector& poly) { return QPointF(cx6a * inv6a, cy6a * inv6a); } -QPointF entityPolygonCentroidWorld(const Project::Entity& e, int frame, double sTotal) { - const QPointF O = - sampleLocation(e.locationKeys, frame, e.originWorld, KeyInterpolation::Linear); - QVector w; - w.reserve(e.polygonLocal.size()); - for (const QPointF& lp : e.polygonLocal) { - w.push_back(O + lp * sTotal); - } - return polygonCentroidFromWorldPoints(w); -} - QPointF resolvedOriginAtFrame(const Project& project, const QString& id, int frame) { if (id.isEmpty()) { return QPointF(); @@ -91,6 +220,81 @@ QPointF resolvedOriginAtFrame(const Project& project, const QString& id, int fra return QPointF(); } +double depthToScale01Ws(int depthZ) { + const int d = std::clamp(depthZ, 0, 255); + return static_cast(d) / 255.0; +} + +double distanceScaleFromDepth01Ws(double depth01, double calibMult) { + const double d = std::clamp(depth01, 0.0, 1.0); + const double raw = 0.5 + d * 1.0; + if (calibMult > 0.0) { + return raw / std::max(calibMult, 1e-6); + } + return raw; +} + +// scale = distanceScale * userScale +double combinedVisualScaleForEntity(const Project::Entity& e, int frame) { + const double ds01 = depthToScale01Ws(e.depth); + const double userScaleAnimated = + core::sampleUserScale(e.userScaleKeys, frame, e.userScale, core::KeyInterpolation::Linear); + const double distScale = + e.ignoreDistanceScale ? 1.0 : distanceScaleFromDepth01Ws(ds01, e.distanceScaleCalibMult); + return distScale * std::max(1e-6, userScaleAnimated); +} + +QPointF entityPolygonCentroidWorld(const Project& project, const Project::Entity& e, int frame, double sTotal) { + const QPointF O = resolvedOriginAtFrame(project, e.id, frame); + QVector w; + w.reserve(e.polygonLocal.size()); + for (const QPointF& lp : e.polygonLocal) { + w.push_back(O + lp * sTotal); + } + return polygonCentroidFromWorldPoints(w); +} + +namespace { + +/// 将采样原点与多边形形心对齐;保持世界几何与贴图不变(与 reanchorEntityPivot 同类变换)。 +/// 有父级时跳过:基准量分布在 parentOffset/轨道上,需单独处理链式更新。 +bool snapEntityOriginToCentroidInPlace(const Project& project, Project::Entity& e, int frame, double sTotal) { + if (!e.parentId.isEmpty()) { + return false; + } + if (e.polygonLocal.size() < 3 || sTotal <= 1e-9) { + return false; + } + const QPointF O_anim = resolvedOriginAtFrame(project, e.id, frame); + QVector polyWorld; + polyWorld.reserve(e.polygonLocal.size()); + for (const QPointF& lp : e.polygonLocal) { + polyWorld.push_back(O_anim + lp * sTotal); + } + const QPointF C = polygonCentroidFromWorldPoints(polyWorld); + if (QLineF(O_anim, C).length() < 0.25) { + return false; + } + const QPointF I_disp = O_anim + (e.imageTopLeftWorld - e.originWorld) * sTotal; + const QPointF d = C - O_anim; + + QVector newLocal; + newLocal.reserve(polyWorld.size()); + for (const QPointF& p : polyWorld) { + newLocal.push_back((p - C) / sTotal); + } + + for (auto& k : e.locationKeys) { + k.value += d; + } + e.originWorld += d; + e.polygonLocal = std::move(newLocal); + e.imageTopLeftWorld = e.originWorld + (I_disp - C) / sTotal; + return true; +} + +} // namespace + QString ensureDir(const QString& path) { QDir dir(path); @@ -156,16 +360,6 @@ QRect clampRectToImage(const QRect& rect, const QSize& size) { return r; } -static const Project::NlaStrip* findStripById(const Project::AnimationScheme& scheme, const QString& stripId) { - if (stripId.isEmpty()) return nullptr; - for (const auto& tr : scheme.tracks) { - for (const auto& st : tr.strips) { - if (st.id == stripId) return &st; - } - } - return nullptr; -} - static void upsertBoolKey(QVector& keys, int frame, bool value) { for (auto& k : keys) { if (k.frame == frame) { @@ -179,23 +373,170 @@ static void upsertBoolKey(QVector& keys, int frame, b keys.push_back(k); } -static Project::AnimationClip* activeClipOrNull(Project& project) { - auto* scheme = project.activeSchemeOrNull(); - if (!scheme) return nullptr; - const Project::NlaStrip* st = findStripById(*scheme, project.selectedStripId()); - if (!st) { - for (const auto& tr : scheme->tracks) { - for (const auto& s : tr.strips) { - if (s.enabled && !s.muted) { - st = &s; - break; - } - } - if (st) break; +static QString animationTrackId(Project::AnimationTargetKind kind, + const QString& targetId, + Project::AnimationProperty property) { + auto kindName = [](Project::AnimationTargetKind k) { + switch (k) { + case Project::AnimationTargetKind::Entity: return QStringLiteral("entity"); + case Project::AnimationTargetKind::Tool: return QStringLiteral("tool"); + case Project::AnimationTargetKind::Camera: return QStringLiteral("camera"); + } + return QStringLiteral("entity"); + }; + auto propName = [](Project::AnimationProperty p) { + switch (p) { + case Project::AnimationProperty::Position: return QStringLiteral("position"); + case Project::AnimationProperty::UserScale: return QStringLiteral("userScale"); + case Project::AnimationProperty::Sprite: return QStringLiteral("sprite"); + case Project::AnimationProperty::Visibility: return QStringLiteral("visibility"); + case Project::AnimationProperty::DepthScale: return QStringLiteral("depthScale"); + case Project::AnimationProperty::CameraScale: return QStringLiteral("cameraScale"); + case Project::AnimationProperty::Priority: return QStringLiteral("priority"); + } + return QStringLiteral("property"); + }; + return kindName(kind) + QStringLiteral(":") + targetId + QStringLiteral(":") + propName(property); +} + +static Project::Animation* activeAnimationOrCreate(Project& project) { + if (Project::Animation* existing = project.activeAnimationOrNull()) { + return existing; + } + + // 默认动画:无(静态),仅 1 帧。其存在用于“动画系统外”的静态编辑基底。 + Project::Animation none; + none.id = QStringLiteral("none"); + none.name = QStringLiteral("无"); + none.fps = std::max(1, project.fps()); + none.lengthFrames = 1; + none.loop = false; + project.setAnimations({none}); + project.setActiveAnimationId(none.id); + return project.activeAnimationOrNull(); +} + +static Project::AnimationTrack* findOrCreateTrack(Project::Animation& anim, + Project::AnimationTargetKind kind, + const QString& targetId, + Project::AnimationProperty property, + Project::AnimationValueType valueType, + Project::AnimationInterpolation interpolation) { + for (auto& t : anim.tracks) { + if (t.targetKind == kind && t.targetId == targetId && t.property == property) { + t.valueType = valueType; + t.interpolation = interpolation; + return &t; } } - if (!st) return nullptr; - return project.findClipById(st->clipId); + + Project::AnimationTrack t; + t.id = animationTrackId(kind, targetId, property); + t.targetKind = kind; + t.targetId = targetId; + t.property = property; + t.valueType = valueType; + t.interpolation = interpolation; + anim.tracks.push_back(t); + return &anim.tracks.last(); +} + +static void upsertAnimKey(Project::AnimationTrack& track, int frame, const QPointF& value) { + for (auto& k : track.keys) { + if (k.frame == frame) { + k.vec2Value = value; + return; + } + } + Project::AnimationKey k; + k.frame = frame; + k.vec2Value = value; + track.keys.push_back(k); +} + +static void upsertAnimKey(Project::AnimationTrack& track, int frame, double value) { + for (auto& k : track.keys) { + if (k.frame == frame) { + k.numberValue = value; + return; + } + } + Project::AnimationKey k; + k.frame = frame; + k.numberValue = value; + track.keys.push_back(k); +} + +static void upsertAnimKey(Project::AnimationTrack& track, int frame, bool value) { + for (auto& k : track.keys) { + if (k.frame == frame) { + k.boolValue = value; + return; + } + } + Project::AnimationKey k; + k.frame = frame; + k.boolValue = value; + track.keys.push_back(k); +} + +static void upsertAnimKey(Project::AnimationTrack& track, int frame, const QString& value) { + for (auto& k : track.keys) { + if (k.frame == frame) { + k.stringValue = value; + return; + } + } + Project::AnimationKey k; + k.frame = frame; + k.stringValue = value; + track.keys.push_back(k); +} + +static void upsertAnimKey(Project::AnimationTrack& track, int frame, const QByteArray& value) { + for (auto& k : track.keys) { + if (k.frame == frame) { + k.bytesValue = value; + return; + } + } + Project::AnimationKey k; + k.frame = frame; + k.bytesValue = value; + track.keys.push_back(k); +} + +static void pruneEmptyAnimationTracks(Project::Animation& anim) { + anim.tracks.erase( + std::remove_if(anim.tracks.begin(), + anim.tracks.end(), + [](const Project::AnimationTrack& track) { + return track.targetId.isEmpty() || track.keys.isEmpty(); + }), + anim.tracks.end()); +} + +/// 平移轨道里存储的偏移:根实体为世界坐标增量,子实体为相对父坐标增量(父未动时与世界增量一致)。 +static bool shiftEntityPositionAnimKeys(QVector& anims, const QString& entityId, const QPointF& delta) { + bool any = false; + for (auto& anim : anims) { + for (auto& t : anim.tracks) { + if (t.targetKind != Project::AnimationTargetKind::Entity || t.targetId != entityId) { + continue; + } + if (t.property != Project::AnimationProperty::Position) { + continue; + } + if (t.valueType != Project::AnimationValueType::Vec2 || t.keys.isEmpty()) { + continue; + } + for (auto& k : t.keys) { + k.vec2Value += delta; + } + any = true; + } + } + return any; } } // namespace @@ -214,6 +555,108 @@ QString ProjectWorkspace::assetsDirPath() const { return QDir(m_projectDir).filePath(QString::fromUtf8(kAssetsDirName)); } +QString ProjectWorkspace::ensureAnimationsDir() const { + const auto assets = assetsDirPath(); + if (assets.isEmpty()) { + return {}; + } + return ensureDir(QDir(assets).filePath(QStringLiteral("animations"))); +} + +QString ProjectWorkspace::defaultAnimationPayloadPath(const QString& animationId) const { + const QString id = sanitizedEntityIdForAsset(animationId.isEmpty() ? QStringLiteral("animation") : animationId); + return core::persistence::AnimationBundleStore::bundleRelativeDir(id); +} + +void ProjectWorkspace::markIndexDirty() { + m_indexDirty = true; +} + +void ProjectWorkspace::markEntityDirty(const QString& id, const Project::Entity* snapshot) { + if (!id.isEmpty()) { + m_dirtyEntityIds.insert(id); + } + // 后台线程落盘 bundle(与 project.json 解耦);显式 save/close 仍会 flush + 写索引。 + if (m_projectDir.isEmpty() || id.isEmpty()) { + return; + } + if (!m_asyncWriter) { + m_asyncWriter = new core::persistence::AsyncProjectWriter(); + m_asyncWriter->setProjectDir(m_projectDir); + } + if (snapshot && snapshot->id == id) { + m_asyncWriter->requestWriteEntity(*snapshot); + return; + } + for (const auto& e : m_project.entities()) { + if (e.id == id) { + m_asyncWriter->requestWriteEntity(e); + break; + } + } +} + +void ProjectWorkspace::markAnimationDirty(const QString& id) { + if (!id.isEmpty()) { + m_dirtyAnimationIds.insert(id); + } + if (m_projectDir.isEmpty() || id.isEmpty()) { + return; + } + if (!m_asyncWriter) { + m_asyncWriter = new core::persistence::AsyncProjectWriter(); + m_asyncWriter->setProjectDir(m_projectDir); + } + for (const auto& a : m_project.animations()) { + if (a.id == id) { + m_asyncWriter->requestWriteAnimation(a); + break; + } + } +} + +void ProjectWorkspace::markAllEntitiesDirty() { + for (const auto& e : m_project.entities()) { + markEntityDirty(e.id); + } +} + +void ProjectWorkspace::markAllAnimationsDirty() { + for (const auto& a : m_project.animations()) { + markAnimationDirty(a.id); + } +} + +void ProjectWorkspace::schedulePayloadSync() { + if (m_projectDir.isEmpty()) { + return; + } + if (!m_payloadSyncTimer) { + m_payloadSyncTimer = new QTimer(); + m_payloadSyncTimer->setSingleShot(true); + QObject::connect(m_payloadSyncTimer, &QTimer::timeout, [this]() { + (void)syncPayloadsToDiskOnce(); + }); + } + m_payloadSyncTimer->start(400); +} + +bool ProjectWorkspace::syncPayloadsToDiskOnce() { + if (m_projectDir.isEmpty()) { + return false; + } + const bool okEnt = syncEntityPayloadsToDisk(); + const bool okAnim = syncAnimationPayloadsToDisk(); + if (!okEnt || !okAnim) { + return false; + } + if (m_asyncWriter) { + m_asyncWriter->flushBlocking(60000); + } + // payload sync 可能修正 payloadPath/entityPayloadPath,需要再写一次 index + return writeIndexJsonWithoutPayloadSync(); +} + QString ProjectWorkspace::backgroundAbsolutePath() const { if (m_projectDir.isEmpty() || m_project.backgroundImagePath().isEmpty()) { return {}; @@ -307,6 +750,10 @@ bool ProjectWorkspace::createNew(const QString& projectDir, const QString& name, if (m_projectDir.isEmpty()) { return false; } + if (!m_asyncWriter) { + m_asyncWriter = new core::persistence::AsyncProjectWriter(); + } + m_asyncWriter->setProjectDir(m_projectDir); const auto rootOk = ensureDir(m_projectDir); if (rootOk.isEmpty()) { @@ -332,37 +779,23 @@ bool ProjectWorkspace::createNew(const QString& projectDir, const QString& name, return false; } - // 初始化默认动画方案/片段(600 帧固定片段) - if (m_project.animationSchemes().isEmpty()) { - Project::AnimationClip clip; - clip.id = QStringLiteral("clip-1"); - clip.name = QStringLiteral("Clip_001"); - m_project.setAnimationClips({clip}); - - Project::NlaStrip strip; - strip.id = QStringLiteral("strip-1"); - strip.clipId = clip.id; - strip.startSlot = 0; - strip.slotLen = 1; - - Project::NlaTrack track; - track.id = QStringLiteral("track-1"); - track.name = QStringLiteral("Track"); - track.strips = {strip}; - - Project::AnimationScheme scheme; - scheme.id = QStringLiteral("scheme-1"); - scheme.name = QStringLiteral("方案_001"); - scheme.tracks = {track}; - - m_project.setAnimationSchemes({scheme}); - m_project.setActiveSchemeId(scheme.id); - m_project.setSelectedStripId(strip.id); - } + // 默认只创建“无”(静态)动画作为基底:仅 1 帧 + Project::Animation none; + none.id = QStringLiteral("none"); + none.name = QStringLiteral("无"); + none.payloadPath = core::persistence::AnimationBundleStore::bundleRelativeDir(none.id); + none.fps = std::max(1, m_project.fps()); + none.lengthFrames = 1; + none.loop = false; + m_project.setAnimations({none}); + m_project.setActiveAnimationId(none.id); + markAnimationDirty(none.id); + markIndexDirty(); if (!ensureDefaultCameraIfMissing(nullptr)) { return false; } + markAllEntitiesDirty(); return writeIndexJson(); } @@ -383,9 +816,9 @@ bool ProjectWorkspace::ensureDefaultCameraIfMissing(bool* outAdded) { const QString bgAbs = backgroundAbsolutePath(); QSize sz; if (!bgAbs.isEmpty() && QFileInfo::exists(bgAbs)) { - QImageReader reader(bgAbs); - reader.setAutoTransform(true); - sz = reader.size(); + if (!image_file::probeImagePixelSize(bgAbs, &sz)) { + sz = QSize(); + } } if (!sz.isValid() || sz.width() < 1 || sz.height() < 1) { c.centerWorld = QPointF(512.0, 384.0); @@ -420,72 +853,23 @@ bool ProjectWorkspace::openExisting(const QString& projectDir) { const QString prevDir = m_projectDir; const Project prevProject = m_project; m_projectDir = dir; + if (!m_asyncWriter) { + m_asyncWriter = new core::persistence::AsyncProjectWriter(); + } + m_asyncWriter->setProjectDir(m_projectDir); if (!readIndexJson(indexPath)) { m_projectDir = prevDir; m_project = prevProject; return false; } + // 不做任何自动迁移/兼容:工程必须是当前版本(v7:目录 bundle payload + index)。 + // readIndexJson 会写入 m_project;这里补齐历史初始化 ensureDir(assetsDirPath()); m_undoStack.clear(); m_redoStack.clear(); - // 旧项目迁移:若无动画方案/片段,则将实体/工具的关键帧迁移到默认 clip,并创建默认 scheme/track/strip - if (m_project.animationSchemes().isEmpty()) { - Project::AnimationClip clip; - clip.id = QStringLiteral("clip-1"); - clip.name = QStringLiteral("Clip_001"); - - auto ents = m_project.entities(); - for (auto& e : ents) { - if (e.id.isEmpty()) continue; - if (!e.locationKeys.isEmpty()) clip.entityLocationKeys.insert(e.id, e.locationKeys); - if (!e.userScaleKeys.isEmpty()) clip.entityUserScaleKeys.insert(e.id, e.userScaleKeys); - if (!e.imageFrames.isEmpty()) clip.entityImageFrames.insert(e.id, e.imageFrames); - if (!e.visibilityKeys.isEmpty()) clip.entityVisibilityKeys.insert(e.id, e.visibilityKeys); - e.locationKeys.clear(); - e.userScaleKeys.clear(); - e.imageFrames.clear(); - e.visibilityKeys.clear(); - } - m_project.setEntities(ents); - - auto tools = m_project.tools(); - for (auto& t : tools) { - if (t.id.isEmpty()) continue; - if (!t.locationKeys.isEmpty()) clip.toolLocationKeys.insert(t.id, t.locationKeys); - if (!t.visibilityKeys.isEmpty()) clip.toolVisibilityKeys.insert(t.id, t.visibilityKeys); - t.locationKeys.clear(); - t.visibilityKeys.clear(); - } - m_project.setTools(tools); - - m_project.setAnimationClips({clip}); - - Project::NlaStrip strip; - strip.id = QStringLiteral("strip-1"); - strip.clipId = clip.id; - strip.startSlot = 0; - strip.slotLen = 1; - - Project::NlaTrack track; - track.id = QStringLiteral("track-1"); - track.name = QStringLiteral("Track"); - track.strips = {strip}; - - Project::AnimationScheme scheme; - scheme.id = QStringLiteral("scheme-1"); - scheme.name = QStringLiteral("方案_001"); - scheme.tracks = {track}; - - m_project.setAnimationSchemes({scheme}); - m_project.setActiveSchemeId(scheme.id); - m_project.setSelectedStripId(strip.id); - - // 迁移后立即落盘,避免后续求值出现双来源 - writeIndexJson(); - } bool cameraAdded = false; if (!ensureDefaultCameraIfMissing(&cameraAdded)) { return false; @@ -497,6 +881,9 @@ bool ProjectWorkspace::openExisting(const QString& projectDir) { } void ProjectWorkspace::close() { + if (m_asyncWriter) { + m_asyncWriter->flushBlocking(15000); + } m_projectDir.clear(); m_project = Project(); m_undoStack.clear(); @@ -510,6 +897,16 @@ bool ProjectWorkspace::save() { return writeIndexJson(); } +bool ProjectWorkspace::saveAll() { + if (m_projectDir.isEmpty()) { + return false; + } + markAllEntitiesDirty(); + markAllAnimationsDirty(); + markIndexDirty(); + return writeIndexJson(); +} + bool ProjectWorkspace::canUndo() const { return !m_undoStack.isEmpty(); } @@ -547,6 +944,30 @@ bool ProjectWorkspace::undo() { m_undoStack.push_back(op); return false; } + } else if (op.type == Operation::Type::SetAnimations) { + const auto snapAnims = m_project.animations(); + const auto snapHs = m_project.presentationHotspots(); + m_project.setAnimations(op.beforeAnimations); + if (op.animBundlesPresentationHotspots) { + m_project.setPresentationHotspots(op.animSnapshotBeforePresentationHotspots); + } + markIndexDirty(); + markAllAnimationsDirty(); + if (!writeIndexJsonWithoutPayloadSync()) { + m_project.setAnimations(snapAnims); + m_project.setPresentationHotspots(snapHs); + m_undoStack.push_back(op); + return false; + } + } else if (op.type == Operation::Type::SetPresentationHotspots) { + const auto cur = m_project.presentationHotspots(); + m_project.setPresentationHotspots(op.beforePresentationHotspots); + markIndexDirty(); + if (!writeIndexJsonWithoutPayloadSync()) { + m_project.setPresentationHotspots(cur); + m_undoStack.push_back(op); + return false; + } } else if (op.type == Operation::Type::SetProjectTitle) { m_project.setName(op.beforeProjectTitle); if (!writeIndexJson()) { @@ -595,6 +1016,30 @@ bool ProjectWorkspace::redo() { m_redoStack.push_back(op); return false; } + } else if (op.type == Operation::Type::SetAnimations) { + const auto snapAnims = m_project.animations(); + const auto snapHs = m_project.presentationHotspots(); + m_project.setAnimations(op.afterAnimations); + if (op.animBundlesPresentationHotspots) { + m_project.setPresentationHotspots(op.animSnapshotAfterPresentationHotspots); + } + markIndexDirty(); + markAllAnimationsDirty(); + if (!writeIndexJsonWithoutPayloadSync()) { + m_project.setAnimations(snapAnims); + m_project.setPresentationHotspots(snapHs); + m_redoStack.push_back(op); + return false; + } + } else if (op.type == Operation::Type::SetPresentationHotspots) { + const auto cur = m_project.presentationHotspots(); + m_project.setPresentationHotspots(op.afterPresentationHotspots); + markIndexDirty(); + if (!writeIndexJsonWithoutPayloadSync()) { + m_project.setPresentationHotspots(cur); + m_redoStack.push_back(op); + return false; + } } else if (op.type == Operation::Type::SetProjectTitle) { m_project.setName(op.afterProjectTitle); if (!writeIndexJson()) { @@ -716,9 +1161,16 @@ bool ProjectWorkspace::importBackgroundImage(const QString& backgroundImageSourc } bool ProjectWorkspace::writeIndexJson() { + // 显式保存:入队 payload → flush → 原子写索引,避免队列未刷完就与索引不一致。 if (!m_projectDir.isEmpty() && !syncEntityPayloadsToDisk()) { return false; } + if (!m_projectDir.isEmpty() && !syncAnimationPayloadsToDisk()) { + return false; + } + if (m_asyncWriter) { + m_asyncWriter->flushBlocking(60000); + } return writeIndexJsonWithoutPayloadSync(); } @@ -726,12 +1178,12 @@ bool ProjectWorkspace::writeIndexJsonWithoutPayloadSync() { const auto root = projectToJson(m_project); QJsonDocument doc(root); - QFile f(indexFilePath()); - if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + const QString destAbs = indexFilePath(); + if (!core::persistence::writeBytesAtomic(destAbs, doc.toJson(QJsonDocument::Indented))) { return false; } - const auto bytes = doc.toJson(QJsonDocument::Indented); - return f.write(bytes) == bytes.size(); + m_indexDirty = false; + return true; } bool ProjectWorkspace::readIndexJson(const QString& indexPath) { @@ -753,13 +1205,15 @@ bool ProjectWorkspace::readIndexJson(const QString& indexPath) { return false; } m_project = p; - if (fileVer == 1) { - loadV1LegacyAnimationSidecars(); - } else { - if (!hydrateEntityPayloadsFromDisk()) { - return false; - } + if (!hydrateEntityPayloadsFromDisk()) { + return false; } + if (!hydrateAnimationPayloadsFromDisk()) { + return false; + } + m_dirtyEntityIds.clear(); + m_dirtyAnimationIds.clear(); + m_indexDirty = false; return true; } @@ -796,109 +1250,30 @@ QJsonObject ProjectWorkspace::projectToJson(const Project& project) { root.insert("cameras", cams); root.insert("activeCameraId", project.activeCameraId()); - // —— 动画(v3)—— - root.insert("activeSchemeId", project.activeSchemeId()); - root.insert("selectedStripId", project.selectedStripId()); - - QJsonArray clips; - for (const auto& c : project.animationClips()) { - QJsonObject co; - co.insert("id", c.id); - co.insert("name", c.name); - - auto encodeVec2 = [](const QVector& keys) { - QJsonArray a; - for (const auto& k : keys) { - QJsonObject o; - o.insert("frame", k.frame); - o.insert("x", k.value.x()); - o.insert("y", k.value.y()); - a.append(o); - } - return a; - }; - auto encodeDouble = [](const QVector& keys) { - QJsonArray a; - for (const auto& k : keys) { - QJsonObject o; - o.insert("frame", k.frame); - o.insert("value", k.value); - a.append(o); - } - return a; - }; - auto encodeImage = [](const QVector& keys) { - QJsonArray a; - for (const auto& k : keys) { - QJsonObject o; - o.insert("frame", k.frame); - o.insert("path", k.imagePath); - a.append(o); - } - return a; - }; - auto encodeBool = [](const QVector& keys) { - QJsonArray a; - for (const auto& k : keys) { - QJsonObject o; - o.insert("frame", k.frame); - o.insert("value", k.value); - a.append(o); - } - return a; - }; - - auto encodeHash = [&](const auto& h, auto encoder) { - QJsonObject out; - for (auto it = h.constBegin(); it != h.constEnd(); ++it) { - out.insert(it.key(), encoder(it.value())); - } - return out; - }; - - co.insert("entityLocationKeys", encodeHash(c.entityLocationKeys, encodeVec2)); - co.insert("entityUserScaleKeys", encodeHash(c.entityUserScaleKeys, encodeDouble)); - co.insert("entityImageFrames", encodeHash(c.entityImageFrames, encodeImage)); - co.insert("entityVisibilityKeys", encodeHash(c.entityVisibilityKeys, encodeBool)); - co.insert("toolLocationKeys", encodeHash(c.toolLocationKeys, encodeVec2)); - co.insert("toolVisibilityKeys", encodeHash(c.toolVisibilityKeys, encodeBool)); - co.insert("cameraLocationKeys", encodeHash(c.cameraLocationKeys, encodeVec2)); - co.insert("cameraScaleKeys", encodeHash(c.cameraScaleKeys, encodeDouble)); - - clips.append(co); + root.insert("activeAnimationId", project.activeAnimationId()); + QJsonArray animations; + for (const auto& a : project.animations()) { + QJsonObject ao; + ao.insert("id", a.id); + ao.insert("name", a.name); + ao.insert("path", a.payloadPath); + ao.insert("fps", a.fps); + ao.insert("lengthFrames", a.lengthFrames); + ao.insert("loop", a.loop); + animations.append(ao); } - root.insert("animationClips", clips); + root.insert("animations", animations); - QJsonArray schemes; - for (const auto& s : project.animationSchemes()) { - QJsonObject so; - so.insert("id", s.id); - so.insert("name", s.name); - QJsonArray tracks; - for (const auto& t : s.tracks) { - QJsonObject to; - to.insert("id", t.id); - to.insert("name", t.name); - to.insert("muted", t.muted); - to.insert("solo", t.solo); - QJsonArray strips; - for (const auto& st : t.strips) { - QJsonObject sto; - sto.insert("id", st.id); - sto.insert("clipId", st.clipId); - sto.insert("startSlot", st.startSlot); - sto.insert("slotLen", st.slotLen); - sto.insert("enabled", st.enabled); - sto.insert("muted", st.muted); - strips.append(sto); - } - to.insert("strips", strips); - tracks.append(to); - } - so.insert("tracks", tracks); - schemes.append(so); + QJsonArray hots; + for (const auto& h : project.presentationHotspots()) { + QJsonObject ho; + ho.insert(QStringLiteral("id"), h.id); + ho.insert(QStringLiteral("cx"), h.centerWorld.x()); + ho.insert(QStringLiteral("cy"), h.centerWorld.y()); + ho.insert(QStringLiteral("targetAnimationId"), h.targetAnimationId); + hots.append(ho); } - root.insert("animationSchemes", schemes); + root.insert(QStringLiteral("presentationHotspots"), hots); return root; } @@ -908,7 +1283,7 @@ bool ProjectWorkspace::projectFromJson(const QJsonObject& root, Project& outProj return false; } const int version = root.value("version").toInt(); - if (version != 1 && version != 2 && version != 3 && version != 4) { + if (version != 7) { return false; } if (outFileVersion) { @@ -936,204 +1311,87 @@ bool ProjectWorkspace::projectFromJson(const QJsonObject& root, Project& outProj continue; } Project::Entity e; - if (version == 1) { - if (entityFromJsonV1(v.toObject(), e)) { - entities.push_back(e); - } - } else { - if (!entityStubFromJsonV2(v.toObject(), e)) { - return false; - } - entities.push_back(e); + if (!entityStubFromJsonV2(v.toObject(), e)) { + return false; } + entities.push_back(e); } } outProject.setEntities(entities); QVector tools; - if (version >= 2) { - const auto toolsVal = root.value("tools"); - if (toolsVal.isArray()) { - const QJsonArray arr = toolsVal.toArray(); - tools.reserve(arr.size()); - for (const auto& v : arr) { - if (!v.isObject()) continue; - Project::Tool t; - if (toolFromJsonV2(v.toObject(), t)) { - tools.push_back(t); - } + const auto toolsVal = root.value("tools"); + if (toolsVal.isArray()) { + const QJsonArray arr = toolsVal.toArray(); + tools.reserve(arr.size()); + for (const auto& v : arr) { + if (!v.isObject()) continue; + Project::Tool t; + if (toolFromJsonV2(v.toObject(), t)) { + tools.push_back(t); } } } outProject.setTools(tools); QVector cameras; - if (version >= 2) { - const auto camsVal = root.value("cameras"); - if (camsVal.isArray()) { - const QJsonArray arr = camsVal.toArray(); - cameras.reserve(arr.size()); - for (const auto& v : arr) { - if (!v.isObject()) continue; - Project::Camera c; - if (cameraFromJsonV4(v.toObject(), c)) { - cameras.push_back(c); - } + const auto camsVal = root.value("cameras"); + if (camsVal.isArray()) { + const QJsonArray arr = camsVal.toArray(); + cameras.reserve(arr.size()); + for (const auto& v : arr) { + if (!v.isObject()) continue; + Project::Camera c; + if (cameraFromJsonV4(v.toObject(), c)) { + cameras.push_back(c); } } } outProject.setCameras(cameras); outProject.setActiveCameraId(root.value("activeCameraId").toString()); - // —— 动画(v3,可选)—— - if (version >= 3) { - outProject.setActiveSchemeId(root.value("activeSchemeId").toString()); - outProject.setSelectedStripId(root.value("selectedStripId").toString()); - - QVector clips; - const auto clipsVal = root.value("animationClips"); - if (clipsVal.isArray()) { - const QJsonArray arr = clipsVal.toArray(); - clips.reserve(arr.size()); - for (const auto& v : arr) { - if (!v.isObject()) continue; - const QJsonObject co = v.toObject(); - Project::AnimationClip c; - c.id = co.value("id").toString(); - c.name = co.value("name").toString(); - - auto decodeVec2 = [](const QJsonValue& val) { - QVector out; - if (!val.isArray()) return out; - const QJsonArray karr = val.toArray(); - out.reserve(karr.size()); - for (const auto& kv : karr) { - if (!kv.isObject()) continue; - const QJsonObject o = kv.toObject(); - Project::Entity::KeyframeVec2 k; - k.frame = o.value("frame").toInt(0); - k.value = QPointF(o.value("x").toDouble(0.0), o.value("y").toDouble(0.0)); - out.push_back(k); - } - return out; - }; - auto decodeDouble = [](const QJsonValue& val) { - QVector out; - if (!val.isArray()) return out; - const QJsonArray karr = val.toArray(); - out.reserve(karr.size()); - for (const auto& kv : karr) { - if (!kv.isObject()) continue; - const QJsonObject o = kv.toObject(); - Project::Entity::KeyframeDouble k; - k.frame = o.value("frame").toInt(0); - k.value = o.value("value").toDouble(1.0); - out.push_back(k); - } - return out; - }; - auto decodeImage = [](const QJsonValue& val) { - QVector out; - if (!val.isArray()) return out; - const QJsonArray karr = val.toArray(); - out.reserve(karr.size()); - for (const auto& kv : karr) { - if (!kv.isObject()) continue; - const QJsonObject o = kv.toObject(); - Project::Entity::ImageFrame k; - k.frame = o.value("frame").toInt(0); - k.imagePath = asOptionalRelativeUnderProject(o.value("path").toString()); - out.push_back(k); - } - return out; - }; - auto decodeBool = [](const QJsonValue& val) { - QVector out; - if (!val.isArray()) return out; - const QJsonArray karr = val.toArray(); - out.reserve(karr.size()); - for (const auto& kv : karr) { - if (!kv.isObject()) continue; - const QJsonObject o = kv.toObject(); - Project::ToolKeyframeBool k; - k.frame = o.value("frame").toInt(0); - k.value = o.value("value").toBool(true); - out.push_back(k); - } - return out; - }; - - auto decodeHash = [&](const QJsonValue& val, auto decoder, auto& dst) { - dst.clear(); - if (!val.isObject()) return; - const QJsonObject ho = val.toObject(); - for (auto it = ho.begin(); it != ho.end(); ++it) { - dst.insert(it.key(), decoder(it.value())); - } - }; - - decodeHash(co.value("entityLocationKeys"), decodeVec2, c.entityLocationKeys); - decodeHash(co.value("entityUserScaleKeys"), decodeDouble, c.entityUserScaleKeys); - decodeHash(co.value("entityImageFrames"), decodeImage, c.entityImageFrames); - decodeHash(co.value("entityVisibilityKeys"), decodeBool, c.entityVisibilityKeys); - decodeHash(co.value("toolLocationKeys"), decodeVec2, c.toolLocationKeys); - decodeHash(co.value("toolVisibilityKeys"), decodeBool, c.toolVisibilityKeys); - decodeHash(co.value("cameraLocationKeys"), decodeVec2, c.cameraLocationKeys); - decodeHash(co.value("cameraScaleKeys"), decodeDouble, c.cameraScaleKeys); - - clips.push_back(std::move(c)); + QVector animations; + const auto animVal = root.value("animations"); + if (animVal.isArray()) { + for (const auto& av : animVal.toArray()) { + if (!av.isObject()) continue; + const QJsonObject ao = av.toObject(); + Project::Animation a; + a.id = ao.value("id").toString(); + a.name = ao.value("name").toString(); + a.payloadPath = asRelativeUnderProject(ao.value("path").toString()); + a.fps = std::max(1, ao.value("fps").toInt(30)); + a.lengthFrames = std::max(1, ao.value("lengthFrames").toInt(Project::kClipFixedFrames)); + a.loop = ao.value("loop").toBool(true); + if (a.id.isEmpty() || a.payloadPath.isEmpty()) { + return false; } + animations.push_back(a); } - outProject.setAnimationClips(clips); - - QVector schemes; - const auto schemesVal = root.value("animationSchemes"); - if (schemesVal.isArray()) { - const QJsonArray arr = schemesVal.toArray(); - schemes.reserve(arr.size()); - for (const auto& v : arr) { - if (!v.isObject()) continue; - const QJsonObject so = v.toObject(); - Project::AnimationScheme s; - s.id = so.value("id").toString(); - s.name = so.value("name").toString(); - const auto tracksVal = so.value("tracks"); - if (tracksVal.isArray()) { - const QJsonArray tarr = tracksVal.toArray(); - s.tracks.reserve(tarr.size()); - for (const auto& tv : tarr) { - if (!tv.isObject()) continue; - const QJsonObject to = tv.toObject(); - Project::NlaTrack t; - t.id = to.value("id").toString(); - t.name = to.value("name").toString(); - t.muted = to.value("muted").toBool(false); - t.solo = to.value("solo").toBool(false); - const auto stripsVal = to.value("strips"); - if (stripsVal.isArray()) { - const QJsonArray sarr = stripsVal.toArray(); - t.strips.reserve(sarr.size()); - for (const auto& sv : sarr) { - if (!sv.isObject()) continue; - const QJsonObject sto = sv.toObject(); - Project::NlaStrip st; - st.id = sto.value("id").toString(); - st.clipId = sto.value("clipId").toString(); - st.startSlot = sto.value("startSlot").toInt(0); - st.slotLen = std::max(1, sto.value("slotLen").toInt(1)); - st.enabled = sto.value("enabled").toBool(true); - st.muted = sto.value("muted").toBool(false); - t.strips.push_back(std::move(st)); - } - } - s.tracks.push_back(std::move(t)); - } - } - schemes.push_back(std::move(s)); - } - } - outProject.setAnimationSchemes(schemes); } + outProject.setAnimations(animations); + outProject.setActiveAnimationId(root.value("activeAnimationId").toString()); + + QVector hotspots; + const auto hotVal = root.value(QStringLiteral("presentationHotspots")); + if (hotVal.isArray()) { + const QJsonArray arr = hotVal.toArray(); + hotspots.reserve(arr.size()); + for (const auto& v : arr) { + if (!v.isObject()) + continue; + const QJsonObject ho = v.toObject(); + Project::PresentationHotspot ph; + ph.id = ho.value(QStringLiteral("id")).toString(); + ph.centerWorld = + QPointF(ho.value(QStringLiteral("cx")).toDouble(), ho.value(QStringLiteral("cy")).toDouble()); + ph.targetAnimationId = ho.value(QStringLiteral("targetAnimationId")).toString(); + if (!ph.id.isEmpty()) { + hotspots.push_back(ph); + } + } + } + outProject.setPresentationHotspots(hotspots); return true; } @@ -1178,6 +1436,7 @@ QJsonObject ProjectWorkspace::toolToJson(const Project::Tool& t) { o.insert("parentOffsetY", t.parentOffsetWorld.y()); o.insert("originX", t.originWorld.x()); o.insert("originY", t.originWorld.y()); + o.insert("priority", t.priority); o.insert("type", (t.type == Project::Tool::Type::Bubble) ? QStringLiteral("bubble") : QStringLiteral("bubble")); o.insert("text", t.text); o.insert("fontPx", t.fontPx); @@ -1218,6 +1477,7 @@ bool ProjectWorkspace::toolFromJsonV2(const QJsonObject& o, Project::Tool& out) t.parentId = o.value("parentId").toString(); t.parentOffsetWorld = QPointF(o.value("parentOffsetX").toDouble(0.0), o.value("parentOffsetY").toDouble(0.0)); t.originWorld = QPointF(o.value("originX").toDouble(0.0), o.value("originY").toDouble(0.0)); + t.priority = o.value("priority").toInt(10); const QString type = o.value("type").toString(); t.type = (type == QStringLiteral("bubble")) ? Project::Tool::Type::Bubble : Project::Tool::Type::Bubble; t.text = o.value("text").toString(); @@ -1352,129 +1612,7 @@ bool ProjectWorkspace::entityStubFromJsonV2(const QJsonObject& o, Project::Entit return true; } -bool ProjectWorkspace::entityFromJsonV1(const QJsonObject& o, Project::Entity& out) { - out.id = o.value("id").toString(); - if (out.id.isEmpty()) { - return false; - } - out.depth = o.value("depth").toInt(0); - out.imagePath = asOptionalRelativeUnderProject(o.value("imagePath").toString()); - out.imageTopLeftWorld = QPointF(o.value("imageTopLeftX").toDouble(0.0), o.value("imageTopLeftY").toDouble(0.0)); - out.originWorld = QPointF(o.value("originX").toDouble(0.0), o.value("originY").toDouble(0.0)); - out.polygonLocal.clear(); - out.cutoutPolygonWorld.clear(); - out.entityPayloadPath.clear(); - out.legacyAnimSidecarPath = asOptionalRelativeUnderProject(o.value("animationBundle").toString()); - out.locationKeys.clear(); - out.depthScaleKeys.clear(); - out.imageFrames.clear(); - - const auto localVal = o.value("polygonLocal"); - if (localVal.isArray()) { - const QJsonArray arr = localVal.toArray(); - out.polygonLocal.reserve(arr.size()); - for (const auto& v : arr) { - if (!v.isObject()) { - continue; - } - const QJsonObject p = v.toObject(); - out.polygonLocal.push_back(QPointF(p.value("x").toDouble(0.0), p.value("y").toDouble(0.0))); - } - } - - const auto cutoutVal = o.value("cutoutPolygon"); - if (cutoutVal.isArray()) { - const QJsonArray arr = cutoutVal.toArray(); - out.cutoutPolygonWorld.reserve(arr.size()); - for (const auto& v : arr) { - if (!v.isObject()) { - continue; - } - const QJsonObject p = v.toObject(); - out.cutoutPolygonWorld.push_back(QPointF(p.value("x").toDouble(0.0), p.value("y").toDouble(0.0))); - } - } - - // 兼容旧字段:polygon(world) - if (out.polygonLocal.isEmpty()) { - const auto legacy = o.value("polygon"); - if (legacy.isArray()) { - const QJsonArray arr = legacy.toArray(); - QVector polyWorld; - polyWorld.reserve(arr.size()); - for (const auto& v : arr) { - if (!v.isObject()) continue; - const QJsonObject p = v.toObject(); - polyWorld.push_back(QPointF(p.value("x").toDouble(0.0), p.value("y").toDouble(0.0))); - } - if (!polyWorld.isEmpty()) { - // 若没给 origin,则用包围盒中心近似 - if (qFuzzyIsNull(out.originWorld.x()) && qFuzzyIsNull(out.originWorld.y())) { - QRectF bb; - for (const auto& pt : polyWorld) { - bb = bb.isNull() ? QRectF(pt, QSizeF(1, 1)) : bb.united(QRectF(pt, QSizeF(1, 1))); - } - out.originWorld = bb.center(); - } - out.cutoutPolygonWorld = polyWorld; - out.polygonLocal.reserve(polyWorld.size()); - for (const auto& pt : polyWorld) { - out.polygonLocal.push_back(pt - out.originWorld); - } - } - } - } - - if (out.cutoutPolygonWorld.isEmpty()) { - // 没有 cutout 就默认用当前实体形状(origin+local) - for (const auto& lp : out.polygonLocal) { - out.cutoutPolygonWorld.push_back(out.originWorld + lp); - } - } - out.blackholeVisible = true; - out.blackholeId = QStringLiteral("blackhole-%1").arg(out.id); - out.blackholeResolvedBy = QStringLiteral("pending"); - - // 旧版:关键帧内嵌在 project.json;若存在对应 .anim 文件,打开项目时会被二进制数据覆盖。 - const auto lk = o.value("locationKeys"); - if (lk.isArray()) { - for (const auto& v : lk.toArray()) { - if (!v.isObject()) continue; - const auto ko = v.toObject(); - Project::Entity::KeyframeVec2 kf; - kf.frame = ko.value("frame").toInt(0); - kf.value = QPointF(ko.value("x").toDouble(0.0), ko.value("y").toDouble(0.0)); - out.locationKeys.push_back(kf); - } - } - const auto dk = o.value("depthScaleKeys"); - if (dk.isArray()) { - for (const auto& v : dk.toArray()) { - if (!v.isObject()) continue; - const auto ko = v.toObject(); - Project::Entity::KeyframeFloat01 kf; - kf.frame = ko.value("frame").toInt(0); - kf.value = ko.value("v").toDouble(0.5); - out.depthScaleKeys.push_back(kf); - } - } - const auto ik = o.value("imageFrames"); - if (ik.isArray()) { - for (const auto& v : ik.toArray()) { - if (!v.isObject()) continue; - const auto ko = v.toObject(); - Project::Entity::ImageFrame kf; - kf.frame = ko.value("frame").toInt(0); - kf.imagePath = asOptionalRelativeUnderProject(ko.value("imagePath").toString()); - if (!kf.imagePath.isEmpty()) { - out.imageFrames.push_back(kf); - } - } - } - - return !out.polygonLocal.isEmpty(); -} - +// 不再支持 v1 entity JSON(无兼容/无迁移)。 QString ProjectWorkspace::fileSuffixWithDot(const QString& path) { QFileInfo fi(path); const auto suf = fi.suffix(); @@ -1515,6 +1653,7 @@ bool ProjectWorkspace::applyBackgroundPath(const QString& relativePath, const auto before = m_project.backgroundImagePath(); m_project.setBackgroundImagePath(rel); + markIndexDirty(); if (!writeIndexJson()) { m_project.setBackgroundImagePath(before); return false; @@ -1536,8 +1675,21 @@ bool ProjectWorkspace::applyEntities(const QVector& entities, bool recordHistory, const QString& label) { const auto before = m_project.entities(); + QHash beforeById; + beforeById.reserve(before.size()); + for (const auto& e : before) { + beforeById.insert(e.id, e); + } + for (const auto& e : entities) { + if (!beforeById.contains(e.id) || !entityStaticEquals(beforeById.value(e.id), e)) { + markEntityDirty(e.id, &e); + } + } + if (before.size() != entities.size()) { + markIndexDirty(); + } m_project.setEntities(entities); - if (!writeIndexJson()) { + if (!writeIndexJsonWithoutPayloadSync()) { m_project.setEntities(before); return false; } @@ -1558,7 +1710,8 @@ bool ProjectWorkspace::applyTools(const QVector& tools, const QString& label) { const auto before = m_project.tools(); m_project.setTools(tools); - if (!writeIndexJson()) { + markIndexDirty(); + if (!writeIndexJsonWithoutPayloadSync()) { m_project.setTools(before); return false; } @@ -1579,7 +1732,8 @@ bool ProjectWorkspace::applyCameras(const QVector& cameras, const QString& label) { const auto before = m_project.cameras(); m_project.setCameras(cameras); - if (!writeIndexJson()) { + markIndexDirty(); + if (!writeIndexJsonWithoutPayloadSync()) { m_project.setCameras(before); return false; } @@ -1595,6 +1749,63 @@ bool ProjectWorkspace::applyCameras(const QVector& cameras, return true; } +bool ProjectWorkspace::applyAnimations(const QVector& animations, + bool recordHistory, + const QString& label) { + const auto beforeAnims = m_project.animations(); + const auto beforeHs = m_project.presentationHotspots(); + m_project.setAnimations(animations); + sanitizePresentationHotspotTargets(m_project); + const auto afterHs = m_project.presentationHotspots(); + const bool hotspotsTouched = !presentationHotspotsEqual(beforeHs, afterHs); + markIndexDirty(); + markAllAnimationsDirty(); + if (!writeIndexJsonWithoutPayloadSync()) { + m_project.setAnimations(beforeAnims); + m_project.setPresentationHotspots(beforeHs); + return false; + } + if (recordHistory) { + Operation op; + op.type = Operation::Type::SetAnimations; + op.label = label; + op.beforeAnimations = beforeAnims; + op.afterAnimations = animations; + op.animBundlesPresentationHotspots = hotspotsTouched; + if (hotspotsTouched) { + op.animSnapshotBeforePresentationHotspots = beforeHs; + op.animSnapshotAfterPresentationHotspots = afterHs; + } + pushOperation(op); + m_redoStack.clear(); + } + return true; +} + +bool ProjectWorkspace::applyPresentationHotspots(const QVector& hotspots, + bool recordHistory, + const QString& label) { + if (!isOpen()) + return false; + const auto before = m_project.presentationHotspots(); + m_project.setPresentationHotspots(hotspots); + markIndexDirty(); + if (!writeIndexJsonWithoutPayloadSync()) { + m_project.setPresentationHotspots(before); + return false; + } + if (recordHistory) { + Operation op; + op.type = Operation::Type::SetPresentationHotspots; + op.label = label; + op.beforePresentationHotspots = before; + op.afterPresentationHotspots = hotspots; + pushOperation(op); + m_redoStack.clear(); + } + return true; +} + QString ProjectWorkspace::ensureEntitiesDir() const { const auto assets = assetsDirPath(); if (assets.isEmpty()) { @@ -1609,13 +1820,19 @@ bool ProjectWorkspace::syncEntityPayloadsToDisk() { return false; } QVector ents = m_project.entities(); - bool changed = false; + bool pathsChanged = false; + if (!m_asyncWriter && !m_projectDir.isEmpty()) { + m_asyncWriter = new core::persistence::AsyncProjectWriter(); + m_asyncWriter->setProjectDir(m_projectDir); + } for (auto& e : ents) { - e.legacyAnimSidecarPath.clear(); if (e.entityPayloadPath.isEmpty()) { - e.entityPayloadPath = - QString::fromUtf8(kAssetsDirName) + QStringLiteral("/entities/") + e.id + QStringLiteral(".hfe"); - changed = true; + e.entityPayloadPath = core::persistence::EntityBundleStore::bundleRelativeDir(e.id); + pathsChanged = true; + } else if (e.entityPayloadPath.endsWith(QStringLiteral(".hfe"), Qt::CaseInsensitive)) { + // 与 v7 bundle 目录布局对齐(曾误写为单文件 .hfe) + e.entityPayloadPath = core::persistence::EntityBundleStore::bundleRelativeDir(e.id); + pathsChanged = true; } const QString rel = asRelativeUnderProject(e.entityPayloadPath); if (rel.isEmpty()) { @@ -1623,16 +1840,59 @@ bool ProjectWorkspace::syncEntityPayloadsToDisk() { } if (rel != e.entityPayloadPath) { e.entityPayloadPath = rel; - changed = true; + pathsChanged = true; } - const QString abs = QDir(m_projectDir).filePath(rel); - if (!EntityPayloadBinary::save(abs, e)) { + + const bool dirty = m_dirtyEntityIds.contains(e.id); + if (dirty && m_asyncWriter) { + m_asyncWriter->requestWriteEntity(e); + } + } + if (pathsChanged) { + m_project.setEntities(ents); + markIndexDirty(); + } + m_dirtyEntityIds.clear(); + return true; +} + +bool ProjectWorkspace::syncAnimationPayloadsToDisk() { + if (ensureAnimationsDir().isEmpty()) { + return false; + } + auto animations = m_project.animations(); + bool pathsChanged = false; + if (!m_asyncWriter && !m_projectDir.isEmpty()) { + m_asyncWriter = new core::persistence::AsyncProjectWriter(); + m_asyncWriter->setProjectDir(m_projectDir); + } + for (auto& a : animations) { + if (a.id.isEmpty()) { return false; } + if (a.payloadPath.isEmpty()) { + a.payloadPath = core::persistence::AnimationBundleStore::bundleRelativeDir(a.id); + pathsChanged = true; + m_dirtyAnimationIds.insert(a.id); + } + const QString rel = asRelativeUnderProject(a.payloadPath); + if (rel.isEmpty()) { + return false; + } + if (rel != a.payloadPath) { + a.payloadPath = rel; + pathsChanged = true; + m_dirtyAnimationIds.insert(a.id); + } + if (m_dirtyAnimationIds.contains(a.id) && m_asyncWriter) { + m_asyncWriter->requestWriteAnimation(a); + } } - if (changed) { - m_project.setEntities(ents); + if (pathsChanged) { + m_project.setAnimations(animations); + markIndexDirty(); } + m_dirtyAnimationIds.clear(); return true; } @@ -1643,18 +1903,15 @@ bool ProjectWorkspace::saveSingleEntityPayload(Project::Entity& entity) { if (ensureEntitiesDir().isEmpty()) { return false; } - entity.legacyAnimSidecarPath.clear(); if (entity.entityPayloadPath.isEmpty()) { - entity.entityPayloadPath = - QString::fromUtf8(kAssetsDirName) + QStringLiteral("/entities/") + entity.id + QStringLiteral(".hfe"); + entity.entityPayloadPath = core::persistence::EntityBundleStore::bundleRelativeDir(entity.id); } const QString rel = asRelativeUnderProject(entity.entityPayloadPath); if (rel.isEmpty()) { return false; } entity.entityPayloadPath = rel; - const QString abs = QDir(m_projectDir).filePath(rel); - return EntityPayloadBinary::save(abs, entity); + return core::persistence::EntityBundleStore::save(m_projectDir, entity); } bool ProjectWorkspace::hydrateEntityPayloadsFromDisk() { @@ -1672,44 +1929,60 @@ bool ProjectWorkspace::hydrateEntityPayloadsFromDisk() { if (rel.isEmpty()) { return false; } - const QString abs = QDir(m_projectDir).filePath(rel); - if (!QFileInfo::exists(abs)) { - return false; - } - if (!EntityPayloadBinary::load(abs, e)) { - return false; + e.entityPayloadPath = rel; + + // 曾错误地把 payload 写成 *.hfe 单文件路径,而 bundle 实际写在 assets/entities// 目录下。 + QString tryRel = rel; + if (!core::persistence::EntityBundleStore::load(m_projectDir, e)) { + if (tryRel.endsWith(QStringLiteral(".hfe"), Qt::CaseInsensitive)) { + tryRel = core::persistence::EntityBundleStore::bundleRelativeDir(expectId); + tryRel = asRelativeUnderProject(tryRel); + if (!tryRel.isEmpty()) { + e.entityPayloadPath = tryRel; + if (!core::persistence::EntityBundleStore::load(m_projectDir, e)) { + return false; + } + } else { + return false; + } + } else { + return false; + } } if (e.id != expectId) { return false; } - e.entityPayloadPath = rel; } m_project.setEntities(ents); return true; } -void ProjectWorkspace::loadV1LegacyAnimationSidecars() { +bool ProjectWorkspace::hydrateAnimationPayloadsFromDisk() { if (m_projectDir.isEmpty()) { - return; + return true; } - QVector ents = m_project.entities(); - for (auto& e : ents) { - QString rel = e.legacyAnimSidecarPath; - if (rel.isEmpty()) { - rel = QString::fromUtf8(kAssetsDirName) + QStringLiteral("/anim/") + e.id + QStringLiteral(".anim"); + QVector animations = m_project.animations(); + for (auto& a : animations) { + const QString expectId = a.id; + QString rel = asRelativeUnderProject(a.payloadPath); + if (expectId.isEmpty() || rel.isEmpty()) { + return false; } - rel = asRelativeUnderProject(rel); - if (!rel.isEmpty()) { - const QString abs = QDir(m_projectDir).filePath(rel); - if (QFileInfo::exists(abs)) { - EntityPayloadBinary::loadLegacyAnimFile(abs, e); - } + a.payloadPath = rel; + if (!core::persistence::AnimationBundleStore::load(m_projectDir, a)) { + return false; + } + if (a.id != expectId) { + return false; } - e.legacyAnimSidecarPath.clear(); } - m_project.setEntities(ents); + m_project.setAnimations(animations); + m_dirtyAnimationIds.clear(); + return true; } +// 不再支持 v1 legacy .anim sidecar(无兼容/无迁移)。 + bool ProjectWorkspace::writeEntityImage(const QString& entityId, const QImage& image, QString& outRelPath) { outRelPath.clear(); if (m_projectDir.isEmpty() || entityId.isEmpty() || image.isNull()) { @@ -1830,15 +2103,16 @@ bool ProjectWorkspace::addEntity(const Project::Entity& entity, const QImage& im Project::Entity e = entity; if (!image.isNull()) { - QString rel; - if (!writeEntityImage(e.id, image, rel)) { + QByteArray png; + QBuffer buf(&png); + if (!buf.open(QIODevice::WriteOnly) || !image.save(&buf, "PNG")) { return false; } - e.imagePath = rel; + e.imagePath.clear(); + e.defaultImagePng = png; } if (e.entityPayloadPath.isEmpty()) { - e.entityPayloadPath = - QString::fromUtf8(kAssetsDirName) + QStringLiteral("/entities/") + e.id + QStringLiteral(".hfe"); + e.entityPayloadPath = core::persistence::EntityBundleStore::bundleRelativeDir(e.id); } if (e.blackholeId.isEmpty()) { e.blackholeId = QStringLiteral("blackhole-%1").arg(e.id); @@ -1947,6 +2221,9 @@ bool ProjectWorkspace::resolveBlackholeByUseOriginalBackground(const QString& id e.blackholeId = QStringLiteral("blackhole-%1").arg(e.id); } e.blackholeResolvedBy = QStringLiteral("use_original_background"); + removeBlackholeOverlayFile(m_projectDir, e.blackholeOverlayPath); + e.blackholeOverlayPath.clear(); + e.blackholeOverlayRect = QRect(); m_project.setEntities(ents); if (!saveSingleEntityPayload(ents[hit]) || !writeIndexJsonWithoutPayloadSync()) { @@ -1989,7 +2266,7 @@ bool ProjectWorkspace::resolveBlackholeByCopyBackground(const QString& id, const return false; } - QImage bg(bgAbs); + QImage bg = image_file::loadImageLimited(bgAbs, image_decode::kWorkspaceMaxPixels); if (bg.isNull()) { return false; } @@ -2023,14 +2300,16 @@ bool ProjectWorkspace::resolveBlackholeByCopyBackground(const QString& id, const p.drawImage(targetRect.topLeft(), srcSnapshot, srcRect); p.end(); } - QImageWriter writer(bgAbs); - writer.setFormat("png"); - writer.setCompression(1); - if (!writer.write(bg)) { + + const QImage patch = bg.copy(targetRect); + const auto before = m_project.entities(); + QString overlayRel; + if (!writeBlackholeOverlayPng(m_projectDir, id, ents[hit].blackholeOverlayPath, patch, targetRect, + &overlayRel)) { return false; } - - const auto before = m_project.entities(); + ents[hit].blackholeOverlayPath = overlayRel; + ents[hit].blackholeOverlayRect = targetRect; ents[hit].blackholeVisible = hideBlackholeAfterFill ? false : ents[hit].blackholeVisible; if (ents[hit].blackholeId.isEmpty()) { ents[hit].blackholeId = QStringLiteral("blackhole-%1").arg(ents[hit].id); @@ -2052,32 +2331,16 @@ bool ProjectWorkspace::resolveBlackholeByCopyBackground(const QString& id, const return true; } -bool ProjectWorkspace::resolveBlackholeByModelInpaint(const QString& id, const QImage& patchedBackground, +bool ProjectWorkspace::resolveBlackholeByModelInpaint(const QString& id, const QImage& overlayPatch, + const QRect& overlayRectInBackground, bool hideBlackholeAfterFill) { if (m_projectDir.isEmpty() || id.isEmpty()) { return false; } - const QString bgAbs = backgroundAbsolutePath(); - if (bgAbs.isEmpty() || !QFileInfo::exists(bgAbs)) { + if (overlayPatch.isNull() || !overlayRectInBackground.isValid() || overlayRectInBackground.width() <= 0 || + overlayRectInBackground.height() <= 0) { return false; } - if (patchedBackground.isNull()) { - return false; - } - - // 写回背景文件 - { - QImage bg = patchedBackground; - if (bg.format() != QImage::Format_ARGB32_Premultiplied) { - bg = bg.convertToFormat(QImage::Format_ARGB32_Premultiplied); - } - QImageWriter writer(bgAbs); - writer.setFormat("png"); - writer.setCompression(1); - if (!writer.write(bg)) { - return false; - } - } // 更新实体黑洞状态 + 记录历史 const auto before = m_project.entities(); @@ -2093,6 +2356,13 @@ bool ProjectWorkspace::resolveBlackholeByModelInpaint(const QString& id, const Q return false; } + QString overlayRel; + if (!writeBlackholeOverlayPng(m_projectDir, id, ents[hit].blackholeOverlayPath, overlayPatch, + overlayRectInBackground, &overlayRel)) { + return false; + } + ents[hit].blackholeOverlayPath = overlayRel; + ents[hit].blackholeOverlayRect = overlayRectInBackground; ents[hit].blackholeVisible = hideBlackholeAfterFill ? false : ents[hit].blackholeVisible; if (ents[hit].blackholeId.isEmpty()) { ents[hit].blackholeId = QStringLiteral("blackhole-%1").arg(ents[hit].id); @@ -2119,32 +2389,59 @@ bool ProjectWorkspace::setEntityVisibilityKey(const QString& id, int frame, bool if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { return false; } - Project::AnimationClip* clip = activeClipOrNull(m_project); - if (!clip) return false; - auto& keys = clip->entityVisibilityKeys[id]; - upsertBoolKey(keys, frame, visible); - return writeIndexJson(); + if (isNoneAnimationActive(m_project)) { + return false; + } + const auto beforeAnimations = m_project.animations(); + Project::Animation* anim = activeAnimationOrCreate(m_project); + if (!anim) return false; + auto* track = findOrCreateTrack(*anim, + Project::AnimationTargetKind::Entity, + id, + Project::AnimationProperty::Visibility, + Project::AnimationValueType::Bool, + Project::AnimationInterpolation::Hold); + if (!track) return false; + upsertAnimKey(*track, frame, visible); + markAnimationDirty(anim->id); + const auto afterAnimations = m_project.animations(); + m_project.setAnimations(beforeAnimations); + return applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(显隐)")); } bool ProjectWorkspace::removeEntityVisibilityKey(const QString& id, int frame) { if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { return false; } - Project::AnimationClip* clip = activeClipOrNull(m_project); - if (!clip) return false; - if (!clip->entityVisibilityKeys.contains(id)) return true; - auto keys = clip->entityVisibilityKeys.value(id); + if (isNoneAnimationActive(m_project)) { + return false; + } + const auto beforeAnimations = m_project.animations(); bool changed = false; - for (int i = 0; i < keys.size(); ++i) { - if (keys[i].frame == frame) { - keys.removeAt(i); - changed = true; - break; + if (Project::Animation* anim = activeAnimationOrCreate(m_project)) { + for (auto& t : anim->tracks) { + if (t.targetKind != Project::AnimationTargetKind::Entity || + t.targetId != id || + t.property != Project::AnimationProperty::Visibility) { + continue; + } + for (int i = 0; i < t.keys.size(); ++i) { + if (t.keys[i].frame == frame) { + t.keys.removeAt(i); + changed = true; + break; + } + } + } + if (changed) { + pruneEmptyAnimationTracks(*anim); + markAnimationDirty(anim->id); } } if (!changed) return true; - clip->entityVisibilityKeys.insert(id, keys); - return writeIndexJson(); + const auto afterAnimations = m_project.animations(); + m_project.setAnimations(beforeAnimations); + return applyAnimations(afterAnimations, true, QStringLiteral("删除关键帧(显隐)")); } bool ProjectWorkspace::setEntityDisplayName(const QString& id, const QString& displayName) { @@ -2227,6 +2524,10 @@ bool ProjectWorkspace::setEntityUserScale(const QString& id, double userScale, i return false; } const double u = std::clamp(userScale, 0.05, 20.0); + const bool writeAnimated = keyframeAtFrame >= 0 && !isNoneAnimationActive(m_project); + if (writeAnimated) { + return setEntityUserScaleKey(id, keyframeAtFrame, u); + } auto ents = m_project.entities(); bool found = false; for (auto& e : ents) { @@ -2235,25 +2536,16 @@ bool ProjectWorkspace::setEntityUserScale(const QString& id, double userScale, i } found = true; const bool baseSame = qFuzzyCompare(e.userScale + 1.0, u + 1.0); - e.userScale = u; - if (keyframeAtFrame >= 0) { - Project::AnimationClip* clip = activeClipOrNull(m_project); - if (!clip) return false; - auto& keys = clip->entityUserScaleKeys[id]; - upsertKey(keys, keyframeAtFrame, std::clamp(u, 1e-6, 1e3)); - } else if (baseSame) { + if (baseSame) { return true; } + e.userScale = u; break; } if (!found) { return false; } - if (!applyEntities(ents, true, QStringLiteral("整体缩放"))) { - return false; - } - // 缩放关键帧已写入 clip,此处只需保证索引落盘 - return writeIndexJson(); + return applyEntities(ents, true, QStringLiteral("整体缩放")); } bool ProjectWorkspace::setEntityIgnoreDistanceScale(const QString& id, bool on) { @@ -2323,6 +2615,25 @@ bool ProjectWorkspace::setEntityParent(const QString& id, const QString& parentI } }; + auto convertAnimTrackVec2 = [&](Project::Animation& anim, + Project::AnimationTargetKind kind, + const QString& targetId, + Project::AnimationProperty prop, + const QString& oldPid, + const QString& newPid) { + for (auto& t : anim.tracks) { + if (t.targetKind != kind || t.targetId != targetId || t.property != prop) continue; + if (t.valueType != Project::AnimationValueType::Vec2) continue; + if (t.keys.isEmpty()) continue; + for (auto& k : t.keys) { + const QPointF oldParentO = oldPid.isEmpty() ? QPointF() : parentOriginAt(oldPid, k.frame); + const QPointF world = oldPid.isEmpty() ? k.vec2Value : (oldParentO + k.vec2Value); + const QPointF newParentO = newPid.isEmpty() ? QPointF() : parentOriginAt(newPid, k.frame); + k.vec2Value = newPid.isEmpty() ? world : (world - newParentO); + } + } + }; + bool found = false; for (auto& e : ents) { if (e.id != id) continue; @@ -2333,15 +2644,18 @@ bool ProjectWorkspace::setEntityParent(const QString& id, const QString& parentI const QPointF oldParentOStart = oldPid.isEmpty() ? QPointF() : parentOriginAt(oldPid, frameStart); const QPointF baseWorldAtStart = oldPid.isEmpty() ? oldBaseStored : (oldParentOStart + oldBaseStored); - // 转换 clip 与内嵌 key(兼容 v1/v2/v3 数据来源) - if (Project::AnimationClip* clip = activeClipOrNull(m_project)) { - if (clip->entityLocationKeys.contains(e.id)) { - auto k = clip->entityLocationKeys.value(e.id); - convertKeys(k, oldPid, parentId); - clip->entityLocationKeys.insert(e.id, k); - } - } + // 转换内嵌 key(兼容旧数据来源/工具逻辑) convertKeys(e.locationKeys, oldPid, parentId); + // 转换新动画轨道(作为运行时求值的真相源) + if (Project::Animation* anim = activeAnimationOrCreate(m_project)) { + convertAnimTrackVec2(*anim, + Project::AnimationTargetKind::Entity, + e.id, + Project::AnimationProperty::Position, + oldPid, + parentId); + markAnimationDirty(anim->id); + } // 更新父子信息 e.parentId = parentId; @@ -2368,6 +2682,62 @@ bool ProjectWorkspace::setEntityParent(const QString& id, const QString& parentI return applyEntities(ents, true, QStringLiteral("设置父实体")); } +bool ProjectWorkspace::normalizeAllEntityOriginsToCentroids(int frame) { + if (m_projectDir.isEmpty() || frame < 0) { + return false; + } + auto ents = m_project.entities(); + bool changed = false; + for (auto& e : ents) { + const double s = combinedVisualScaleForEntity(e, frame); + if (snapEntityOriginToCentroidInPlace(m_project, e, frame, s)) { + changed = true; + } + } + if (!changed) { + return true; + } + return applyEntities(ents, true, QStringLiteral("对齐实体中心")); +} + +QPointF ProjectWorkspace::entityCentroidWorldAtFrame(const QString& id, int frame) const { + if (id.isEmpty() || frame < 0) { + return {}; + } + const auto rf = core::eval::evaluateAtFrame(m_project, frame, 10); + for (const auto& re : rf.entities) { + if (re.entity.id != id) { + continue; + } + const double s = combinedVisualScaleForEntity(re.entity, frame); + return entityPolygonCentroidWorld(m_project, re.entity, frame, s); + } + for (const auto& rt : rf.tools) { + if (rt.tool.id == id) { + return rt.tool.originWorld; + } + } + return {}; +} + +bool ProjectWorkspace::setEntityParentOffsetWorld(const QString& id, const QPointF& offsetWorld) { + if (m_projectDir.isEmpty() || id.isEmpty()) { + return false; + } + auto ents = m_project.entities(); + for (auto& e : ents) { + if (e.id != id) { + continue; + } + if (e.parentId.isEmpty()) { + return false; + } + e.parentOffsetWorld = offsetWorld; + return applyEntities(ents, true, QStringLiteral("相对父中心")); + } + return false; +} + bool ProjectWorkspace::moveEntityCentroidTo(const QString& id, int frame, const QPointF& targetCentroidWorld, double sTotal, bool autoKeyLocation) { if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0 || sTotal <= 1e-9) { @@ -2376,7 +2746,7 @@ bool ProjectWorkspace::moveEntityCentroidTo(const QString& id, int frame, const auto ents = m_project.entities(); for (const auto& e : ents) { if (e.id == id) { - const QPointF c = entityPolygonCentroidWorld(e, frame, sTotal); + const QPointF c = entityPolygonCentroidWorld(m_project, e, frame, sTotal); const QPointF delta = targetCentroidWorld - c; return moveEntityBy(id, delta, frame, autoKeyLocation); } @@ -2389,14 +2759,14 @@ bool ProjectWorkspace::reanchorEntityPivot(const QString& id, int frame, const Q return false; } auto ents = m_project.entities(); + QPointF pivotDelta; bool found = false; for (auto& e : ents) { if (e.id != id) { continue; } found = true; - const QPointF O_anim = - sampleLocation(e.locationKeys, frame, e.originWorld, KeyInterpolation::Linear); + const QPointF O_anim = resolvedOriginAtFrame(m_project, e.id, frame); QVector polyWorld; polyWorld.reserve(e.polygonLocal.size()); @@ -2407,22 +2777,12 @@ bool ProjectWorkspace::reanchorEntityPivot(const QString& id, int frame, const Q return false; } - double minX = polyWorld[0].x(); - double minY = polyWorld[0].y(); - double maxX = minX; - double maxY = minY; - for (const QPointF& p : polyWorld) { - minX = std::min(minX, p.x()); - minY = std::min(minY, p.y()); - maxX = std::max(maxX, p.x()); - maxY = std::max(maxY, p.y()); - } - QPointF O_new(newPivotWorld); - O_new.setX(std::clamp(O_new.x(), minX, maxX)); - O_new.setY(std::clamp(O_new.y(), minY, maxY)); + // 按目标枢轴精确重锚:外形世界坐标不变;形心不变,仅改变换原点 + const QPointF O_new = newPivotWorld; const QPointF I_disp = O_anim + (e.imageTopLeftWorld - e.originWorld) * sTotal; const QPointF d = O_new - O_anim; + pivotDelta = d; QVector newLocal; newLocal.reserve(polyWorld.size()); @@ -2433,6 +2793,9 @@ bool ProjectWorkspace::reanchorEntityPivot(const QString& id, int frame, const Q for (auto& k : e.locationKeys) { k.value += d; } + if (!e.parentId.isEmpty()) { + e.parentOffsetWorld += d; + } e.originWorld += d; e.polygonLocal = std::move(newLocal); e.imageTopLeftWorld = e.originWorld + (I_disp - O_new) / sTotal; @@ -2441,7 +2804,16 @@ bool ProjectWorkspace::reanchorEntityPivot(const QString& id, int frame, const Q if (!found) { return false; } - return applyEntities(ents, true, QStringLiteral("属性:枢轴")); + + QVector anims = m_project.animations(); + const bool shiftedAnim = shiftEntityPositionAnimKeys(anims, id, pivotDelta); + if (!applyEntities(ents, true, QStringLiteral("属性:枢轴"))) { + return false; + } + if (shiftedAnim && !applyAnimations(anims, true, QStringLiteral("属性:枢轴(位移轨)"))) { + return false; + } + return true; } bool ProjectWorkspace::reorderEntitiesById(const QStringList& idsInOrder) { @@ -2498,72 +2870,51 @@ bool ProjectWorkspace::moveEntityBy(const QString& id, const QPointF& delta, int } auto ents = m_project.entities(); bool found = false; + bool changedStatic = false; for (auto& e : ents) { if (e.id != id) { continue; } found = true; - Project::AnimationClip* clip = activeClipOrNull(m_project); - QVector* keys = nullptr; - if (clip) { - keys = &clip->entityLocationKeys[e.id]; + // 「无」动画为静态基底,不写位置关键帧。 + if (autoKeyLocation && currentFrame >= 0 && !isNoneAnimationActive(m_project)) { + Project::Animation* anim = activeAnimationOrCreate(m_project); + if (!anim) return false; + // 关键:用“当前帧求值后的基准”写回关键帧,避免拖拽预览(求值态)与落盘(静态态)不一致导致松手瞬移。 + const QPointF worldBase = resolvedOriginAtFrame(m_project, e.id, currentFrame); + QPointF base = worldBase; + if (!e.parentId.isEmpty()) { + const QPointF parentWorld = resolvedOriginAtFrame(m_project, e.parentId, currentFrame); + base = worldBase - parentWorld; // 相对父对象偏移 + } + auto* track = findOrCreateTrack(*anim, + Project::AnimationTargetKind::Entity, + e.id, + Project::AnimationProperty::Position, + Project::AnimationValueType::Vec2, + Project::AnimationInterpolation::Linear); + upsertAnimKey(*track, currentFrame, base + delta); + markAnimationDirty(anim->id); + return writeIndexJson(); } // 父子关系:绑定父对象时,位置曲线表示“相对父对象偏移”。 if (!e.parentId.isEmpty()) { - const bool hasCurve = (keys && !keys->isEmpty()) || (!e.locationKeys.isEmpty()); - if (autoKeyLocation && currentFrame >= 0) { - const QPointF sampled = - (keys) - ? sampleLocation(*keys, currentFrame, e.parentOffsetWorld, KeyInterpolation::Linear) - : sampleLocation(e.locationKeys, currentFrame, e.parentOffsetWorld, KeyInterpolation::Linear); - if (keys) upsertKey(*keys, currentFrame, sampled + delta); - else upsertKey(e.locationKeys, currentFrame, sampled + delta); - } else if (!hasCurve) { - e.parentOffsetWorld += delta; - } else if (currentFrame >= 0) { - const QPointF sampled = - (keys) - ? sampleLocation(*keys, currentFrame, e.parentOffsetWorld, KeyInterpolation::Linear) - : sampleLocation(e.locationKeys, currentFrame, e.parentOffsetWorld, KeyInterpolation::Linear); - if (keys) upsertKey(*keys, currentFrame, sampled + delta); - else upsertKey(e.locationKeys, currentFrame, sampled + delta); - } else { - e.parentOffsetWorld += delta; - } + e.parentOffsetWorld += delta; + changedStatic = true; break; } - const bool hasCurve = (keys && !keys->isEmpty()) || (!e.locationKeys.isEmpty()); - if (autoKeyLocation && currentFrame >= 0) { - const QPointF sampled = - (keys) - ? sampleLocation(*keys, currentFrame, e.originWorld, KeyInterpolation::Linear) - : sampleLocation(e.locationKeys, currentFrame, e.originWorld, KeyInterpolation::Linear); - if (keys) upsertKey(*keys, currentFrame, sampled + delta); - else upsertKey(e.locationKeys, currentFrame, sampled + delta); - } else if (!hasCurve) { - e.originWorld += delta; - e.imageTopLeftWorld += delta; - } else if (currentFrame >= 0) { - const QPointF sampled = - (keys) - ? sampleLocation(*keys, currentFrame, e.originWorld, KeyInterpolation::Linear) - : sampleLocation(e.locationKeys, currentFrame, e.originWorld, KeyInterpolation::Linear); - if (keys) upsertKey(*keys, currentFrame, sampled + delta); - else upsertKey(e.locationKeys, currentFrame, sampled + delta); - } else { - e.originWorld += delta; - e.imageTopLeftWorld += delta; - } + e.originWorld += delta; + e.imageTopLeftWorld += delta; + changedStatic = true; break; } if (!found) { return false; } - if (!applyEntities(ents, true, QStringLiteral("移动实体"))) { - return false; + if (!changedStatic) { + return true; } - // clip 曲线与选中条带存于 project.json,需要保证落盘 - return writeIndexJson(); + return applyEntities(ents, true, QStringLiteral("移动实体")); } bool ProjectWorkspace::addTool(const Project::Tool& tool) { @@ -2664,21 +3015,20 @@ bool ProjectWorkspace::setCameraViewScaleValue(const QString& id, double viewSca return false; } const double v = std::clamp(viewScale, 1e-6, 1e3); + const bool writeAnimated = keyframeAtFrame >= 0 && !isNoneAnimationActive(m_project); + if (writeAnimated) { + return setCameraScaleKey(id, keyframeAtFrame, v); + } auto cams = m_project.cameras(); bool found = false; for (auto& c : cams) { if (c.id != id) continue; found = true; const bool baseSame = qFuzzyCompare(c.viewScale + 1.0, v + 1.0); - c.viewScale = v; - if (keyframeAtFrame >= 0) { - Project::AnimationClip* clip = activeClipOrNull(m_project); - if (!clip) return false; - auto& keys = clip->cameraScaleKeys[id]; - upsertKey(keys, keyframeAtFrame, v); - } else if (baseSame) { + if (baseSame) { return true; } + c.viewScale = v; break; } if (!found) return false; @@ -2694,35 +3044,32 @@ bool ProjectWorkspace::moveCameraBy(const QString& id, const QPointF& delta, int } auto cams = m_project.cameras(); bool found = false; + bool changedStatic = false; for (auto& c : cams) { if (c.id != id) continue; found = true; - Project::AnimationClip* clip = activeClipOrNull(m_project); - QVector* keys = nullptr; - if (clip) { - keys = &clip->cameraLocationKeys[c.id]; - } - const bool hasCurve = (keys && !keys->isEmpty()) || (!c.locationKeys.isEmpty()); - if (autoKeyLocation && currentFrame >= 0) { - const QPointF sampled = - (keys) ? sampleLocation(*keys, currentFrame, c.centerWorld, KeyInterpolation::Linear) - : sampleLocation(c.locationKeys, currentFrame, c.centerWorld, KeyInterpolation::Linear); - if (keys) upsertKey(*keys, currentFrame, sampled + delta); - else upsertKey(c.locationKeys, currentFrame, sampled + delta); - } else if (!hasCurve) { - c.centerWorld += delta; - } else if (currentFrame >= 0) { - const QPointF sampled = - (keys) ? sampleLocation(*keys, currentFrame, c.centerWorld, KeyInterpolation::Linear) - : sampleLocation(c.locationKeys, currentFrame, c.centerWorld, KeyInterpolation::Linear); - if (keys) upsertKey(*keys, currentFrame, sampled + delta); - else upsertKey(c.locationKeys, currentFrame, sampled + delta); - } else { - c.centerWorld += delta; + if (autoKeyLocation && currentFrame >= 0 && !isNoneAnimationActive(m_project)) { + Project::Animation* anim = activeAnimationOrCreate(m_project); + if (!anim) return false; + const QPointF base = resolvedOriginAtFrame(m_project, c.id, currentFrame); + auto* track = findOrCreateTrack(*anim, + Project::AnimationTargetKind::Camera, + c.id, + Project::AnimationProperty::Position, + Project::AnimationValueType::Vec2, + Project::AnimationInterpolation::Linear); + upsertAnimKey(*track, currentFrame, base + delta); + markAnimationDirty(anim->id); + // 同步到摄像机内嵌 keys(兼容旧工程/工具逻辑) + upsertKey(c.locationKeys, currentFrame, base + delta); + return writeIndexJson(); } + c.centerWorld += delta; + changedStatic = true; break; } if (!found) return false; + if (!changedStatic) return true; return applyCameras(cams, true, QStringLiteral("移动摄像机")); } @@ -2730,65 +3077,117 @@ bool ProjectWorkspace::setCameraLocationKey(const QString& id, int frame, const if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { return false; } - Project::AnimationClip* clip = activeClipOrNull(m_project); - if (!clip) return false; - auto& keys = clip->cameraLocationKeys[id]; - upsertKey(keys, frame, centerWorld); - return writeIndexJson(); + if (isNoneAnimationActive(m_project)) { + return false; + } + const auto beforeAnimations = m_project.animations(); + Project::Animation* anim = activeAnimationOrCreate(m_project); + if (!anim) return false; + auto* track = findOrCreateTrack(*anim, + Project::AnimationTargetKind::Camera, + id, + Project::AnimationProperty::Position, + Project::AnimationValueType::Vec2, + Project::AnimationInterpolation::Linear); + upsertAnimKey(*track, frame, centerWorld); + markAnimationDirty(anim->id); + const auto afterAnimations = m_project.animations(); + m_project.setAnimations(beforeAnimations); + return applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(摄像机位置)")); } bool ProjectWorkspace::setCameraScaleKey(const QString& id, int frame, double viewScale) { if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { return false; } + if (isNoneAnimationActive(m_project)) { + return false; + } const double v = std::clamp(viewScale, 1e-6, 1e3); - Project::AnimationClip* clip = activeClipOrNull(m_project); - if (!clip) return false; - auto& keys = clip->cameraScaleKeys[id]; - upsertKey(keys, frame, v); - return writeIndexJson(); + const auto beforeAnimations = m_project.animations(); + Project::Animation* anim = activeAnimationOrCreate(m_project); + if (!anim) return false; + auto* track = findOrCreateTrack(*anim, + Project::AnimationTargetKind::Camera, + id, + Project::AnimationProperty::CameraScale, + Project::AnimationValueType::Double, + Project::AnimationInterpolation::Linear); + upsertAnimKey(*track, frame, v); + markAnimationDirty(anim->id); + const auto afterAnimations = m_project.animations(); + m_project.setAnimations(beforeAnimations); + return applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(摄像机缩放)")); } bool ProjectWorkspace::removeCameraLocationKey(const QString& id, int frame) { if (m_projectDir.isEmpty() || id.isEmpty()) { return false; } - Project::AnimationClip* clip = activeClipOrNull(m_project); - if (!clip) return false; - if (!clip->cameraLocationKeys.contains(id)) return false; - auto keys = clip->cameraLocationKeys.value(id); - bool removed = false; - for (int i = 0; i < keys.size(); ++i) { - if (keys[i].frame == frame) { - keys.removeAt(i); - removed = true; - break; + if (isNoneAnimationActive(m_project)) { + return false; + } + const auto beforeAnimations = m_project.animations(); + bool changed = false; + if (Project::Animation* anim = activeAnimationOrCreate(m_project)) { + for (auto& t : anim->tracks) { + if (t.targetKind != Project::AnimationTargetKind::Camera || + t.targetId != id || + t.property != Project::AnimationProperty::Position) { + continue; + } + for (int i = 0; i < t.keys.size(); ++i) { + if (t.keys[i].frame == frame) { + t.keys.removeAt(i); + changed = true; + break; + } + } + } + if (changed) { + pruneEmptyAnimationTracks(*anim); + markAnimationDirty(anim->id); } } - if (!removed) return false; - clip->cameraLocationKeys.insert(id, keys); - return writeIndexJson(); + if (!changed) return true; + const auto afterAnimations = m_project.animations(); + m_project.setAnimations(beforeAnimations); + return applyAnimations(afterAnimations, true, QStringLiteral("删除关键帧(摄像机位置)")); } bool ProjectWorkspace::removeCameraScaleKey(const QString& id, int frame) { if (m_projectDir.isEmpty() || id.isEmpty()) { return false; } - Project::AnimationClip* clip = activeClipOrNull(m_project); - if (!clip) return false; - if (!clip->cameraScaleKeys.contains(id)) return false; - auto keys = clip->cameraScaleKeys.value(id); - bool removed = false; - for (int i = 0; i < keys.size(); ++i) { - if (keys[i].frame == frame) { - keys.removeAt(i); - removed = true; - break; + if (isNoneAnimationActive(m_project)) { + return false; + } + const auto beforeAnimations = m_project.animations(); + bool changed = false; + if (Project::Animation* anim = activeAnimationOrCreate(m_project)) { + for (auto& t : anim->tracks) { + if (t.targetKind != Project::AnimationTargetKind::Camera || + t.targetId != id || + t.property != Project::AnimationProperty::CameraScale) { + continue; + } + for (int i = 0; i < t.keys.size(); ++i) { + if (t.keys[i].frame == frame) { + t.keys.removeAt(i); + changed = true; + break; + } + } + } + if (changed) { + pruneEmptyAnimationTracks(*anim); + markAnimationDirty(anim->id); } } - if (!removed) return false; - clip->cameraScaleKeys.insert(id, keys); - return writeIndexJson(); + if (!changed) return true; + const auto afterAnimations = m_project.animations(); + m_project.setAnimations(beforeAnimations); + return applyAnimations(afterAnimations, true, QStringLiteral("删除关键帧(摄像机缩放)")); } bool ProjectWorkspace::setToolVisible(const QString& id, bool on) { @@ -2882,32 +3281,88 @@ bool ProjectWorkspace::setToolVisibilityKey(const QString& id, int frame, bool v if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { return false; } - Project::AnimationClip* clip = activeClipOrNull(m_project); - if (!clip) return false; - auto& keys = clip->toolVisibilityKeys[id]; - upsertBoolKey(keys, frame, visible); - return writeIndexJson(); + if (isNoneAnimationActive(m_project)) { + return false; + } + const auto beforeAnimations = m_project.animations(); + Project::Animation* anim = activeAnimationOrCreate(m_project); + if (!anim) return false; + auto* track = findOrCreateTrack(*anim, + Project::AnimationTargetKind::Tool, + id, + Project::AnimationProperty::Visibility, + Project::AnimationValueType::Bool, + Project::AnimationInterpolation::Hold); + upsertAnimKey(*track, frame, visible); + markAnimationDirty(anim->id); + const auto afterAnimations = m_project.animations(); + m_project.setAnimations(beforeAnimations); + return applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(工具显隐)")); +} + +bool ProjectWorkspace::setToolPriorityKey(const QString& id, int frame, int priority) { + if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { + return false; + } + if (isNoneAnimationActive(m_project)) { + auto tools = m_project.tools(); + for (auto& t : tools) { + if (t.id != id) continue; + t.priority = priority; + return applyTools(tools, true, QStringLiteral("工具优先级")); + } + return false; + } + const double v = std::clamp(double(priority), -1e6, 1e6); + const auto beforeAnimations = m_project.animations(); + Project::Animation* anim = activeAnimationOrCreate(m_project); + if (!anim) return false; + auto* track = findOrCreateTrack(*anim, + Project::AnimationTargetKind::Tool, + id, + Project::AnimationProperty::Priority, + Project::AnimationValueType::Double, + Project::AnimationInterpolation::Hold); + upsertAnimKey(*track, frame, v); + markAnimationDirty(anim->id); + const auto afterAnimations = m_project.animations(); + m_project.setAnimations(beforeAnimations); + return applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(工具优先级)")); } bool ProjectWorkspace::removeToolVisibilityKey(const QString& id, int frame) { if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { return false; } - Project::AnimationClip* clip = activeClipOrNull(m_project); - if (!clip) return false; - if (!clip->toolVisibilityKeys.contains(id)) return true; - auto keys = clip->toolVisibilityKeys.value(id); + if (isNoneAnimationActive(m_project)) { + return false; + } + const auto beforeAnimations = m_project.animations(); bool changed = false; - for (int i = 0; i < keys.size(); ++i) { - if (keys[i].frame == frame) { - keys.removeAt(i); - changed = true; - break; + if (Project::Animation* anim = activeAnimationOrCreate(m_project)) { + for (auto& t : anim->tracks) { + if (t.targetKind != Project::AnimationTargetKind::Tool || + t.targetId != id || + t.property != Project::AnimationProperty::Visibility) { + continue; + } + for (int i = 0; i < t.keys.size(); ++i) { + if (t.keys[i].frame == frame) { + t.keys.removeAt(i); + changed = true; + break; + } + } + } + if (changed) { + pruneEmptyAnimationTracks(*anim); + markAnimationDirty(anim->id); } } if (!changed) return true; - clip->toolVisibilityKeys.insert(id, keys); - return writeIndexJson(); + const auto afterAnimations = m_project.animations(); + m_project.setAnimations(beforeAnimations); + return applyAnimations(afterAnimations, true, QStringLiteral("删除关键帧(工具显隐)")); } bool ProjectWorkspace::setToolParent(const QString& id, const QString& parentId, const QPointF& parentOffsetWorld) { @@ -2959,15 +3414,26 @@ bool ProjectWorkspace::setToolParent(const QString& id, const QString& parentId, const QPointF oldParentOStart = oldPid.isEmpty() ? QPointF() : parentOriginAt(oldPid, frameStart); const QPointF baseWorldAtStart = oldPid.isEmpty() ? oldBaseStored : (oldParentOStart + oldBaseStored); - if (Project::AnimationClip* clip = activeClipOrNull(m_project)) { - if (clip->toolLocationKeys.contains(t.id)) { - auto k = clip->toolLocationKeys.value(t.id); - convertKeys(k, oldPid, parentId); - clip->toolLocationKeys.insert(t.id, k); - } - } convertKeys(t.locationKeys, oldPid, parentId); + if (Project::Animation* anim = activeAnimationOrCreate(m_project)) { + for (auto& tr : anim->tracks) { + if (tr.targetKind != Project::AnimationTargetKind::Tool || + tr.targetId != t.id || + tr.property != Project::AnimationProperty::Position || + tr.valueType != Project::AnimationValueType::Vec2) { + continue; + } + for (auto& k : tr.keys) { + const QPointF oldParentO = oldPid.isEmpty() ? QPointF() : parentOriginAt(oldPid, k.frame); + const QPointF world = oldPid.isEmpty() ? k.vec2Value : (oldParentO + k.vec2Value); + const QPointF newParentO = parentId.isEmpty() ? QPointF() : parentOriginAt(parentId, k.frame); + k.vec2Value = parentId.isEmpty() ? world : (world - newParentO); + } + } + markAnimationDirty(anim->id); + } + t.parentId = parentId; if (parentId.isEmpty()) { t.originWorld = baseWorldAtStart; @@ -2993,72 +3459,52 @@ bool ProjectWorkspace::moveToolBy(const QString& id, const QPointF& delta, int c } auto tools = m_project.tools(); bool found = false; + bool changedStatic = false; for (auto& t : tools) { if (t.id != id) continue; found = true; - Project::AnimationClip* clip = activeClipOrNull(m_project); - QVector* keys = nullptr; - if (clip) { - keys = &clip->toolLocationKeys[t.id]; + if (autoKeyLocation && currentFrame >= 0 && !isNoneAnimationActive(m_project)) { + Project::Animation* anim = activeAnimationOrCreate(m_project); + if (!anim) return false; + const QPointF worldBase = resolvedOriginAtFrame(m_project, t.id, currentFrame); + QPointF base = worldBase; + if (!t.parentId.isEmpty()) { + const QPointF parentWorld = resolvedOriginAtFrame(m_project, t.parentId, currentFrame); + base = worldBase - parentWorld; + } + auto* track = findOrCreateTrack(*anim, + Project::AnimationTargetKind::Tool, + t.id, + Project::AnimationProperty::Position, + Project::AnimationValueType::Vec2, + Project::AnimationInterpolation::Linear); + upsertAnimKey(*track, currentFrame, base + delta); + markAnimationDirty(anim->id); + // 同步到工具内嵌 keys(兼容旧工程/工具逻辑) + upsertKey(t.locationKeys, currentFrame, base + delta); + return writeIndexJson(); } if (!t.parentId.isEmpty()) { - const bool hasCurve = (keys && !keys->isEmpty()) || (!t.locationKeys.isEmpty()); - if (autoKeyLocation && currentFrame >= 0) { - const QPointF sampled = - (keys) - ? sampleLocation(*keys, currentFrame, t.parentOffsetWorld, KeyInterpolation::Linear) - : sampleLocation(t.locationKeys, currentFrame, t.parentOffsetWorld, KeyInterpolation::Linear); - if (keys) upsertKey(*keys, currentFrame, sampled + delta); - else upsertKey(t.locationKeys, currentFrame, sampled + delta); - } else if (!hasCurve) { - t.parentOffsetWorld += delta; - } else if (currentFrame >= 0) { - const QPointF sampled = - (keys) - ? sampleLocation(*keys, currentFrame, t.parentOffsetWorld, KeyInterpolation::Linear) - : sampleLocation(t.locationKeys, currentFrame, t.parentOffsetWorld, KeyInterpolation::Linear); - if (keys) upsertKey(*keys, currentFrame, sampled + delta); - else upsertKey(t.locationKeys, currentFrame, sampled + delta); - } else { - t.parentOffsetWorld += delta; - } + t.parentOffsetWorld += delta; + changedStatic = true; break; } - const bool hasCurve = (keys && !keys->isEmpty()) || (!t.locationKeys.isEmpty()); - if (autoKeyLocation && currentFrame >= 0) { - const QPointF sampled = - (keys) - ? sampleLocation(*keys, currentFrame, t.originWorld, KeyInterpolation::Linear) - : sampleLocation(t.locationKeys, currentFrame, t.originWorld, KeyInterpolation::Linear); - if (keys) upsertKey(*keys, currentFrame, sampled + delta); - else upsertKey(t.locationKeys, currentFrame, sampled + delta); - } else if (!hasCurve) { - t.originWorld += delta; - } else if (currentFrame >= 0) { - const QPointF sampled = - (keys) - ? sampleLocation(*keys, currentFrame, t.originWorld, KeyInterpolation::Linear) - : sampleLocation(t.locationKeys, currentFrame, t.originWorld, KeyInterpolation::Linear); - if (keys) upsertKey(*keys, currentFrame, sampled + delta); - else upsertKey(t.locationKeys, currentFrame, sampled + delta); - } else { - t.originWorld += delta; - } + t.originWorld += delta; + changedStatic = true; break; } if (!found) return false; - if (!applyTools(tools, true, QStringLiteral("移动工具"))) { - return false; - } - return writeIndexJson(); + if (!changedStatic) return true; + return applyTools(tools, true, QStringLiteral("移动工具")); } bool ProjectWorkspace::setEntityLocationKey(const QString& id, int frame, const QPointF& originWorld) { if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { return false; } - Project::AnimationClip* clip = activeClipOrNull(m_project); - if (!clip) return false; + if (isNoneAnimationActive(m_project)) { + return false; + } QPointF keyValue = originWorld; for (const auto& e : m_project.entities()) { if (e.id == id && !e.parentId.isEmpty()) { @@ -3067,38 +3513,133 @@ bool ProjectWorkspace::setEntityLocationKey(const QString& id, int frame, const break; } } - auto& keys = clip->entityLocationKeys[id]; - upsertKey(keys, frame, keyValue); - return writeIndexJson(); + const auto beforeAnimations = m_project.animations(); + Project::Animation* anim = activeAnimationOrCreate(m_project); + if (!anim) return false; + auto* track = findOrCreateTrack(*anim, + Project::AnimationTargetKind::Entity, + id, + Project::AnimationProperty::Position, + Project::AnimationValueType::Vec2, + Project::AnimationInterpolation::Linear); + if (!track) return false; + upsertAnimKey(*track, frame, keyValue); + markAnimationDirty(anim->id); + const auto afterAnimations = m_project.animations(); + m_project.setAnimations(beforeAnimations); + return applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(位置)")); } bool ProjectWorkspace::setEntityDepthScaleKey(const QString& id, int frame, double value01) { if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { return false; } - const double v = std::clamp(value01, 0.0, 1.0); - auto ents = m_project.entities(); - bool found = false; - for (auto& e : ents) { - if (e.id != id) continue; - found = true; - upsertKey(e.depthScaleKeys, frame, v); - break; + if (isNoneAnimationActive(m_project)) { + return false; } - if (!found) return false; - return applyEntities(ents, true, QStringLiteral("插入关键帧(缩放)")); + const double v = std::clamp(value01, 0.0, 1.0); + const auto beforeAnimations = m_project.animations(); + Project::Animation* anim = activeAnimationOrCreate(m_project); + if (!anim) return false; + auto* track = findOrCreateTrack(*anim, + Project::AnimationTargetKind::Entity, + id, + Project::AnimationProperty::DepthScale, + Project::AnimationValueType::Double, + Project::AnimationInterpolation::Linear); + if (!track) return false; + upsertAnimKey(*track, frame, v); + markAnimationDirty(anim->id); + const auto afterAnimations = m_project.animations(); + m_project.setAnimations(beforeAnimations); + return applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(深度缩放)")); } bool ProjectWorkspace::setEntityUserScaleKey(const QString& id, int frame, double userScale) { if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { return false; } + if (isNoneAnimationActive(m_project)) { + return false; + } const double v = std::clamp(userScale, 1e-6, 1e3); - Project::AnimationClip* clip = activeClipOrNull(m_project); - if (!clip) return false; - auto& keys = clip->entityUserScaleKeys[id]; - upsertKey(keys, frame, v); - return writeIndexJson(); + const auto beforeAnimations = m_project.animations(); + Project::Animation* anim = activeAnimationOrCreate(m_project); + if (!anim) return false; + auto* track = findOrCreateTrack(*anim, + Project::AnimationTargetKind::Entity, + id, + Project::AnimationProperty::UserScale, + Project::AnimationValueType::Double, + Project::AnimationInterpolation::Linear); + if (!track) return false; + upsertAnimKey(*track, frame, v); + markAnimationDirty(anim->id); + const auto afterAnimations = m_project.animations(); + m_project.setAnimations(beforeAnimations); + return applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(整体缩放)")); +} + +bool ProjectWorkspace::setEntityPriorityKey(const QString& id, int frame, int priority) { + if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { + return false; + } + if (isNoneAnimationActive(m_project)) { + // “无”动画:直接写静态值 + auto ents = m_project.entities(); + for (auto& e : ents) { + if (e.id != id) continue; + e.priority = priority; + return applyEntities(ents, true, QStringLiteral("优先级")); + } + return false; + } + const double v = std::clamp(double(priority), -1e6, 1e6); + const auto beforeAnimations = m_project.animations(); + Project::Animation* anim = activeAnimationOrCreate(m_project); + if (!anim) return false; + auto* track = findOrCreateTrack(*anim, + Project::AnimationTargetKind::Entity, + id, + Project::AnimationProperty::Priority, + Project::AnimationValueType::Double, + Project::AnimationInterpolation::Hold); + if (!track) return false; + upsertAnimKey(*track, frame, v); + markAnimationDirty(anim->id); + const auto afterAnimations = m_project.animations(); + m_project.setAnimations(beforeAnimations); + return applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(优先级)")); +} + +bool ProjectWorkspace::setEntityDefaultImage(const QString& id, const QImage& image, QString* outRelPath) { + if (outRelPath) outRelPath->clear(); + if (m_projectDir.isEmpty() || id.isEmpty() || image.isNull()) { + return false; + } + QByteArray png; + { + QBuffer buf(&png); + if (!buf.open(QIODevice::WriteOnly) || !image.save(&buf, "PNG")) { + return false; + } + } + + auto ents = m_project.entities(); + bool found = false; + for (auto& e : ents) { + if (e.id != id) continue; + found = true; + e.imagePath.clear(); + e.defaultImagePng = png; + break; + } + if (!found) return false; + if (!applyEntities(ents, true, QStringLiteral("更换实体本体图"))) { + return false; + } + if (outRelPath) *outRelPath = QString(); + return true; } bool ProjectWorkspace::setEntityImageFrame(const QString& id, int frame, const QImage& image, QString* outRelPath) { @@ -3106,16 +3647,34 @@ bool ProjectWorkspace::setEntityImageFrame(const QString& id, int frame, const Q if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0 || image.isNull()) { return false; } - QString rel; - if (!writeEntityFrameImage(id, frame, image, rel)) { + if (isNoneAnimationActive(m_project)) { return false; } - Project::AnimationClip* clip = activeClipOrNull(m_project); - if (!clip) return false; - auto& frames = clip->entityImageFrames[id]; - upsertFrame(frames, frame, rel); - if (!writeIndexJson()) return false; - if (outRelPath) *outRelPath = rel; + // 逐帧动画帧:按用户要求存入二进制(.hfa),不写出单独的图片文件。 + QByteArray png; + { + QBuffer buf(&png); + if (!buf.open(QIODevice::WriteOnly) || !image.save(&buf, "PNG")) { + return false; + } + } + const auto beforeAnimations = m_project.animations(); + Project::Animation* anim = activeAnimationOrCreate(m_project); + if (!anim) return false; + Project::AnimationTrack* track = findOrCreateTrack( + *anim, + Project::AnimationTargetKind::Entity, + id, + Project::AnimationProperty::Sprite, + Project::AnimationValueType::PngBytes, + Project::AnimationInterpolation::Hold); + if (!track) return false; + upsertAnimKey(*track, frame, png); + markAnimationDirty(anim->id); + const auto afterAnimations = m_project.animations(); + m_project.setAnimations(beforeAnimations); + if (!applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(图像)"))) return false; + if (outRelPath) *outRelPath = QString(); // 不再产生资源路径 return true; } @@ -3123,20 +3682,228 @@ bool ProjectWorkspace::setEntityImageFramePath(const QString& id, int frame, con if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { return false; } - const QString rel = relativePath.trimmed(); - if (rel.isEmpty()) { + if (isNoneAnimationActive(m_project)) { return false; } - auto ents = m_project.entities(); - bool found = false; - for (auto& e : ents) { - if (e.id != id) continue; - found = true; - upsertFrame(e.imageFrames, frame, rel); - break; + Q_UNUSED(relativePath); + // 不再支持路径型贴图关键帧(仅支持二进制 PNG bytes)。 + return false; +} + +QVector ProjectWorkspace::entitySpriteFrames(const QString& id) const { + const Project::Animation* anim = m_project.activeAnimationOrNull(); + if (anim) { + return spriteFramesForEntity(*anim, id); } - if (!found) return false; - return applyEntities(ents, true, QStringLiteral("插入关键帧(图像)")); + return {}; +} + +QString ProjectWorkspace::entityImagePathAt(const QString& id, int frame) const { + const Project::Entity* entity = nullptr; + for (const auto& e : m_project.entities()) { + if (e.id == id) { + entity = &e; + break; + } + } + if (!entity) return {}; + const QByteArray png = entityImagePngAt(id, frame); + if (png.isEmpty()) return {}; + return QStringLiteral("pngb64:") + QString::fromLatin1(png.toBase64()); +} + +QByteArray ProjectWorkspace::entityImagePngAt(const QString& id, int frame) const { + const Project::Entity* entity = nullptr; + for (const auto& e : m_project.entities()) { + if (e.id == id) { + entity = &e; + break; + } + } + if (!entity) return {}; + if (const Project::Animation* anim = m_project.activeAnimationOrNull()) { + for (const auto& t : anim->tracks) { + if (t.targetKind != Project::AnimationTargetKind::Entity || + t.targetId != id || + t.property != Project::AnimationProperty::Sprite) { + continue; + } + if (t.valueType != Project::AnimationValueType::PngBytes) { + continue; + } + QByteArray best; + int bestFrame = -1; + for (const auto& k : t.keys) { + if (k.frame <= frame && k.frame >= bestFrame && !k.bytesValue.isEmpty()) { + bestFrame = k.frame; + best = k.bytesValue; + } + } + if (!best.isEmpty()) return best; + } + } + return entity->defaultImagePng; +} + +bool ProjectWorkspace::clearEntityImageFramesInRange(const QString& id, int startFrame, int endFrame) { + if (m_projectDir.isEmpty() || id.isEmpty()) return false; + if (isNoneAnimationActive(m_project)) { + return false; + } + const int a = std::min(startFrame, endFrame); + const int b = std::max(startFrame, endFrame); + const auto beforeAnimations = m_project.animations(); + bool changed = false; + + if (Project::Animation* anim = activeAnimationOrCreate(m_project)) { + for (auto& t : anim->tracks) { + if (t.targetKind != Project::AnimationTargetKind::Entity || + t.targetId != id || + t.property != Project::AnimationProperty::Sprite) { + continue; + } + const int before = static_cast(t.keys.size()); + for (int i = static_cast(t.keys.size()) - 1; i >= 0; --i) { + if (t.keys[i].frame >= a && t.keys[i].frame <= b) { + t.keys.removeAt(i); + } + } + changed = changed || (before != t.keys.size()); + } + if (changed) { + pruneEmptyAnimationTracks(*anim); + markAnimationDirty(anim->id); + } + } + + if (!changed) return true; + const auto afterAnimations = m_project.animations(); + m_project.setAnimations(beforeAnimations); + return applyAnimations(afterAnimations, true, QStringLiteral("清除区间图像帧")); +} + +int ProjectWorkspace::activeAnimationLengthFrames() const { + if (const Project::Animation* anim = m_project.activeAnimationOrNull()) { + return std::max(1, anim->lengthFrames); + } + return Project::kClipFixedFrames; +} + +int ProjectWorkspace::activeAnimationFps() const { + if (const Project::Animation* anim = m_project.activeAnimationOrNull()) { + return std::max(1, anim->fps); + } + return std::max(1, m_project.fps()); +} + +int ProjectWorkspace::animationLengthFramesForId(const QString& animationId) const { + if (const Project::Animation* anim = m_project.findAnimationById(animationId)) { + return std::max(1, anim->lengthFrames); + } + return Project::kClipFixedFrames; +} + +int ProjectWorkspace::animationFpsForId(const QString& animationId) const { + if (const Project::Animation* anim = m_project.findAnimationById(animationId)) { + return std::max(1, anim->fps); + } + return std::max(1, m_project.fps()); +} + +bool ProjectWorkspace::animationLoopsForId(const QString& animationId) const { + if (const Project::Animation* anim = m_project.findAnimationById(animationId)) { + return anim->loop; + } + return false; +} + +static void pruneAnimationTracksToLength(Project::Animation& anim, int lengthFramesCap) { + const int cap = std::max(1, lengthFramesCap); + for (auto& t : anim.tracks) { + QVector kept; + kept.reserve(t.keys.size()); + for (const auto& k : t.keys) { + if (k.frame >= 0 && k.frame < cap) { + kept.push_back(k); + } + } + t.keys = std::move(kept); + } +} + +static QString nextUniqueAnimationId(const QVector& anims, const QString& prefix) { + QStringList ids; + ids.reserve(anims.size()); + for (const auto& a : anims) { + ids.push_back(a.id); + } + int n = 1; + while (ids.contains(prefix + QString::number(n))) { + ++n; + } + return prefix + QString::number(n); +} + +bool ProjectWorkspace::createAnimationScheme(const QString& displayName, int lengthFrames, bool loop, int fps, + QString* outNewId) { + if (!isOpen() || lengthFrames < 1) { + return false; + } + const int fp = std::max(1, fps); + const QString nm = displayName.trimmed().isEmpty() ? QStringLiteral("方案_%1").arg(project().animations().size()) + : displayName.trimmed(); + + Project::Animation a; + a.id = nextUniqueAnimationId(project().animations(), QStringLiteral("animation-")); + a.name = nm; + a.payloadPath = core::persistence::AnimationBundleStore::bundleRelativeDir(a.id); + a.fps = fp; + a.lengthFrames = lengthFrames; + a.loop = loop; + + QVector next = project().animations(); + next.push_back(a); + if (!applyAnimations(next, true, QStringLiteral("新建动画方案"))) { + return false; + } + project().setActiveAnimationId(a.id); + if (!writeIndexJson()) { + return false; + } + if (outNewId) { + *outNewId = a.id; + } + return true; +} + +bool ProjectWorkspace::updateAnimationSchemeSettings(const QString& id, const QString& displayName, + int lengthFrames, bool loop, int fps) { + if (!isOpen() || id.isEmpty() || lengthFrames < 1) { + return false; + } + QVector next = project().animations(); + int ix = -1; + for (int i = 0; i < next.size(); ++i) { + if (next[i].id == id) { + ix = i; + break; + } + } + if (ix < 0) { + return false; + } + Project::Animation a = next[ix]; + a.name = displayName.trimmed().isEmpty() ? a.name : displayName.trimmed(); + a.fps = std::max(1, fps); + a.loop = loop; + a.lengthFrames = std::max(1, lengthFrames); + pruneAnimationTracksToLength(a, a.lengthFrames); + next[ix] = a; + + if (!applyAnimations(next, true, QStringLiteral("动画方案设置"))) { + return false; + } + return writeIndexJson(); } namespace { @@ -3187,63 +3954,140 @@ bool ProjectWorkspace::removeEntityLocationKey(const QString& id, int frame) { if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { return false; } - Project::AnimationClip* clip = activeClipOrNull(m_project); - if (!clip) return false; - if (!clip->entityLocationKeys.contains(id)) return false; - auto keys = clip->entityLocationKeys.value(id); - const bool removed = removeLocationKeyAtFrame(keys, frame); - if (!removed) return false; - clip->entityLocationKeys.insert(id, keys); - return writeIndexJson(); + if (isNoneAnimationActive(m_project)) { + return false; + } + const auto beforeAnimations = m_project.animations(); + bool changed = false; + if (Project::Animation* anim = activeAnimationOrCreate(m_project)) { + for (auto& t : anim->tracks) { + if (t.targetKind != Project::AnimationTargetKind::Entity || + t.targetId != id || + t.property != Project::AnimationProperty::Position) { + continue; + } + for (int i = 0; i < t.keys.size(); ++i) { + if (t.keys[i].frame == frame) { + t.keys.removeAt(i); + changed = true; + break; + } + } + } + if (changed) { + pruneEmptyAnimationTracks(*anim); + markAnimationDirty(anim->id); + } + } + if (!changed) return true; + const auto afterAnimations = m_project.animations(); + m_project.setAnimations(beforeAnimations); + return applyAnimations(afterAnimations, true, QStringLiteral("删除关键帧(位置)")); } bool ProjectWorkspace::removeEntityDepthScaleKey(const QString& id, int frame) { if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { return false; } - auto ents = m_project.entities(); - bool found = false; - bool removed = false; - for (auto& e : ents) { - if (e.id != id) { - continue; - } - found = true; - removed = removeDepthKeyAtFrame(e.depthScaleKeys, frame); - break; - } - if (!found || !removed) { + if (isNoneAnimationActive(m_project)) { return false; } - return applyEntities(ents, true, QStringLiteral("删除关键帧(缩放)")); + const auto beforeAnimations = m_project.animations(); + bool changed = false; + if (Project::Animation* anim = activeAnimationOrCreate(m_project)) { + for (auto& t : anim->tracks) { + if (t.targetKind != Project::AnimationTargetKind::Entity || + t.targetId != id || + t.property != Project::AnimationProperty::DepthScale) { + continue; + } + for (int i = 0; i < t.keys.size(); ++i) { + if (t.keys[i].frame == frame) { + t.keys.removeAt(i); + changed = true; + break; + } + } + } + if (changed) { + pruneEmptyAnimationTracks(*anim); + markAnimationDirty(anim->id); + } + } + if (!changed) return true; + const auto afterAnimations = m_project.animations(); + m_project.setAnimations(beforeAnimations); + return applyAnimations(afterAnimations, true, QStringLiteral("删除关键帧(深度缩放)")); } bool ProjectWorkspace::removeEntityUserScaleKey(const QString& id, int frame) { if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { return false; } - Project::AnimationClip* clip = activeClipOrNull(m_project); - if (!clip) return false; - if (!clip->entityUserScaleKeys.contains(id)) return false; - auto keys = clip->entityUserScaleKeys.value(id); - const bool removed = removeUserScaleKeyAtFrame(keys, frame); - if (!removed) return false; - clip->entityUserScaleKeys.insert(id, keys); - return writeIndexJson(); + if (isNoneAnimationActive(m_project)) { + return false; + } + const auto beforeAnimations = m_project.animations(); + bool changed = false; + if (Project::Animation* anim = activeAnimationOrCreate(m_project)) { + for (auto& t : anim->tracks) { + if (t.targetKind != Project::AnimationTargetKind::Entity || + t.targetId != id || + t.property != Project::AnimationProperty::UserScale) { + continue; + } + for (int i = 0; i < t.keys.size(); ++i) { + if (t.keys[i].frame == frame) { + t.keys.removeAt(i); + changed = true; + break; + } + } + } + if (changed) { + pruneEmptyAnimationTracks(*anim); + markAnimationDirty(anim->id); + } + } + if (!changed) return true; + const auto afterAnimations = m_project.animations(); + m_project.setAnimations(beforeAnimations); + return applyAnimations(afterAnimations, true, QStringLiteral("删除关键帧(整体缩放)")); } bool ProjectWorkspace::removeEntityImageFrame(const QString& id, int frame) { if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { return false; } - Project::AnimationClip* clip = activeClipOrNull(m_project); - if (!clip) return false; - if (!clip->entityImageFrames.contains(id)) return false; - auto keys = clip->entityImageFrames.value(id); - const bool removed = removeImageKeyAtFrame(keys, frame); - if (!removed) return false; - clip->entityImageFrames.insert(id, keys); - return writeIndexJson(); + if (isNoneAnimationActive(m_project)) { + return false; + } + const auto beforeAnimations = m_project.animations(); + bool removedAny = false; + if (Project::Animation* anim = activeAnimationOrCreate(m_project)) { + for (auto& t : anim->tracks) { + if (t.targetKind != Project::AnimationTargetKind::Entity || + t.targetId != id || + t.property != Project::AnimationProperty::Sprite) { + continue; + } + for (int i = 0; i < t.keys.size(); ++i) { + if (t.keys[i].frame == frame) { + t.keys.removeAt(i); + removedAny = true; + break; + } + } + } + if (removedAny) { + pruneEmptyAnimationTracks(*anim); + markAnimationDirty(anim->id); + } + } + if (!removedAny) return false; + const auto afterAnimations = m_project.animations(); + m_project.setAnimations(beforeAnimations); + return applyAnimations(afterAnimations, true, QStringLiteral("删除关键帧(图像)")); } QString ProjectWorkspace::copyIntoAssetsAsBackground(const QString& sourceFilePath, @@ -3266,32 +4110,55 @@ QString ProjectWorkspace::copyIntoAssetsAsBackground(const QString& sourceFilePa const auto destAbs = QDir(assetsDir).filePath(fileName); const auto destRel = QString::fromUtf8(kAssetsDirName) + "/" + fileName; - // Qt 默认的 image allocation limit 较小,超大分辨率背景可能会被拒绝。 - // 这里提高 limit,并对极端大图按像素数上限自动缩放后再裁剪落盘。 -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) - QImageReader::setAllocationLimit(1024); // MB -#endif - QImageReader reader(sourceFilePath); - reader.setAutoTransform(true); - const QSize sz = reader.size(); - if (sz.isValid()) { - constexpr qint64 kMaxPixels = 160LL * 1000LL * 1000LL; // 160MP - const qint64 pixels = qint64(sz.width()) * qint64(sz.height()); - if (pixels > kMaxPixels) { - const double s = std::sqrt(double(kMaxPixels) / std::max(1.0, double(pixels))); - const int nw = std::max(1, int(std::lround(sz.width() * s))); - const int nh = std::max(1, int(std::lround(sz.height() * s))); - reader.setScaledSize(QSize(nw, nh)); + const auto tmpAbs = destAbs + ".tmp"; + +#ifdef LT_HAVE_VIPS + if (VipsBackend::isAvailable()) { + QSize srcSz; + if (VipsBackend::probeSize(sourceFilePath, &srcSz) && srcSz.isValid()) { + const QRect full(0, 0, srcSz.width(), srcSz.height()); + const QRect clamped = cropRectInSourceImage.isNull() + ? full + : clampRectToImage(cropRectInSourceImage, srcSz); + if (!clamped.isNull() && clamped.width() > 0 && clamped.height() > 0) { + if (QFileInfo::exists(tmpAbs)) { + QFile::remove(tmpAbs); + } + if (VipsBackend::saveRectToPng(sourceFilePath, clamped, tmpAbs)) { + QFile::remove(destAbs); + if (QFile::rename(tmpAbs, destAbs)) { + return destRel; + } + } + QFile::remove(tmpAbs); + } } } +#endif + + // 回退:Qt 解码(像素上限),裁剪框需映射到缩放后的 QImage + core::image_decode::prepareLargeImageReader(); + QImageReader reader(sourceFilePath); + reader.setAutoTransform(true); + const QSize srcLogicalSize = reader.size(); + core::image_decode::downscaleReaderIfExceeds(reader, core::image_decode::kWorkspaceMaxPixels); QImage img = reader.read(); if (img.isNull()) { return {}; } - const QRect crop = cropRectInSourceImage.isNull() ? QRect(0, 0, img.width(), img.height()) - : clampRectToImage(cropRectInSourceImage, img.size()); - if (crop.isNull()) { + QRect crop; + if (cropRectInSourceImage.isNull()) { + crop = QRect(0, 0, img.width(), img.height()); + } else if (srcLogicalSize.isValid() && srcLogicalSize.width() > 0 && srcLogicalSize.height() > 0 && + img.size() != srcLogicalSize) { + const QRect clampedSrc = clampRectToImage(cropRectInSourceImage, srcLogicalSize); + crop = core::image_decode::mapRectBetweenSizes(clampedSrc, srcLogicalSize, img.size()); + } else { + crop = clampRectToImage(cropRectInSourceImage, img.size()); + } + crop = crop.intersected(img.rect()); + if (crop.isNull() || crop.width() <= 0 || crop.height() <= 0) { return {}; } const QImage cropped = img.copy(crop); @@ -3299,8 +4166,6 @@ QString ProjectWorkspace::copyIntoAssetsAsBackground(const QString& sourceFilePa return {}; } - // 覆盖式更新背景:先写临时文件,再替换,避免中间态损坏 - const auto tmpAbs = destAbs + ".tmp"; if (QFileInfo::exists(tmpAbs)) { QFile::remove(tmpAbs); } @@ -3397,7 +4262,7 @@ bool ProjectWorkspace::computeFakeDepthForProject() { if (bgAbs.isEmpty() || !QFileInfo::exists(bgAbs)) { return false; } - QImage bg(bgAbs); + QImage bg = image_file::loadImageLimited(bgAbs, image_decode::kWorkspaceMaxPixels); if (bg.isNull()) { return false; } diff --git a/client/core/workspace/ProjectWorkspace.h b/client/core/workspace/ProjectWorkspace.h index 6ff88bc..701b845 100644 --- a/client/core/workspace/ProjectWorkspace.h +++ b/client/core/workspace/ProjectWorkspace.h @@ -5,19 +5,24 @@ #include #include #include +#include +#include #include #include #include #include namespace core { +namespace persistence { +class AsyncProjectWriter; +} class ProjectWorkspace { public: static constexpr const char* kProjectIndexFileName = "project.json"; static constexpr const char* kAssetsDirName = "assets"; // 写入 project.json 的 version 字段;仍可读 version 1(内嵌实体 + 可选 .anim)。 - static constexpr int kProjectIndexFormatVersion = 4; + static constexpr int kProjectIndexFormatVersion = 7; ProjectWorkspace() = default; @@ -58,6 +63,7 @@ public: // 仅写盘(project.json + payload 同步)。动画 UI 直接编辑 Project 后可调用此函数持久化。 bool save(); + bool saveAll(); // 历史操作(最多 30 步),类似 Blender:维护 undo/redo 栈 bool canUndo() const; @@ -92,6 +98,7 @@ public: bool setToolFontPx(const QString& id, int fontPx); bool setToolAlign(const QString& id, core::Project::Tool::TextAlign align); bool setToolVisibilityKey(const QString& id, int frame, bool visible); + bool setToolPriorityKey(const QString& id, int frame, int priority); bool removeToolVisibilityKey(const QString& id, int frame); bool setToolParent(const QString& id, const QString& parentId, const QPointF& parentOffsetWorld); bool moveToolBy(const QString& id, const QPointF& delta, int currentFrame, bool autoKeyLocation); @@ -113,9 +120,9 @@ public: // 复制背景其他区域填充黑洞(sourceOffsetPx 以黑洞包围盒左上角为基准偏移) bool resolveBlackholeByCopyBackground(const QString& id, const QPoint& sourceOffsetPx, bool hideBlackholeAfterFill); - // 使用模型补全后的结果写回背景(patchedBackground 已包含补全贴合后的完整背景图像) - bool resolveBlackholeByModelInpaint(const QString& id, const QImage& patchedBackground, - bool hideBlackholeAfterFill); + // 将模型补全的裁剪块保存为覆盖层并叠加显示,不修改 background 原图 + bool resolveBlackholeByModelInpaint(const QString& id, const QImage& overlayPatch, + const QRect& overlayRectInBackground, bool hideBlackholeAfterFill); bool setEntityVisibilityKey(const QString& id, int frame, bool visible); bool removeEntityVisibilityKey(const QString& id, int frame); bool setEntityDisplayName(const QString& id, const QString& displayName); @@ -126,17 +133,42 @@ public: // 将多边形质心平移到 targetCentroidWorld(整体平移);sTotal 须与画布一致 bool moveEntityCentroidTo(const QString& id, int frame, const QPointF& targetCentroidWorld, double sTotal, bool autoKeyLocation); + /// 仅适用于有父实体:设置相对父形心的偏移(静态 parentOffsetWorld)。 + bool setEntityParentOffsetWorld(const QString& id, const QPointF& offsetWorld); // 在保持外形不变的前提下移动枢轴点;sTotal 须与画布一致(距离缩放×整体缩放) bool reanchorEntityPivot(const QString& id, int frame, const QPointF& newPivotWorld, double sTotal); + /// 将各实体 origin 与多边形形心对齐(外形不变);打开工程时迁移旧数据。 + bool normalizeAllEntityOriginsToCentroids(int frame); + /// 当前帧下实体多边形形心的世界坐标(与画布深度/距离缩放采样点一致);工具 id 则返回其原点。 + [[nodiscard]] QPointF entityCentroidWorldAtFrame(const QString& id, int frame) const; bool reorderEntitiesById(const QStringList& idsInOrder); // currentFrame:自动关键帧时写入位置曲线;autoKeyLocation 为 false 时忽略。 bool moveEntityBy(const QString& id, const QPointF& delta, int currentFrame, bool autoKeyLocation); bool setEntityLocationKey(const QString& id, int frame, const QPointF& originWorld); bool setEntityDepthScaleKey(const QString& id, int frame, double value01); bool setEntityUserScaleKey(const QString& id, int frame, double userScale); + bool setEntityPriorityKey(const QString& id, int frame, int priority); + bool setEntityDefaultImage(const QString& id, const QImage& image, QString* outRelPath = nullptr); bool setEntityImageFrame(const QString& id, int frame, const QImage& image, QString* outRelPath = nullptr); // 仅更新 imageFrames 中某帧的图像路径(不读图、不写盘),用于高性能地“切断”Hold 区间 bool setEntityImageFramePath(const QString& id, int frame, const QString& relativePath); + QVector entitySpriteFrames(const QString& id) const; + QString entityImagePathAt(const QString& id, int frame) const; + QByteArray entityImagePngAt(const QString& id, int frame) const; + bool clearEntityImageFramesInRange(const QString& id, int startFrame, int endFrame); + int activeAnimationLengthFrames() const; + int activeAnimationFps() const; + int animationLengthFramesForId(const QString& animationId) const; + int animationFpsForId(const QString& animationId) const; + bool animationLoopsForId(const QString& animationId) const; + + bool createAnimationScheme(const QString& displayName, int lengthFrames, bool loop, int fps, + QString* outNewId = nullptr); + bool updateAnimationSchemeSettings(const QString& id, const QString& displayName, int lengthFrames, bool loop, + int fps); + + bool applyPresentationHotspots(const QVector& hotspots, bool recordHistory, + const QString& label); bool removeEntityLocationKey(const QString& id, int frame); bool removeEntityDepthScaleKey(const QString& id, int frame); bool removeEntityUserScaleKey(const QString& id, int frame); @@ -152,8 +184,9 @@ private: bool readIndexJson(const QString& indexPath); bool syncEntityPayloadsToDisk(); + bool syncAnimationPayloadsToDisk(); bool hydrateEntityPayloadsFromDisk(); - void loadV1LegacyAnimationSidecars(); + bool hydrateAnimationPayloadsFromDisk(); bool writeIndexJsonWithoutPayloadSync(); bool saveSingleEntityPayload(Project::Entity& entity); @@ -163,7 +196,6 @@ private: static QString fileSuffixWithDot(const QString& path); static QString asOptionalRelativeUnderProject(const QString& relativePath); static QJsonObject entityToJson(const Project::Entity& e); - static bool entityFromJsonV1(const QJsonObject& o, Project::Entity& out); static bool entityStubFromJsonV2(const QJsonObject& o, Project::Entity& out); static QJsonObject toolToJson(const Project::Tool& t); static bool toolFromJsonV2(const QJsonObject& o, Project::Tool& out); @@ -174,7 +206,16 @@ private: bool ensureDefaultCameraIfMissing(bool* outAdded = nullptr); struct Operation { - enum class Type { ImportBackground, SetEntities, SetTools, SetCameras, SetProjectTitle, SetProjectFrameRange }; + enum class Type { + ImportBackground, + SetEntities, + SetTools, + SetCameras, + SetAnimations, + SetPresentationHotspots, + SetProjectTitle, + SetProjectFrameRange + }; Type type {Type::ImportBackground}; QString label; QString beforeBackgroundPath; @@ -185,6 +226,14 @@ private: QVector afterTools; QVector beforeCameras; QVector afterCameras; + QVector beforeAnimations; + QVector afterAnimations; + bool animBundlesPresentationHotspots = false; + QVector animSnapshotBeforePresentationHotspots; + QVector animSnapshotAfterPresentationHotspots; + + QVector beforePresentationHotspots; + QVector afterPresentationHotspots; QString beforeProjectTitle; QString afterProjectTitle; int beforeFrameStart = 0; @@ -200,19 +249,36 @@ private: bool applyEntities(const QVector& entities, bool recordHistory, const QString& label); bool applyTools(const QVector& tools, bool recordHistory, const QString& label); bool applyCameras(const QVector& cameras, bool recordHistory, const QString& label); + bool applyAnimations(const QVector& animations, bool recordHistory, const QString& label); QString copyIntoAssetsAsBackground(const QString& sourceFilePath, const QRect& cropRectInSourceImage); bool writeDepthMap(const QImage& depth8); bool writeDepthMapBytes(const QByteArray& pngBytes); QString ensureEntitiesDir() const; + QString ensureAnimationsDir() const; bool writeEntityImage(const QString& entityId, const QImage& image, QString& outRelPath); bool writeEntityFrameImage(const QString& entityId, int frame, const QImage& image, QString& outRelPath); + QString defaultAnimationPayloadPath(const QString& animationId) const; + void markIndexDirty(); + /// snapshot:在尚未写入 m_project 前(例如新实体)必须传入,否则异步 bundle 写会漏队列。 + void markEntityDirty(const QString& id, const Project::Entity* snapshot = nullptr); + void markAnimationDirty(const QString& id); + void markAllEntitiesDirty(); + void markAllAnimationsDirty(); + void schedulePayloadSync(); + bool syncPayloadsToDiskOnce(); private: QString m_projectDir; Project m_project; + persistence::AsyncProjectWriter* m_asyncWriter = nullptr; + QVector m_undoStack; QVector m_redoStack; + QSet m_dirtyEntityIds; + QSet m_dirtyAnimationIds; + bool m_indexDirty = false; + QTimer* m_payloadSyncTimer = nullptr; }; } // namespace core diff --git a/client/gui/CMakeLists.txt b/client/gui/CMakeLists.txt index cfd3203..044e720 100644 --- a/client/gui/CMakeLists.txt +++ b/client/gui/CMakeLists.txt @@ -3,14 +3,18 @@ set(GUI_ROOT ${CMAKE_CURRENT_SOURCE_DIR}) set(GUI_SOURCES ${GUI_ROOT}/app/main.cpp + ${GUI_ROOT}/app/AppStyle.cpp ${GUI_ROOT}/main_window/MainWindow.cpp ${GUI_ROOT}/library/ResourceLibraryDock.cpp ${GUI_ROOT}/widgets/LongPressSwitchToolButton.cpp ${GUI_ROOT}/widgets/ToolOptionPopup.cpp + ${GUI_ROOT}/widgets/CompactNumericSpinBox.cpp + ${GUI_ROOT}/widgets/PropertyPanelControls.cpp ${GUI_ROOT}/main_window/RecentProjectHistory.cpp ${GUI_ROOT}/dialogs/AboutWindow.cpp ${GUI_ROOT}/dialogs/ImageCropDialog.cpp ${GUI_ROOT}/dialogs/FrameAnimationDialog.cpp + ${GUI_ROOT}/dialogs/AnimationSchemeSettingsDialog.cpp ${GUI_ROOT}/dialogs/CancelableTaskDialog.cpp ${GUI_ROOT}/dialogs/EntityFinalizeDialog.cpp ${GUI_ROOT}/dialogs/BlackholeResolveDialog.cpp @@ -24,18 +28,23 @@ set(GUI_SOURCES ${GUI_ROOT}/props/EntityPropertySection.cpp ${GUI_ROOT}/props/ToolPropertySection.cpp ${GUI_ROOT}/props/CameraPropertySection.cpp + ${GUI_ROOT}/props/HotspotPropertySection.cpp ${GUI_ROOT}/timeline/TimelineWidget.cpp ) set(GUI_HEADERS ${GUI_ROOT}/main_window/MainWindow.h + ${GUI_ROOT}/app/AppStyle.h ${GUI_ROOT}/library/ResourceLibraryDock.h ${GUI_ROOT}/widgets/LongPressSwitchToolButton.h ${GUI_ROOT}/widgets/ToolOptionPopup.h + ${GUI_ROOT}/widgets/PropertyPanelControls.h + ${GUI_ROOT}/widgets/CompactNumericSpinBox.h ${GUI_ROOT}/main_window/RecentProjectHistory.h ${GUI_ROOT}/dialogs/AboutWindow.h ${GUI_ROOT}/dialogs/ImageCropDialog.h ${GUI_ROOT}/dialogs/FrameAnimationDialog.h + ${GUI_ROOT}/dialogs/AnimationSchemeSettingsDialog.h ${GUI_ROOT}/dialogs/CancelableTaskDialog.h ${GUI_ROOT}/dialogs/EntityFinalizeDialog.h ${GUI_ROOT}/dialogs/BlackholeResolveDialog.h @@ -49,6 +58,7 @@ set(GUI_HEADERS ${GUI_ROOT}/props/EntityPropertySection.h ${GUI_ROOT}/props/ToolPropertySection.h ${GUI_ROOT}/props/CameraPropertySection.h + ${GUI_ROOT}/props/HotspotPropertySection.h ${GUI_ROOT}/props/PropertySectionWidget.h ${GUI_ROOT}/timeline/TimelineWidget.h ) @@ -76,6 +86,7 @@ target_link_libraries(LandscapeInteractiveToolApp ${QT_PACKAGE}::Core ${QT_PACKAGE}::Gui ${QT_PACKAGE}::Widgets + ${QT_PACKAGE}::Concurrent core ) diff --git a/client/gui/app/AppStyle.cpp b/client/gui/app/AppStyle.cpp new file mode 100644 index 0000000..5d1b5e0 --- /dev/null +++ b/client/gui/app/AppStyle.cpp @@ -0,0 +1,561 @@ +#include "app/AppStyle.h" + +namespace gui { + +QString appStyleSheet() { + return QStringLiteral(R"QSS( + QWidget { + font-size: 13px; + } + + /* ===== 属性面板容器 ===== */ + #PropertyDockContent { + background: #f8f9fb; + } + + /* 属性面板标签:清晰可读 */ + #PropertyDockContent QLabel { + font-size: 12px; + color: #374151; + font-weight: 500; + } + #PropertyDockContent #PropertyPanelHeading { + font-size: 13px; + font-weight: 700; + color: #111827; + padding: 4px 0 2px 0; + } + + /* 属性面板单行编辑 */ + #PropertyDockContent #PropertyPanelLineEdit { + font-size: 12px; + border-radius: 6px; + padding: 4px 8px; + min-height: 22px; + max-height: 26px; + background: #ffffff; + border: 1px solid #d1d5db; + selection-background-color: rgba(59, 130, 246, 0.20); + } + #PropertyDockContent #PropertyPanelLineEdit:hover { + border: 1px solid #9ca3af; + } + #PropertyDockContent #PropertyPanelLineEdit:focus { + border: 1px solid #3b82f6; + background: #ffffff; + } + + /* 属性面板下拉框 */ + #PropertyDockContent #PropertyPanelComboBox { + font-size: 12px; + border-radius: 6px; + padding: 4px 8px; + min-height: 22px; + max-height: 26px; + background: #ffffff; + border: 1px solid #d1d5db; + } + #PropertyDockContent #PropertyPanelComboBox:hover { + border: 1px solid #9ca3af; + } + #PropertyDockContent #PropertyPanelComboBox:focus { + border: 1px solid #3b82f6; + } + #PropertyDockContent #PropertyPanelComboBox::drop-down { + width: 18px; + border: 0; + } + #PropertyDockContent #PropertyPanelComboBox::down-arrow { + image: none; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid #6b7280; + width: 0; + height: 0; + } + + /* 属性面板多行编辑 */ + #PropertyDockContent #PropertyPanelTextEdit { + font-size: 12px; + border-radius: 6px; + padding: 6px 8px; + background: #ffffff; + border: 1px solid #d1d5db; + selection-background-color: rgba(59, 130, 246, 0.20); + } + #PropertyDockContent #PropertyPanelTextEdit:hover { + border: 1px solid #9ca3af; + } + #PropertyDockContent #PropertyPanelTextEdit:focus { + border: 1px solid #3b82f6; + } + + /* 属性面板按钮 */ + #PropertyDockContent #PropertyPanelPushButton { + font-size: 12px; + font-weight: 500; + border-radius: 6px; + padding: 3px 12px; + min-height: 24px; + max-height: 26px; + background: #ffffff; + border: 1px solid #d1d5db; + color: #374151; + } + #PropertyDockContent #PropertyPanelPushButton:hover { + background: #eef2ff; + border: 1px solid #93b4fc; + color: #1d4ed8; + } + #PropertyDockContent #PropertyPanelPushButton:pressed { + background: #dbeafe; + border: 1px solid #60a5fa; + } + + /* 属性面板复选框 */ + #PropertyDockContent #PropertyPanelCheckBox { + font-size: 12px; + spacing: 6px; + color: #374151; + } + #PropertyDockContent #PropertyPanelCheckBox::indicator { + width: 16px; + height: 16px; + border-radius: 4px; + border: 1.5px solid #9ca3af; + background: #ffffff; + } + #PropertyDockContent #PropertyPanelCheckBox::indicator:hover { + border: 1.5px solid #3b82f6; + } + #PropertyDockContent #PropertyPanelCheckBox::indicator:checked { + background: #3b82f6; + border: 1.5px solid #2563eb; + } + + /* 属性面板列表 */ + #PropertyDockContent #PropertyPanelListWidget { + font-size: 12px; + border-radius: 6px; + padding: 4px; + background: #ffffff; + border: 1px solid #d1d5db; + } + #PropertyDockContent #PropertyPanelListWidget::item { + padding: 4px 8px; + border-radius: 4px; + } + #PropertyDockContent #PropertyPanelListWidget::item:selected { + background: rgba(59, 130, 246, 0.12); + color: #111827; + } + + /* 属性面板紧凑数值框 */ + #PropertyDockContent #CompactNumericSpin { + font-size: 12px; + border-radius: 6px; + padding: 0px; + min-height: 22px; + max-height: 26px; + background: #ffffff; + border: 1px solid #d1d5db; + } + #PropertyDockContent #CompactNumericSpin:hover { + border: 1px solid #9ca3af; + } + #PropertyDockContent #CompactNumericSpin:focus { + border: 1px solid #3b82f6; + } + #PropertyDockContent #CompactNumericSpin QLineEdit { + padding: 0px 4px; + margin: 0px; + min-height: 20px; + border: none; + background: transparent; + } + #PropertyDockContent #CompactNumericSpin::up-button, + #PropertyDockContent #CompactNumericSpin::down-button { + width: 14px; + border-left: 1px solid #e5e7eb; + background: #f9fafb; + } + #PropertyDockContent #CompactNumericSpin::up-button:hover, + #PropertyDockContent #CompactNumericSpin::down-button:hover { + background: #eef2ff; + } + + /* 属性面板滑块 */ + #PropertyDockContent QSlider::groove:horizontal { + height: 4px; + border-radius: 2px; + background: #e5e7eb; + } + #PropertyDockContent QSlider::handle:horizontal { + width: 14px; + height: 14px; + margin: -5px 0; + border-radius: 7px; + background: #ffffff; + border: 1.5px solid #d1d5db; + } + #PropertyDockContent QSlider::handle:horizontal:hover { + border: 1.5px solid #3b82f6; + background: #eef2ff; + } + + /* 属性面板分割线 */ + #PropertySectionSeparator { + max-height: 1px; + min-height: 1px; + background: #e5e7eb; + } + + /* 全局紧凑数值框(非属性面板内) */ + #CompactNumericSpin { + font-size: 12px; + border-radius: 6px; + padding: 0px; + min-height: 22px; + border: 1px solid #d1d5db; + background: #ffffff; + } + #CompactNumericSpin QLineEdit { + padding: 0px 4px; + margin: 0px; + min-height: 20px; + border: none; + background: transparent; + } + #CompactNumericSpin::up-button, + #CompactNumericSpin::down-button { + width: 14px; + border-left: 1px solid #e5e7eb; + background: #f9fafb; + } + #CompactNumericSpin::up-button:hover, + #CompactNumericSpin::down-button:hover { + background: #eef2ff; + } + + /* ===== 主窗口 ===== */ + QMainWindow { + background: #f3f4f6; + } + + /* ===== 欢迎页 ===== */ + #WelcomePage { + background: #f3f4f6; + } + #WelcomeCard { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 16px; + } + #WelcomeTitle { + font-size: 24px; + font-weight: 800; + color: #111827; + } + #WelcomeSectionTitle { + font-size: 15px; + font-weight: 700; + color: #111827; + } + #WelcomeDesc, #WelcomeHint { + color: #6b7280; + } + #WelcomePrimaryButton { + background: #3b82f6; + border: 1px solid #3b82f6; + color: #ffffff; + border-radius: 10px; + font-weight: 600; + padding: 8px 24px; + } + #WelcomePrimaryButton:hover { + background: #2563eb; + border: 1px solid #2563eb; + } + + /* ===== 项目树眼睛按钮 ===== */ + #ProjectTreeEyeButton { + border: 1.5px solid transparent; + border-radius: 6px; + padding: 0px; + color: #6b7280; + background: transparent; + font-weight: 600; + } + #ProjectTreeEyeButton:hover { + background: rgba(59, 130, 246, 0.08); + color: #3b82f6; + border: 1.5px solid rgba(59, 130, 246, 0.20); + } + #ProjectTreeEyeButton:checked { + color: #3b82f6; + background: rgba(59, 130, 246, 0.06); + } + + /* ===== 项目树 ===== */ + #ProjectTreeDockContent { + background: #f8f9fb; + } + #ProjectTree { + background: transparent; + border: none; + outline: 0; + } + #ProjectTree::item { + padding: 5px 8px; + border-radius: 6px; + color: #374151; + } + #ProjectTree::item:hover { + background: rgba(59, 130, 246, 0.06); + } + #ProjectTree::item:selected { + background: rgba(59, 130, 246, 0.10); + color: #111827; + border: none; + } + #ProjectTree::branch { + border: none; + } + + /* ===== 停靠窗口 ===== */ + QDockWidget { + titlebar-close-icon: none; + titlebar-normal-icon: none; + } + QDockWidget::title { + background: #ffffff; + padding: 8px 14px; + font-size: 13px; + font-weight: 700; + color: #111827; + border-bottom: 1px solid #e5e7eb; + } + + /* 通用标签 */ + QLabel { + color: #111827; + } + + /* ===== 分组框 ===== */ + QGroupBox { + border: 1px solid #e5e7eb; + border-radius: 10px; + margin-top: 14px; + background: #ffffff; + padding: 12px 8px 8px 8px; + } + QGroupBox::title { + subcontrol-origin: margin; + left: 12px; + padding: 0 8px; + color: #374151; + font-weight: 600; + font-size: 12px; + } + + /* ===== 通用输入控件 ===== */ + QLineEdit, QTextEdit, QPlainTextEdit, QComboBox { + background: #ffffff; + border: 1px solid #d1d5db; + border-radius: 8px; + padding: 6px 10px; + selection-background-color: rgba(59, 130, 246, 0.20); + } + QLineEdit:hover, QTextEdit:hover, QPlainTextEdit:hover, QComboBox:hover { + border: 1px solid #9ca3af; + } + QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus, QComboBox:focus { + border: 1.5px solid #3b82f6; + } + + QSpinBox, QDoubleSpinBox { + background: #ffffff; + border: 1px solid #d1d5db; + border-radius: 6px; + padding: 0px; + font-size: 12px; + selection-background-color: rgba(59, 130, 246, 0.20); + } + QSpinBox QLineEdit, QDoubleSpinBox QLineEdit { + padding: 0px 4px; + margin: 0px; + border: none; + background: transparent; + min-height: 20px; + } + QSpinBox:hover, QDoubleSpinBox:hover { + border: 1px solid #9ca3af; + } + QSpinBox:focus, QDoubleSpinBox:focus { + border: 1.5px solid #3b82f6; + } + QAbstractSpinBox::up-button, QAbstractSpinBox::down-button { + width: 16px; + border-left: 1px solid #e5e7eb; + background: #f9fafb; + } + QAbstractSpinBox::up-button:hover, QAbstractSpinBox::down-button:hover { + background: #eef2ff; + } + + QComboBox::drop-down { + border: 0; + width: 26px; + } + QComboBox::down-arrow { + image: none; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 6px solid #6b7280; + width: 0; + height: 0; + } + + QComboBox QAbstractItemView { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 4px; + selection-background-color: rgba(59, 130, 246, 0.10); + } + + /* ===== 按钮 ===== */ + QPushButton, QToolButton { + background: #ffffff; + border: 1px solid #d1d5db; + border-radius: 8px; + padding: 7px 12px; + color: #374151; + } + QPushButton:hover, QToolButton:hover { + background: #f9fafb; + border: 1px solid #9ca3af; + } + QPushButton:pressed, QToolButton:pressed { + background: #f3f4f6; + border: 1px solid #6b7280; + } + QToolButton:checked { + background: rgba(59, 130, 246, 0.10); + border: 1px solid rgba(59, 130, 246, 0.40); + color: #1d4ed8; + } + + /* ===== 复选框 ===== */ + QCheckBox { + spacing: 8px; + color: #374151; + } + QCheckBox::indicator { + width: 18px; + height: 18px; + border-radius: 5px; + border: 1.5px solid #9ca3af; + background: #ffffff; + } + QCheckBox::indicator:hover { + border: 1.5px solid #3b82f6; + } + QCheckBox::indicator:checked { + background: #3b82f6; + border: 1.5px solid #2563eb; + } + + /* ===== 列表/树形视图 ===== */ + QListWidget, QTreeView, QTableView { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 10px; + outline: 0; + } + QListWidget::item, QTreeView::item, QTableView::item { + padding: 6px 10px; + } + QListWidget::item:hover, QTreeView::item:hover, QTableView::item:hover { + background: rgba(59, 130, 246, 0.04); + } + QListWidget::item:selected, QTreeView::item:selected, QTableView::item:selected { + background: rgba(59, 130, 246, 0.12); + color: #111827; + } + + /* ===== 菜单 ===== */ + QMenu { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 6px; + } + QMenu::item { + padding: 8px 12px; + border-radius: 6px; + } + QMenu::item:selected { + background: rgba(59, 130, 246, 0.08); + } + QMenu::separator { + height: 1px; + background: #e5e7eb; + margin: 4px 6px; + } + + /* ===== 滚动条 ===== */ + QScrollBar:vertical { + background: transparent; + width: 10px; + margin: 4px 2px 4px 2px; + } + QScrollBar::handle:vertical { + background: #d1d5db; + border-radius: 5px; + min-height: 32px; + } + QScrollBar::handle:vertical:hover { + background: #9ca3af; + } + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { + height: 0px; + } + QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { + background: transparent; + } + + QScrollBar:horizontal { + background: transparent; + height: 10px; + margin: 2px 4px 2px 4px; + } + QScrollBar::handle:horizontal { + background: #d1d5db; + border-radius: 5px; + min-width: 32px; + } + QScrollBar::handle:horizontal:hover { + background: #9ca3af; + } + QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { + width: 0px; + } + QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { + background: transparent; + } + + /* ===== 工具提示 ===== */ + QToolTip { + background: #111827; + color: #ffffff; + border: 0; + padding: 8px 10px; + border-radius: 8px; + font-size: 12px; + } + )QSS"); +} + +} // namespace gui + diff --git a/client/gui/app/AppStyle.h b/client/gui/app/AppStyle.h new file mode 100644 index 0000000..8d61904 --- /dev/null +++ b/client/gui/app/AppStyle.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +namespace gui { + +// 全局 QSS:仅负责外观统一,不承载业务逻辑。 +QString appStyleSheet(); + +} // namespace gui + diff --git a/client/gui/app/main.cpp b/client/gui/app/main.cpp index 2eacf72..7b8763b 100644 --- a/client/gui/app/main.cpp +++ b/client/gui/app/main.cpp @@ -1,16 +1,22 @@ #include "main_window/MainWindow.h" +#include "app/AppStyle.h" + +#include "core/image/ImageDecodeConfig.h" +#include "core/large_image/VipsBackend.h" #include -#include +#include int main(int argc, char* argv[]) { QApplication app(argc, argv); app.setApplicationName(QStringLiteral("landscape tool")); + app.setStyle(QStyleFactory::create(QStringLiteral("Fusion"))); + app.setStyleSheet(gui::appStyleSheet()); - // 全局放宽 Qt 图片分配限制(默认常见为 256MB),否则超大分辨率背景/深度可能在任意加载路径被拒绝。 -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) - QImageReader::setAllocationLimit(1024); // MB -#endif + core::image_decode::prepareLargeImageReader(); + const char* av0 = (argc > 0 && argv[0]) ? argv[0] : "landscape_tool"; + core::VipsBackend::init(av0); + qAddPostRoutine([]() { core::VipsBackend::shutdown(); }); MainWindow window; window.show(); diff --git a/client/gui/dialogs/AnimationSchemeSettingsDialog.cpp b/client/gui/dialogs/AnimationSchemeSettingsDialog.cpp new file mode 100644 index 0000000..92aeeb9 --- /dev/null +++ b/client/gui/dialogs/AnimationSchemeSettingsDialog.cpp @@ -0,0 +1,90 @@ +#include "dialogs/AnimationSchemeSettingsDialog.h" + +#include "core/domain/Project.h" +#include "core/workspace/ProjectWorkspace.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +AnimationSchemeSettingsDialog::AnimationSchemeSettingsDialog(Mode mode, core::ProjectWorkspace& workspace, + QWidget* parent) + : QDialog(parent) + , m_mode(mode) + , m_workspace(workspace) { + setModal(true); + setMinimumWidth(360); + if (mode == Mode::Create) { + setWindowTitle(QStringLiteral("新建动画方案")); + } else { + setWindowTitle(QStringLiteral("动画方案设置")); + } + + auto* root = new QVBoxLayout(this); + root->setContentsMargins(12, 12, 12, 12); + auto* form = new QFormLayout(); + form->setSpacing(8); + root->addLayout(form); + + m_name = new QLineEdit(this); + m_name->setPlaceholderText(QStringLiteral("显示名称")); + form->addRow(QStringLiteral("名称"), m_name); + + m_frames = new QSpinBox(this); + m_frames->setRange(1, 1000000); + m_frames->setValue(600); + form->addRow(QStringLiteral("总帧数"), m_frames); + + m_fps = new QSpinBox(this); + m_fps->setRange(1, 240); + m_fps->setValue(std::max(1, m_workspace.project().fps())); + form->addRow(QStringLiteral("帧率"), m_fps); + + m_loop = new QCheckBox(QStringLiteral("循环播放"), this); + m_loop->setChecked(true); + form->addRow(QString(), m_loop); + + auto* box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + root->addWidget(box); + connect(box, &QDialogButtonBox::accepted, this, &AnimationSchemeSettingsDialog::accept); + connect(box, &QDialogButtonBox::rejected, this, &QDialog::reject); +} + +void AnimationSchemeSettingsDialog::setEditTarget(const QString& animationId) { + m_editId = animationId; + if (const core::Project::Animation* a = m_workspace.project().findAnimationById(animationId)) { + m_name->setText(a->name); + m_frames->setValue(std::max(1, a->lengthFrames)); + m_fps->setValue(std::max(1, a->fps)); + m_loop->setChecked(a->loop); + } +} + +void AnimationSchemeSettingsDialog::accept() { + const QString nm = m_name->text().trimmed(); + const int frames = m_frames->value(); + const int fps = m_fps->value(); + const bool loop = m_loop->isChecked(); + + if (m_mode == Mode::Create) { + if (!m_workspace.createAnimationScheme(nm, frames, loop, fps, &m_createdNewId)) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("新建失败")); + return; + } + } else { + if (m_editId.isEmpty() || !m_workspace.project().findAnimationById(m_editId)) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("无效的动画方案")); + return; + } + if (!m_workspace.updateAnimationSchemeSettings(m_editId, nm, frames, loop, fps)) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("保存失败")); + return; + } + } + QDialog::accept(); +} diff --git a/client/gui/dialogs/AnimationSchemeSettingsDialog.h b/client/gui/dialogs/AnimationSchemeSettingsDialog.h new file mode 100644 index 0000000..80037c4 --- /dev/null +++ b/client/gui/dialogs/AnimationSchemeSettingsDialog.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include + +namespace core { +class ProjectWorkspace; +} + +class QCheckBox; +class QLineEdit; +class QSpinBox; + +class AnimationSchemeSettingsDialog final : public QDialog { + Q_OBJECT +public: + enum class Mode { Create, Edit }; + + AnimationSchemeSettingsDialog(Mode mode, core::ProjectWorkspace& workspace, QWidget* parent = nullptr); + + void setEditTarget(const QString& animationId); + + QString createdNewId() const { return m_createdNewId; } + +private: + void accept() override; + + Mode m_mode = Mode::Create; + core::ProjectWorkspace& m_workspace; + QString m_editId; + QString m_createdNewId; + + QLineEdit* m_name = nullptr; + QSpinBox* m_frames = nullptr; + QSpinBox* m_fps = nullptr; + QCheckBox* m_loop = nullptr; +}; diff --git a/client/gui/dialogs/BlackholeResolveDialog.cpp b/client/gui/dialogs/BlackholeResolveDialog.cpp index b57e1c1..0daf3d7 100644 --- a/client/gui/dialogs/BlackholeResolveDialog.cpp +++ b/client/gui/dialogs/BlackholeResolveDialog.cpp @@ -10,13 +10,13 @@ namespace { -QPushButton* makeAlgoButton(const QString& title, const QString& subtitle, QWidget* parent) { +QPushButton* makeAlgoButton(const QString& title, QWidget* parent) { auto* btn = new QPushButton(parent); btn->setCheckable(false); btn->setCursor(Qt::PointingHandCursor); - btn->setMinimumHeight(86); + btn->setMinimumHeight(48); btn->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - btn->setText(title + QStringLiteral("\n") + subtitle); + btn->setText(title); btn->setStyleSheet( "QPushButton { text-align: left; padding: 10px 12px; border: 1px solid palette(mid); border-radius: 8px; }" "QPushButton:hover { border-color: palette(highlight); }"); @@ -29,8 +29,8 @@ BlackholeResolveDialog::BlackholeResolveDialog(const QString& blackholeName, QWi : QDialog(parent), m_blackholeName(blackholeName) { setModal(true); - setMinimumSize(560, 420); - setWindowTitle(QStringLiteral("黑洞解决")); + setMinimumSize(400, 320); + setWindowTitle(QStringLiteral("修复 · %1").arg(m_blackholeName)); auto* root = new QVBoxLayout(this); m_pages = new QStackedWidget(this); @@ -51,25 +51,13 @@ void BlackholeResolveDialog::buildSelectPage() { layout->setContentsMargins(8, 8, 8, 8); layout->setSpacing(12); - auto* title = new QLabel(QStringLiteral("第 1 步:选择黑洞解决算法"), m_pageSelect); - auto* sub = new QLabel(QStringLiteral("当前黑洞:%1").arg(m_blackholeName), m_pageSelect); - title->setStyleSheet("font-size: 18px; font-weight: 600;"); - sub->setStyleSheet("color: palette(mid);"); + auto* title = new QLabel(QStringLiteral("方式"), m_pageSelect); + title->setStyleSheet("font-size: 16px; font-weight: 600;"); layout->addWidget(title); - layout->addWidget(sub); - auto* btnCopy = makeAlgoButton( - QStringLiteral("复制背景其他区域"), - QStringLiteral("进入画布拖动取样框,直观选择复制来源。"), - m_pageSelect); - auto* btnOriginal = makeAlgoButton( - QStringLiteral("使用原始背景"), - QStringLiteral("撤销黑洞显示,恢复抠图前背景区域。"), - m_pageSelect); - auto* btnModel = makeAlgoButton( - QStringLiteral("模型补全(SDXL Inpaint)"), - QStringLiteral("输入提示词,自动补全缺失区域;可预览后再决定是否接受。"), - m_pageSelect); + auto* btnCopy = makeAlgoButton(QStringLiteral("复制背景"), m_pageSelect); + auto* btnOriginal = makeAlgoButton(QStringLiteral("原始背景"), m_pageSelect); + auto* btnModel = makeAlgoButton(QStringLiteral("模型补全"), m_pageSelect); layout->addWidget(btnCopy); layout->addWidget(btnOriginal); layout->addWidget(btnModel); @@ -100,12 +88,8 @@ void BlackholeResolveDialog::buildDetailPage() { layout->setSpacing(10); m_detailTitle = new QLabel(m_pageDetail); - m_detailTitle->setStyleSheet("font-size: 18px; font-weight: 600;"); - m_detailHint = new QLabel(m_pageDetail); - m_detailHint->setWordWrap(true); - m_detailHint->setStyleSheet("color: palette(mid);"); + m_detailTitle->setStyleSheet("font-size: 15px; font-weight: 600;"); layout->addWidget(m_detailTitle); - layout->addWidget(m_detailHint); m_algoDetails = new QStackedWidget(m_pageDetail); @@ -120,10 +104,7 @@ void BlackholeResolveDialog::buildDetailPage() { auto* pLay = new QVBoxLayout(panel); pLay->setSpacing(8); - auto* tip = new QLabel( - QStringLiteral("说明:点击“应用”后进入画布拖动模式。\n在画布中拖动青色取样框,松开鼠标即可将该区域复制到黑洞位置并自动移除黑洞。"), - panel); - tip->setWordWrap(true); + auto* tip = new QLabel(QStringLiteral("应用后在画布拖取样框。"), panel); tip->setStyleSheet("color: palette(mid);"); pLay->addWidget(tip); cLay->addWidget(panel); @@ -135,17 +116,9 @@ void BlackholeResolveDialog::buildDetailPage() { { auto* oLay = new QVBoxLayout(m_originalDetail); oLay->setSpacing(8); - auto* desc = new QLabel( - QStringLiteral("该方案不会改动背景像素文件,只会将黑洞切换为不显示,从而恢复原始背景区域。"), - m_originalDetail); + auto* desc = new QLabel(QStringLiteral("仅隐藏黑洞,不改动背景文件。"), m_originalDetail); desc->setWordWrap(true); - auto* note = new QLabel( - QStringLiteral("适用场景:当前黑洞区域无需二次修补,只需恢复抠图前背景;应用后黑洞会自动移除。"), - m_originalDetail); - note->setWordWrap(true); - note->setStyleSheet("color: palette(mid);"); oLay->addWidget(desc); - oLay->addWidget(note); oLay->addStretch(1); } @@ -161,8 +134,8 @@ void BlackholeResolveDialog::buildDetailPage() { pLay->setSpacing(8); m_promptEdit = new QPlainTextEdit(panel); - m_promptEdit->setPlainText(QStringLiteral("This is part of a Chinese painting; please complete the background for me, following the style of the other parts.")); - m_promptEdit->setMinimumHeight(90); + m_promptEdit->setPlaceholderText(QStringLiteral("提示词(可选)")); + m_promptEdit->setMinimumHeight(72); pLay->addWidget(m_promptEdit); mLay->addWidget(panel); @@ -175,8 +148,8 @@ void BlackholeResolveDialog::buildDetailPage() { layout->addWidget(m_algoDetails, 1); auto* btns = new QDialogButtonBox(m_pageDetail); - auto* btnBack = btns->addButton(QStringLiteral("上一步"), QDialogButtonBox::ActionRole); - auto* btnApply = btns->addButton(QStringLiteral("应用"), QDialogButtonBox::AcceptRole); + auto* btnBack = btns->addButton(QStringLiteral("返回"), QDialogButtonBox::ActionRole); + auto* btnApply = btns->addButton(QStringLiteral("确定"), QDialogButtonBox::AcceptRole); auto* btnCancel = btns->addButton(QDialogButtonBox::Cancel); connect(btnBack, &QPushButton::clicked, this, [this]() { m_pages->setCurrentWidget(m_pageSelect); @@ -191,16 +164,13 @@ void BlackholeResolveDialog::buildDetailPage() { void BlackholeResolveDialog::enterAlgorithmPage(Algorithm algo) { m_selectedAlgorithm = algo; if (algo == Algorithm::CopyBackgroundRegion) { - m_detailTitle->setText(QStringLiteral("第 2 步:复制背景其他区域")); - m_detailHint->setText(QStringLiteral("准备进入画布拖动取样框模式。")); + m_detailTitle->setText(QStringLiteral("复制背景")); m_algoDetails->setCurrentWidget(m_copyDetail); } else if (algo == Algorithm::UseOriginalBackground) { - m_detailTitle->setText(QStringLiteral("第 2 步:使用原始背景")); - m_detailHint->setText(QStringLiteral("确认后将切换为原始背景显示。")); + m_detailTitle->setText(QStringLiteral("原始背景")); m_algoDetails->setCurrentWidget(m_originalDetail); } else { - m_detailTitle->setText(QStringLiteral("第 2 步:模型补全(SDXL Inpaint)")); - m_detailHint->setText(QStringLiteral("输入提示词(可选),点击应用后将生成预览。")); + m_detailTitle->setText(QStringLiteral("模型补全")); m_algoDetails->setCurrentWidget(m_modelDetail); } m_pages->setCurrentWidget(m_pageDetail); diff --git a/client/gui/dialogs/BlackholeResolveDialog.h b/client/gui/dialogs/BlackholeResolveDialog.h index 5d19696..772c8d2 100644 --- a/client/gui/dialogs/BlackholeResolveDialog.h +++ b/client/gui/dialogs/BlackholeResolveDialog.h @@ -34,7 +34,6 @@ private: QWidget* m_pageDetail = nullptr; QLabel* m_detailTitle = nullptr; - QLabel* m_detailHint = nullptr; QStackedWidget* m_algoDetails = nullptr; QWidget* m_copyDetail = nullptr; diff --git a/client/gui/dialogs/EntityFinalizeDialog.cpp b/client/gui/dialogs/EntityFinalizeDialog.cpp index baef2a5..acc38f7 100644 --- a/client/gui/dialogs/EntityFinalizeDialog.cpp +++ b/client/gui/dialogs/EntityFinalizeDialog.cpp @@ -1,9 +1,10 @@ #include "dialogs/EntityFinalizeDialog.h" +#include "widgets/CompactNumericSpinBox.h" + #include #include #include -#include #include EntityFinalizeDialog::EntityFinalizeDialog(QWidget* parent) @@ -18,7 +19,7 @@ EntityFinalizeDialog::EntityFinalizeDialog(QWidget* parent) m_name->setPlaceholderText(QStringLiteral("例如:entity-1、人物、树、建筑…")); form->addRow(QStringLiteral("名称"), m_name); - m_userScale = new QDoubleSpinBox(this); + m_userScale = new gui::CompactDoubleSpinBox(this); m_userScale->setDecimals(3); m_userScale->setRange(0.01, 50.0); m_userScale->setSingleStep(0.05); diff --git a/client/gui/dialogs/EntityIntroPopup.cpp b/client/gui/dialogs/EntityIntroPopup.cpp index 1d46d66..aefa6e8 100644 --- a/client/gui/dialogs/EntityIntroPopup.cpp +++ b/client/gui/dialogs/EntityIntroPopup.cpp @@ -1,5 +1,8 @@ #include "dialogs/EntityIntroPopup.h" +#include "core/image/ImageDecodeConfig.h" +#include "core/image/ImageFileLoader.h" + #include #include #include @@ -95,7 +98,8 @@ void EntityIntroPopup::setContent(const core::EntityIntroContent& content) { auto* lab = new QLabel(m_imagesHost); lab->setAlignment(Qt::AlignCenter); if (!abs.isEmpty() && QFileInfo::exists(abs)) { - QPixmap pm(abs); + QPixmap pm = QPixmap::fromImage( + core::image_file::loadImageLimited(abs, core::image_decode::kPreviewMaxPixels)); if (!pm.isNull()) { if (pm.width() > kMaxThumb || pm.height() > kMaxThumb) { pm = pm.scaled(kMaxThumb, kMaxThumb, Qt::KeepAspectRatio, Qt::SmoothTransformation); diff --git a/client/gui/dialogs/FrameAnimationDialog.cpp b/client/gui/dialogs/FrameAnimationDialog.cpp index 747fe0e..1165666 100644 --- a/client/gui/dialogs/FrameAnimationDialog.cpp +++ b/client/gui/dialogs/FrameAnimationDialog.cpp @@ -1,29 +1,45 @@ #include "dialogs/FrameAnimationDialog.h" -#include "core/animation/AnimationSampling.h" +#include "core/image/ImageDecodeConfig.h" +#include "core/image/ImageFileLoader.h" +#include "core/net/ModelServerClient.h" #include "core/workspace/ProjectWorkspace.h" +#include "dialogs/CancelableTaskDialog.h" #include +#include +#include +#include +#include +#include +#include #include +#include #include #include #include -#include +#include +#include +#include +#include #include +#include #include #include +#include +#include #include #include +#include namespace { QString resolvedImageAbsForFrame(const core::ProjectWorkspace& ws, const core::Project::Entity& e, int frame) { - const QString rel = core::sampleImagePath(e.imageFrames, frame, e.imagePath); - if (rel.isEmpty()) return {}; - const QString abs = QDir(ws.projectDir()).filePath(rel); - return abs; + const QByteArray png = ws.entityImagePngAt(e.id, frame); + if (png.isEmpty()) return {}; + return QStringLiteral("pngb64:") + QString::fromLatin1(png.toBase64()); } } // namespace @@ -56,6 +72,7 @@ FrameAnimationDialog::FrameAnimationDialog(core::ProjectWorkspace& workspace, m_list = new QListWidget(this); m_list->setMinimumWidth(240); + m_list->setSelectionMode(QAbstractItemView::ExtendedSelection); mid->addWidget(m_list, 0); auto* right = new QVBoxLayout(); @@ -71,18 +88,27 @@ FrameAnimationDialog::FrameAnimationDialog(core::ProjectWorkspace& workspace, auto* row = new QHBoxLayout(); right->addLayout(row); m_btnReplace = new QPushButton(QStringLiteral("替换此帧…"), this); - m_btnClear = new QPushButton(QStringLiteral("清除此帧(恢复默认)"), this); + m_btnClear = new QPushButton(QStringLiteral("清除选中帧"), this); row->addWidget(m_btnReplace); row->addWidget(m_btnClear); auto* row2 = new QHBoxLayout(); right->addLayout(row2); - m_btnImportFiles = new QPushButton(QStringLiteral("批量导入(多选图片)…"), this); - m_btnImportFolder = new QPushButton(QStringLiteral("批量导入(文件夹)…"), this); + m_btnImportFiles = new QPushButton(QStringLiteral("导入图片…"), this); + m_btnImportFolder = new QPushButton(QStringLiteral("导入文件夹…"), this); row2->addWidget(m_btnImportFiles); row2->addWidget(m_btnImportFolder); row2->addStretch(1); + auto* row3 = new QHBoxLayout(); + right->addLayout(row3); + m_btnAiGenerate = new QPushButton(QStringLiteral("AI 生成逐帧动画…"), this); + row3->addWidget(m_btnAiGenerate); + auto* aiHint = new QLabel(QStringLiteral("写入选中区间"), this); + aiHint->setStyleSheet(QStringLiteral("color: #777;")); + row3->addWidget(aiHint); + row3->addStretch(1); + auto* closeRow = new QHBoxLayout(); root->addLayout(closeRow); closeRow->addStretch(1); @@ -95,6 +121,7 @@ FrameAnimationDialog::FrameAnimationDialog(core::ProjectWorkspace& workspace, connect(m_btnClear, &QPushButton::clicked, this, &FrameAnimationDialog::onClearCurrentFrame); connect(m_btnImportFiles, &QPushButton::clicked, this, &FrameAnimationDialog::onBatchImportFiles); connect(m_btnImportFolder, &QPushButton::clicked, this, &FrameAnimationDialog::onBatchImportFolder); + connect(m_btnAiGenerate, &QPushButton::clicked, this, &FrameAnimationDialog::onGenerateAiFrames); rebuildFrameList(); if (m_list->count() > 0) { @@ -118,16 +145,11 @@ void FrameAnimationDialog::rebuildFrameList() { // 默认贴图(用于 UI 提示) m_defaultImageAbs.clear(); - if (!hit->imagePath.isEmpty()) { - const QString abs = QDir(m_workspace.projectDir()).filePath(hit->imagePath); - if (QFileInfo::exists(abs)) { - m_defaultImageAbs = abs; - } - } for (int f = m_start; f <= m_end; ++f) { bool hasCustom = false; - for (const auto& k : hit->imageFrames) { + const auto spriteFrames = m_workspace.entitySpriteFrames(m_entityId); + for (const auto& k : spriteFrames) { if (k.frame == f) { hasCustom = true; break; @@ -159,38 +181,33 @@ void FrameAnimationDialog::updatePreviewForFrame(int frame) { if (!hit) return; const QString abs = resolvedImageAbsForFrame(m_workspace, *hit, frame); - if (abs.isEmpty() || !QFileInfo::exists(abs)) { + if (abs.isEmpty()) { m_preview->setText(QStringLiteral("无图像")); return; } - QPixmap pm(abs); - if (pm.isNull()) { + QImage loaded; + if (abs.startsWith(QStringLiteral("pngb64:"))) { + const QByteArray b64 = abs.mid(QStringLiteral("pngb64:").size()).toLatin1(); + const QByteArray raw = QByteArray::fromBase64(b64); + loaded.loadFromData(raw, "PNG"); + } else { + if (!QFileInfo::exists(abs)) { + m_preview->setText(QStringLiteral("无图像")); + return; + } + loaded = core::image_file::loadImageLimited(abs, core::image_decode::kPreviewMaxPixels); + } + if (loaded.isNull()) { m_preview->setText(QStringLiteral("加载失败")); return; } + QPixmap pm = QPixmap::fromImage(loaded); m_preview->setPixmap(pm.scaled(m_preview->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation)); } bool FrameAnimationDialog::applyImageToFrame(int frame, const QString& absImagePath) { - // Qt 默认的 image allocation limit 较小,超大分辨率图可能会被拒绝。 - // 这里提高 limit,并对极端大图按像素数上限自动缩放,避免 OOM。 -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) - QImageReader::setAllocationLimit(1024); // MB -#endif - QImageReader reader(absImagePath); - reader.setAutoTransform(true); - const QSize sz = reader.size(); - if (sz.isValid()) { - constexpr qint64 kMaxPixels = 120LL * 1000LL * 1000LL; // 120MP - const qint64 pixels = qint64(sz.width()) * qint64(sz.height()); - if (pixels > kMaxPixels) { - const double s = std::sqrt(double(kMaxPixels) / std::max(1.0, double(pixels))); - const int nw = std::max(1, int(std::lround(sz.width() * s))); - const int nh = std::max(1, int(std::lround(sz.height() * s))); - reader.setScaledSize(QSize(nw, nh)); - } - } - QImage img = reader.read(); + QImage img = + core::image_file::loadImageLimited(absImagePath, core::image_decode::kWorkspaceMaxPixels); if (img.isNull()) { return false; } @@ -200,6 +217,93 @@ bool FrameAnimationDialog::applyImageToFrame(int frame, const QString& absImageP return m_workspace.setEntityImageFrame(m_entityId, frame, img); } +QVector FrameAnimationDialog::targetFramesForWrite(int requestedCount) const { + QVector frames; + if (m_list) { + const auto selected = m_list->selectedItems(); + frames.reserve(selected.size()); + for (auto* it : selected) { + if (it) frames.push_back(it->data(Qt::UserRole).toInt()); + } + std::sort(frames.begin(), frames.end()); + frames.erase(std::unique(frames.begin(), frames.end()), frames.end()); + } + if (!frames.isEmpty()) { + if (requestedCount > 0 && frames.size() > requestedCount) { + frames.resize(requestedCount); + } + return frames; + } + + const int count = requestedCount > 0 ? std::min(requestedCount, m_end - m_start + 1) + : (m_end - m_start + 1); + frames.reserve(count); + for (int i = 0; i < count; ++i) { + frames.push_back(m_start + i); + } + return frames; +} + +QByteArray FrameAnimationDialog::currentEntityPngBytes(QString* outError) const { + if (outError) outError->clear(); + if (!m_workspace.isOpen()) { + if (outError) *outError = QStringLiteral("未打开项目。"); + return {}; + } + + const auto& ents = m_workspace.entities(); + const core::Project::Entity* hit = nullptr; + for (const auto& e : ents) { + if (e.id == m_entityId) { + hit = &e; + break; + } + } + if (!hit) { + if (outError) *outError = QStringLiteral("未找到当前实体。"); + return {}; + } + + int sourceFrame = m_start; + if (m_list && m_list->currentItem()) { + sourceFrame = m_list->currentItem()->data(Qt::UserRole).toInt(); + } + + const QString abs = resolvedImageAbsForFrame(m_workspace, *hit, sourceFrame); + if (abs.isEmpty()) { + if (outError) *outError = QStringLiteral("当前实体没有可用的角色贴图。"); + return {}; + } + + QImage img; + if (abs.startsWith(QStringLiteral("pngb64:"))) { + const QByteArray b64 = abs.mid(QStringLiteral("pngb64:").size()).toLatin1(); + const QByteArray raw = QByteArray::fromBase64(b64); + img.loadFromData(raw, "PNG"); + } else { + if (!QFileInfo::exists(abs)) { + if (outError) *outError = QStringLiteral("当前实体没有可用的角色贴图。"); + return {}; + } + img = core::image_file::loadImageLimited(abs, core::image_decode::kWorkspaceMaxPixels); + } + if (img.isNull()) { + if (outError) *outError = QStringLiteral("角色贴图读取失败。"); + return {}; + } + if (img.format() != QImage::Format_ARGB32 && img.format() != QImage::Format_ARGB32_Premultiplied) { + img = img.convertToFormat(QImage::Format_ARGB32); + } + + QByteArray png; + QBuffer buf(&png); + if (!buf.open(QIODevice::WriteOnly) || !img.save(&buf, "PNG")) { + if (outError) *outError = QStringLiteral("角色贴图编码 PNG 失败。"); + return {}; + } + return png; +} + void FrameAnimationDialog::onReplaceCurrentFrame() { auto* it = m_list->currentItem(); if (!it) return; @@ -222,15 +326,17 @@ void FrameAnimationDialog::onReplaceCurrentFrame() { } if (hit) { // 是否已有精确关键帧 - for (const auto& k : hit->imageFrames) { + const auto spriteFrames = m_workspace.entitySpriteFrames(m_entityId); + for (const auto& k : spriteFrames) { if (k.frame == f + 1) { hasExplicitKeyAtFPlus1 = true; } } - prevRelPathForF = core::sampleImagePath(hit->imageFrames, f, hit->imagePath); - prevRelPathForFPlus1 = core::sampleImagePath(hit->imageFrames, f + 1, hit->imagePath); + prevRelPathForF = m_workspace.entityImagePathAt(m_entityId, f); + prevRelPathForFPlus1 = m_workspace.entityImagePathAt(m_entityId, f + 1); } } + (void)prevRelPathForF; const QString path = QFileDialog::getOpenFileName( this, @@ -255,14 +361,12 @@ void FrameAnimationDialog::onReplaceCurrentFrame() { } void FrameAnimationDialog::onClearCurrentFrame() { - auto* it = m_list->currentItem(); - if (!it) return; - const int f = it->data(Qt::UserRole).toInt(); - if (!m_workspace.removeEntityImageFrame(m_entityId, f)) { - return; + const QVector frames = targetFramesForWrite(0); + for (int f : frames) { + m_workspace.removeEntityImageFrame(m_entityId, f); } rebuildFrameList(); - updatePreviewForFrame(f); + onSelectFrame(); } void FrameAnimationDialog::onBatchImportFiles() { @@ -274,10 +378,10 @@ void FrameAnimationDialog::onBatchImportFiles() { if (paths.isEmpty()) return; QStringList sorted = paths; sorted.sort(Qt::CaseInsensitive); - const int need = m_end - m_start + 1; - const int count = std::min(need, static_cast(sorted.size())); + const QVector targets = targetFramesForWrite(static_cast(sorted.size())); + const int count = std::min(static_cast(targets.size()), static_cast(sorted.size())); for (int i = 0; i < count; ++i) { - applyImageToFrame(m_start + i, sorted[i]); + applyImageToFrame(targets[i], sorted[i]); } rebuildFrameList(); onSelectFrame(); @@ -296,12 +400,213 @@ void FrameAnimationDialog::onBatchImportFolder() { QStringLiteral("*.webp")}; const QStringList files = d.entryList(filters, QDir::Files, QDir::Name); if (files.isEmpty()) return; - const int need = m_end - m_start + 1; - const int count = std::min(need, static_cast(files.size())); + const QVector targets = targetFramesForWrite(static_cast(files.size())); + const int count = std::min(static_cast(targets.size()), static_cast(files.size())); for (int i = 0; i < count; ++i) { - applyImageToFrame(m_start + i, d.filePath(files[i])); + applyImageToFrame(targets[i], d.filePath(files[i])); } rebuildFrameList(); onSelectFrame(); } +void FrameAnimationDialog::onGenerateAiFrames() { + const QVector targetFrames = targetFramesForWrite(0); + const int frameCount = static_cast(targetFrames.size()); + if (frameCount <= 0) { + QMessageBox::warning(this, QStringLiteral("AI 生成动画"), QStringLiteral("当前区间无有效帧。")); + return; + } + + QString imageErr; + const QByteArray characterPng = currentEntityPngBytes(&imageErr); + if (characterPng.isEmpty()) { + QMessageBox::warning(this, + QStringLiteral("AI 生成动画"), + imageErr.isEmpty() ? QStringLiteral("无法读取当前实体贴图。") : imageErr); + return; + } + + QDialog cfg(this); + cfg.setWindowTitle(QStringLiteral("AI 生成实体动画")); + cfg.setModal(true); + auto* root = new QVBoxLayout(&cfg); + root->setContentsMargins(14, 14, 14, 14); + root->setSpacing(10); + + auto* form = new QFormLayout(); + root->addLayout(form); + + auto* promptEdit = new QLineEdit(QStringLiteral("walk cycle"), &cfg); + form->addRow(QStringLiteral("动画提示词"), promptEdit); + + auto* bgRow = new QWidget(&cfg); + auto* bgLayout = new QHBoxLayout(bgRow); + bgLayout->setContentsMargins(0, 0, 0, 0); + auto* bgEdit = new QLineEdit(QStringLiteral("#00FF00"), bgRow); + auto* bgBtn = new QPushButton(QStringLiteral("选择…"), bgRow); + bgLayout->addWidget(bgEdit, 1); + bgLayout->addWidget(bgBtn); + form->addRow(QStringLiteral("生成背景色"), bgRow); + + auto* frameLabel = new QLabel(QString::number(frameCount), &cfg); + form->addRow(QStringLiteral("帧数"), frameLabel); + + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &cfg); + buttons->button(QDialogButtonBox::Ok)->setText(QStringLiteral("开始生成")); + buttons->button(QDialogButtonBox::Cancel)->setText(QStringLiteral("取消")); + root->addWidget(buttons); + + connect(bgBtn, &QPushButton::clicked, &cfg, [bgEdit, &cfg]() { + const QColor initial(bgEdit->text().trimmed()); + const QColor c = QColorDialog::getColor(initial.isValid() ? initial : QColor(0, 255, 0), + &cfg, + QStringLiteral("选择纯色背景")); + if (c.isValid()) { + bgEdit->setText(c.name(QColor::HexRgb).toUpper()); + } + }); + connect(buttons, &QDialogButtonBox::accepted, &cfg, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, &cfg, &QDialog::reject); + + if (cfg.exec() != QDialog::Accepted) { + return; + } + const QString prompt = promptEdit->text().trimmed(); + if (prompt.isEmpty()) { + QMessageBox::warning(this, QStringLiteral("AI 生成动画"), QStringLiteral("动画提示词不能为空。")); + return; + } + + QString base; + const QByteArray env = qgetenv("MODEL_SERVER_URL"); + base = env.isEmpty() ? QStringLiteral("http://127.0.0.1:8000") : QString::fromUtf8(env); + + auto* client = new core::ModelServerClient(this); + client->setBaseUrl(QUrl(base)); + + QString immediateErr; + QNetworkReply* reply = client->animateCharacterSequenceAsync( + characterPng, + prompt, + QStringLiteral("blur, distortion, extra limbs, bad hands, text, watermark"), + bgEdit->text(), + 40, + frameCount, + 25, + 8.0, + 768, + -1, + &immediateErr); + if (!reply) { + QMessageBox::warning(this, + QStringLiteral("AI 生成动画"), + immediateErr.isEmpty() ? QStringLiteral("无法发起后端请求。") : immediateErr); + client->deleteLater(); + return; + } + + auto* task = new CancelableTaskDialog( + QStringLiteral("AI 生成实体动画"), + QStringLiteral("正在生成 PNG 序列。该过程可能耗时较久,请稍候……"), + this); + task->setAttribute(Qt::WA_DeleteOnClose, true); + connect(task, &CancelableTaskDialog::canceled, this, [reply, task]() { + if (reply) reply->abort(); + if (task) task->reject(); + }); + + connect(reply, &QNetworkReply::finished, this, [this, reply, task, client, frameCount, targetFrames]() { + const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray raw = reply->readAll(); + const auto netErr = reply->error(); + const QString netErrStr = reply->errorString(); + + reply->deleteLater(); + client->deleteLater(); + if (task) task->close(); + + if (netErr != QNetworkReply::NoError) { + QMessageBox::warning(this, + QStringLiteral("AI 生成动画"), + QStringLiteral("网络错误:%1").arg(netErrStr)); + return; + } + if (httpStatus != 200) { + QString detail; + const QJsonDocument jerr = QJsonDocument::fromJson(raw); + if (jerr.isObject()) { + detail = jerr.object().value(QStringLiteral("detail")).toString(); + } + QMessageBox::warning(this, + QStringLiteral("AI 生成动画"), + detail.isEmpty() ? QStringLiteral("后端返回 HTTP %1。").arg(httpStatus) + : QStringLiteral("后端错误(HTTP %1):%2").arg(httpStatus).arg(detail)); + return; + } + + const QJsonDocument jd = QJsonDocument::fromJson(raw); + if (!jd.isObject()) { + QMessageBox::warning(this, QStringLiteral("AI 生成动画"), QStringLiteral("响应不是 JSON。")); + return; + } + const QJsonObject obj = jd.object(); + if (!obj.value(QStringLiteral("success")).toBool()) { + const QString err = obj.value(QStringLiteral("error")).toString(); + QMessageBox::warning(this, + QStringLiteral("AI 生成动画"), + err.isEmpty() ? QStringLiteral("生成失败。") : err); + return; + } + + const QJsonArray frames = obj.value(QStringLiteral("frames")).toArray(); + if (frames.isEmpty()) { + QMessageBox::warning(this, QStringLiteral("AI 生成动画"), QStringLiteral("后端未返回 PNG 帧。")); + return; + } + + int applied = 0; + const int count = std::min(frameCount, static_cast(frames.size())); + for (int i = 0; i < count; ++i) { + const QJsonObject fobj = frames.at(i).toObject(); + const QString b64 = fobj.value(QStringLiteral("image_b64")).toString(); + if (b64.isEmpty()) { + continue; + } + + QImage img; + img.loadFromData(QByteArray::fromBase64(b64.toLatin1()), "PNG"); + if (img.isNull()) { + continue; + } + if (img.format() != QImage::Format_ARGB32_Premultiplied) { + img = img.convertToFormat(QImage::Format_ARGB32_Premultiplied); + } + if (i < targetFrames.size() && m_workspace.setEntityImageFrame(m_entityId, targetFrames[i], img)) { + ++applied; + } + } + + rebuildFrameList(); + if (m_list && m_list->count() > 0) { + m_list->setCurrentRow(0); + } + onSelectFrame(); + + if (applied <= 0) { + QMessageBox::warning(this, QStringLiteral("AI 生成动画"), QStringLiteral("生成成功,但写入项目失败。")); + return; + } + if (applied < frameCount) { + QMessageBox::information(this, + QStringLiteral("AI 生成动画"), + QStringLiteral("已写入 %1/%2 帧;部分帧解析或保存失败。").arg(applied).arg(frameCount)); + } else { + QMessageBox::information(this, + QStringLiteral("AI 生成动画"), + QStringLiteral("已写入 %1 帧。").arg(applied)); + } + }); + + task->show(); +} + diff --git a/client/gui/dialogs/FrameAnimationDialog.h b/client/gui/dialogs/FrameAnimationDialog.h index d67730a..aa35835 100644 --- a/client/gui/dialogs/FrameAnimationDialog.h +++ b/client/gui/dialogs/FrameAnimationDialog.h @@ -3,6 +3,7 @@ #include #include #include +#include namespace core { class ProjectWorkspace; @@ -27,11 +28,14 @@ private slots: void onClearCurrentFrame(); void onBatchImportFiles(); void onBatchImportFolder(); + void onGenerateAiFrames(); private: void rebuildFrameList(); void updatePreviewForFrame(int frame); bool applyImageToFrame(int frame, const QString& absImagePath); + QVector targetFramesForWrite(int requestedCount) const; + QByteArray currentEntityPngBytes(QString* outError) const; private: core::ProjectWorkspace& m_workspace; @@ -46,6 +50,7 @@ private: QPushButton* m_btnClear = nullptr; QPushButton* m_btnImportFiles = nullptr; QPushButton* m_btnImportFolder = nullptr; + QPushButton* m_btnAiGenerate = nullptr; QString m_defaultImageAbs; }; diff --git a/client/gui/dialogs/ImageCropDialog.cpp b/client/gui/dialogs/ImageCropDialog.cpp index 9d7cf38..68c268a 100644 --- a/client/gui/dialogs/ImageCropDialog.cpp +++ b/client/gui/dialogs/ImageCropDialog.cpp @@ -1,64 +1,122 @@ #include "dialogs/ImageCropDialog.h" +#include "core/image/ImageDecodeConfig.h" +#include "core/image/ImageFileLoader.h" + #include +#include #include #include #include #include #include -#include +#include +#include #include +#include + +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) +# define CROP_LOCAL_POS_M(E) (E)->position() +# define CROP_LOCAL_POS_W(E) (E)->position() +#else +# define CROP_LOCAL_POS_M(E) (E)->localPos() +# define CROP_LOCAL_POS_W(E) (E)->posF() +#endif + +namespace { + +constexpr int kMinCropSidePx = 2; +/// 相对「适应窗口」倍率的乘子;范围极大以便实质任意缩放(仍避免数值溢出) +constexpr qreal kZoomMulMin = 1e-12; +constexpr qreal kZoomMulMax = 1e12; +constexpr qreal kHandleViewPx = 7.0; + +enum class HitPart : quint8 { + None, + Inside, + N, + Ne, + E, + Se, + S, + Sw, + W, + Nw, + /// 平移视图(选区外左键拖动) + PanView +}; + +QRect clampRectToBounds(QRect r, const QRect& bounds) { + r = r.normalized(); + if (r.width() < kMinCropSidePx || r.height() < kMinCropSidePx) { + return {}; + } + if (r.left() < bounds.left()) { + r.moveLeft(bounds.left()); + } + if (r.top() < bounds.top()) { + r.moveTop(bounds.top()); + } + if (r.right() > bounds.right()) { + r.moveRight(bounds.right()); + } + if (r.bottom() > bounds.bottom()) { + r.moveBottom(bounds.bottom()); + } + if (r.width() < kMinCropSidePx || r.height() < kMinCropSidePx) { + return {}; + } + return r; +} + +} // namespace + class ImageCropDialog::CropView final : public QWidget { public: explicit CropView(QWidget* parent = nullptr) : QWidget(parent) { setMouseTracking(true); + setFocusPolicy(Qt::WheelFocus); setMinimumSize(480, 320); } void setImage(const QImage& img) { m_image = img; - m_selection = {}; + m_zoom = 1.0; + m_pan = QPointF(); + m_selImage = m_image.isNull() ? QRect() : QRect(QPoint(0, 0), m_image.size()); + m_dragPart = HitPart::None; updateGeometry(); update(); } - bool hasSelection() const { return !m_selection.isNull() && m_selection.width() > 0 && m_selection.height() > 0; } + bool hasSelection() const { + return !m_image.isNull() && m_selImage.width() >= kMinCropSidePx && m_selImage.height() >= kMinCropSidePx; + } QRect selectionInImagePixels() const { if (m_image.isNull() || !hasSelection()) { return {}; } - const auto map = viewToImageTransform(); - // selection 是 view 坐标;映射到 image 像素坐标 - const QRectF selF = QRectF(m_selection).normalized(); - bool invertible = false; - const QTransform inv = map.inverted(&invertible); - if (!invertible) { - return {}; - } - - const QPointF topLeftImg = inv.map(selF.topLeft()); - const QPointF bottomRightImg = inv.map(selF.bottomRight()); - - // 使用 floor/ceil,避免因为取整导致宽高变 0 - const int left = qFloor(std::min(topLeftImg.x(), bottomRightImg.x())); - const int top = qFloor(std::min(topLeftImg.y(), bottomRightImg.y())); - const int right = qCeil(std::max(topLeftImg.x(), bottomRightImg.x())); - const int bottom = qCeil(std::max(topLeftImg.y(), bottomRightImg.y())); - - QRect r(QPoint(left, top), QPoint(right, bottom)); - r = r.normalized().intersected(QRect(0, 0, m_image.width(), m_image.height())); - return r; + return m_selImage.normalized().intersected(QRect(0, 0, m_image.width(), m_image.height())); } void resetSelection() { - m_selection = {}; + if (!m_image.isNull()) { + m_selImage = QRect(QPoint(0, 0), m_image.size()); + } else { + m_selImage = {}; + } update(); } protected: + void resizeEvent(QResizeEvent* e) override { + QWidget::resizeEvent(e); + update(); + } + void paintEvent(QPaintEvent*) override { QPainter p(this); p.fillRect(rect(), palette().window()); @@ -69,84 +127,362 @@ protected: return; } - const auto map = viewToImageTransform(); + qreal eff = 0; + QPointF tl; + computeLayout(eff, tl); + + const QTransform imgToView = imageToViewTransform(eff, tl); p.setRenderHint(QPainter::SmoothPixmapTransform, true); - p.setTransform(map); + p.setTransform(imgToView); p.drawImage(QPoint(0, 0), m_image); p.resetTransform(); - if (hasSelection()) { - // 避免 CompositionMode_Clear 在某些平台/样式下表现异常: - // 用“围绕选区画四块遮罩”的方式实现高亮裁剪区域。 - const QRect sel = m_selection.normalized().intersected(rect()); - const QColor shade(0, 0, 0, 120); + const QRectF selView = imgToView.mapRect(QRectF(m_selImage)); + const QRect sel = selView.toAlignedRect().intersected(rect()); - // 上 - p.fillRect(QRect(0, 0, width(), sel.top()), shade); - // 下 - p.fillRect(QRect(0, sel.bottom(), width(), height() - sel.bottom()), shade); - // 左 - p.fillRect(QRect(0, sel.top(), sel.left(), sel.height()), shade); - // 右 - p.fillRect(QRect(sel.right(), sel.top(), width() - sel.right(), sel.height()), shade); + const QColor shade(0, 0, 0, 120); + p.fillRect(QRect(0, 0, width(), sel.top()), shade); + p.fillRect(QRect(0, sel.bottom(), width(), height() - sel.bottom()), shade); + p.fillRect(QRect(0, sel.top(), sel.left(), sel.height()), shade); + p.fillRect(QRect(sel.right(), sel.top(), width() - sel.right(), sel.height()), shade); - p.setPen(QPen(QColor(255, 255, 255, 220), 2)); - p.drawRect(sel); + p.setPen(QPen(QColor(255, 255, 255, 230), 2)); + p.setBrush(Qt::NoBrush); + p.drawRect(sel); + + const qreal hsz = kHandleViewPx; + const QPointF c[4] = { + selView.topLeft(), + selView.topRight(), + selView.bottomRight(), + selView.bottomLeft(), + }; + p.setPen(QPen(QColor(255, 255, 255, 240), 1)); + p.setBrush(QColor(255, 255, 255, 220)); + for (const QPointF& pt : c) { + p.drawRect(QRectF(pt.x() - hsz * 0.5, pt.y() - hsz * 0.5, hsz, hsz)); } } + void wheelEvent(QWheelEvent* e) override { + if (m_image.isNull()) { + e->ignore(); + return; + } + qreal effOld = 0; + QPointF tlOld; + computeLayout(effOld, tlOld); + + const QPointF cursor = CROP_LOCAL_POS_W(e); + const QPointF imgPt((cursor.x() - tlOld.x()) / effOld, (cursor.y() - tlOld.y()) / effOld); + + const qreal steps = e->angleDelta().y() / 120.0; + const qreal factor = std::pow(1.1, steps); + const qreal newZoom = std::clamp(m_zoom * factor, kZoomMulMin, kZoomMulMax); + if (qFuzzyCompare(newZoom, m_zoom)) { + e->accept(); + return; + } + + m_zoom = newZoom; + + qreal effNew = 0; + QPointF tlIgnored; + computeLayout(effNew, tlIgnored); + + const QPointF tlNew(cursor.x() - imgPt.x() * effNew, cursor.y() - imgPt.y() * effNew); + const qreal iw = m_image.width(); + const qreal ih = m_image.height(); + const qreal vw = width(); + const qreal vh = height(); + const QPointF centerOff((vw - iw * effNew) / 2.0, (vh - ih * effNew) / 2.0); + m_pan = tlNew - centerOff; + + e->accept(); + update(); + } + void mousePressEvent(QMouseEvent* e) override { if (m_image.isNull() || e->button() != Qt::LeftButton) { return; } - m_dragging = true; - m_anchor = e->position().toPoint(); - m_selection = QRect(m_anchor, m_anchor); - update(); + qreal eff = 0; + QPointF tl; + computeLayout(eff, tl); + const QPointF viewPos = CROP_LOCAL_POS_M(e); + const QPointF img = viewToImage(viewPos, eff, tl); + + m_dragPart = hitTest(img, eff); + if (m_dragPart == HitPart::None) { + m_dragPart = HitPart::PanView; + } + if (m_dragPart == HitPart::PanView) { + m_pressView = viewPos; + m_panAtPress = m_pan; + setCursor(Qt::ClosedHandCursor); + return; + } + m_pressImg = img; + m_startSel = m_selImage; + updateCursorForHover(viewPos, eff, tl); } void mouseMoveEvent(QMouseEvent* e) override { - if (!m_dragging) { + if (m_image.isNull()) { return; } - const QPoint cur = e->position().toPoint(); - m_selection = QRect(m_anchor, cur).normalized(); - update(); + qreal eff = 0; + QPointF tl; + computeLayout(eff, tl); + const QPointF viewPos = CROP_LOCAL_POS_M(e); + + if ((e->buttons() & Qt::LeftButton) && m_dragPart == HitPart::PanView) { + m_pan = m_panAtPress + (viewPos - m_pressView); + update(); + return; + } + + if ((e->buttons() & Qt::LeftButton) && m_dragPart != HitPart::None && m_dragPart != HitPart::PanView) { + const QPointF img = viewToImage(viewPos, eff, tl); + applyDrag(m_dragPart, m_pressImg, img, m_startSel); + update(); + return; + } + + updateCursorForHover(viewPos, eff, tl); } void mouseReleaseEvent(QMouseEvent* e) override { - if (e->button() != Qt::LeftButton) { - return; + if (e->button() == Qt::LeftButton) { + m_dragPart = HitPart::None; + unsetCursor(); } - m_dragging = false; - update(); + QWidget::mouseReleaseEvent(e); } private: - QTransform viewToImageTransform() const { - // 让图片按比例 fit 到 view 中居中显示 - const QSizeF viewSize = size(); - const QSizeF imgSize = m_image.size(); - const qreal sx = viewSize.width() / imgSize.width(); - const qreal sy = viewSize.height() / imgSize.height(); - const qreal s = std::min(sx, sy); - - const qreal drawW = imgSize.width() * s; - const qreal drawH = imgSize.height() * s; - const qreal offsetX = (viewSize.width() - drawW) / 2.0; - const qreal offsetY = (viewSize.height() - drawH) / 2.0; + void computeLayout(qreal& outEffScale, QPointF& outTopLeft) const { + const qreal vw = width(); + const qreal vh = height(); + const qreal iw = m_image.width(); + const qreal ih = m_image.height(); + if (iw <= 0 || ih <= 0 || vw <= 1 || vh <= 1) { + outEffScale = 1.0; + outTopLeft = QPointF(); + return; + } + const qreal fit = std::min(vw / iw, vh / ih); + const qreal eff = fit * m_zoom; + outEffScale = eff; + outTopLeft = m_pan + QPointF((vw - iw * eff) / 2.0, (vh - ih * eff) / 2.0); + } + QTransform imageToViewTransform(qreal eff, const QPointF& tl) const { QTransform t; - t.translate(offsetX, offsetY); - t.scale(s, s); + t.translate(tl.x(), tl.y()); + t.scale(eff, eff); return t; } + QPointF viewToImage(const QPointF& viewPt, qreal eff, const QPointF& tl) const { + return QPointF((viewPt.x() - tl.x()) / eff, (viewPt.y() - tl.y()) / eff); + } + + qreal marginImage(qreal eff) const { + return std::max(kMinCropSidePx, kHandleViewPx / std::max(eff, 1e-6)); + } + + HitPart hitTest(const QPointF& img, qreal eff) const { + const qreal iw = m_image.width(); + const qreal ih = m_image.height(); + const QRectF boundsF(0, 0, iw, ih); + if (!boundsF.contains(img)) { + return HitPart::PanView; + } + const qreal m = marginImage(eff); + const QRect r = m_selImage.normalized(); + + const bool onN = std::abs(img.y() - r.top()) <= m && img.x() >= r.left() - m && img.x() <= r.right() + m; + const bool onS = std::abs(img.y() - r.bottom()) <= m && img.x() >= r.left() - m && img.x() <= r.right() + m; + const bool onW = std::abs(img.x() - r.left()) <= m && img.y() >= r.top() - m && img.y() <= r.bottom() + m; + const bool onE = std::abs(img.x() - r.right()) <= m && img.y() >= r.top() - m && img.y() <= r.bottom() + m; + + const bool cnw = std::abs(img.x() - r.left()) <= m && std::abs(img.y() - r.top()) <= m; + const bool cne = std::abs(img.x() - r.right()) <= m && std::abs(img.y() - r.top()) <= m; + const bool cse = std::abs(img.x() - r.right()) <= m && std::abs(img.y() - r.bottom()) <= m; + const bool csw = std::abs(img.x() - r.left()) <= m && std::abs(img.y() - r.bottom()) <= m; + + if (cnw) { + return HitPart::Nw; + } + if (cne) { + return HitPart::Ne; + } + if (cse) { + return HitPart::Se; + } + if (csw) { + return HitPart::Sw; + } + if (onN && !onW && !onE) { + return HitPart::N; + } + if (onS && !onW && !onE) { + return HitPart::S; + } + if (onW && !onN && !onS) { + return HitPart::W; + } + if (onE && !onN && !onS) { + return HitPart::E; + } + const QRect fullImage(0, 0, m_image.width(), m_image.height()); + if (r.contains(img.toPoint())) { + // 选区为整图时没有「可平移的选区」,框内左键用于平移视图 + if (r.normalized() == fullImage.normalized()) { + return HitPart::PanView; + } + return HitPart::Inside; + } + return HitPart::PanView; + } + + void updateCursorForHover(const QPointF& viewPt, qreal eff, const QPointF& tl) { + if (m_dragPart != HitPart::None) { + return; + } + const QPointF img = viewToImage(viewPt, eff, tl); + const HitPart h = hitTest(img, eff); + switch (h) { + case HitPart::PanView: + setCursor(Qt::OpenHandCursor); + break; + case HitPart::Inside: + setCursor(Qt::SizeAllCursor); + break; + case HitPart::N: + setCursor(Qt::SizeVerCursor); + break; + case HitPart::S: + setCursor(Qt::SizeVerCursor); + break; + case HitPart::W: + setCursor(Qt::SizeHorCursor); + break; + case HitPart::E: + setCursor(Qt::SizeHorCursor); + break; + case HitPart::Nw: + case HitPart::Se: + setCursor(Qt::SizeFDiagCursor); + break; + case HitPart::Ne: + case HitPart::Sw: + setCursor(Qt::SizeBDiagCursor); + break; + default: + unsetCursor(); + break; + } + } + + void applyDrag(HitPart part, const QPointF& pressImg, const QPointF& curImg, const QRect& start) { + const QRect bounds(0, 0, m_image.width(), m_image.height()); + QRect r = start.normalized(); + const int L = r.left(); + const int T = r.top(); + const int R = r.right(); + const int B = r.bottom(); + + auto clampX = [&](int x) { return std::clamp(x, bounds.left(), bounds.right()); }; + auto clampY = [&](int y) { return std::clamp(y, bounds.top(), bounds.bottom()); }; + + switch (part) { + case HitPart::Inside: { + const QPoint delta = (curImg - pressImg).toPoint(); + r.translate(delta); + r = clampRectToBounds(r, bounds); + break; + } + case HitPart::N: { + int nt = clampY(int(std::round(curImg.y()))); + nt = std::min(nt, B - kMinCropSidePx); + r = QRect(L, nt, R - L + 1, B - nt + 1); + break; + } + case HitPart::S: { + int nb = clampY(int(std::round(curImg.y()))); + nb = std::max(nb, T + kMinCropSidePx); + r = QRect(L, T, R - L + 1, nb - T + 1); + break; + } + case HitPart::W: { + int nl = clampX(int(std::round(curImg.x()))); + nl = std::min(nl, R - kMinCropSidePx); + r = QRect(nl, T, R - nl + 1, B - T + 1); + break; + } + case HitPart::E: { + int nr = clampX(int(std::round(curImg.x()))); + nr = std::max(nr, L + kMinCropSidePx); + r = QRect(L, T, nr - L + 1, B - T + 1); + break; + } + case HitPart::Nw: { + int nl = clampX(int(std::round(curImg.x()))); + int nt = clampY(int(std::round(curImg.y()))); + nl = std::min(nl, R - kMinCropSidePx); + nt = std::min(nt, B - kMinCropSidePx); + r = QRect(QPoint(nl, nt), QPoint(R, B)).normalized(); + break; + } + case HitPart::Ne: { + int nr = clampX(int(std::round(curImg.x()))); + int nt = clampY(int(std::round(curImg.y()))); + nr = std::max(nr, L + kMinCropSidePx); + nt = std::min(nt, B - kMinCropSidePx); + r = QRect(QPoint(L, nt), QPoint(nr, B)).normalized(); + break; + } + case HitPart::Se: { + int nr = clampX(int(std::round(curImg.x()))); + int nb = clampY(int(std::round(curImg.y()))); + nr = std::max(nr, L + kMinCropSidePx); + nb = std::max(nb, T + kMinCropSidePx); + r = QRect(QPoint(L, T), QPoint(nr, nb)).normalized(); + break; + } + case HitPart::Sw: { + int nl = clampX(int(std::round(curImg.x()))); + int nb = clampY(int(std::round(curImg.y()))); + nl = std::min(nl, R - kMinCropSidePx); + nb = std::max(nb, T + kMinCropSidePx); + r = QRect(QPoint(nl, T), QPoint(R, nb)).normalized(); + break; + } + case HitPart::PanView: + break; + default: + break; + } + + m_selImage = clampRectToBounds(r, bounds); + if (m_selImage.isEmpty()) { + m_selImage = start; + } + } + private: QImage m_image; - bool m_dragging = false; - QPoint m_anchor; - QRect m_selection; + QRect m_selImage; + double m_zoom = 1.0; + QPointF m_pan; + + HitPart m_dragPart = HitPart::None; + QPointF m_pressImg; + QRect m_startSel; + QPointF m_pressView; + QPointF m_panAtPress; }; ImageCropDialog::ImageCropDialog(const QString& imagePath, QWidget* parent) @@ -160,34 +496,30 @@ ImageCropDialog::ImageCropDialog(const QString& imagePath, QWidget* parent) } void ImageCropDialog::loadImageOrClose() { - // Qt 默认的 image allocation limit 较小(常见为 256MB),超大分辨率图会被拒绝。 - // 这里用 QImageReader 并提高 limit;同时对极端大图按像素数上限自动缩放,避免 OOM。 -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) - QImageReader::setAllocationLimit(1024); // MB -#endif - QImageReader reader(m_imagePath); - reader.setAutoTransform(true); - const QSize sz = reader.size(); - if (sz.isValid()) { - constexpr qint64 kMaxPixels = 120LL * 1000LL * 1000LL; // 120MP - const qint64 pixels = qint64(sz.width()) * qint64(sz.height()); - if (pixels > kMaxPixels) { - const double s = std::sqrt(double(kMaxPixels) / std::max(1.0, double(pixels))); - const int nw = std::max(1, int(std::lround(sz.width() * s))); - const int nh = std::max(1, int(std::lround(sz.height() * s))); - reader.setScaledSize(QSize(nw, nh)); - } + if (!core::image_file::probeImagePixelSize(m_imagePath, &m_sourceLogicalSize)) { + m_sourceLogicalSize = QSize(); } - m_image = reader.read(); + m_image = + core::image_file::loadImageLimited(m_imagePath, core::image_decode::kPreviewMaxPixels); if (m_image.isNull()) { reject(); + return; + } + if (!m_sourceLogicalSize.isValid() || m_sourceLogicalSize.width() <= 0 || + m_sourceLogicalSize.height() <= 0) { + m_sourceLogicalSize = m_image.size(); } } void ImageCropDialog::rebuildUi() { auto* root = new QVBoxLayout(this); - auto* hint = new QLabel(QStringLiteral("拖拽选择裁剪区域(不选则使用整张图)。"), this); + auto* hint = new QLabel( + QStringLiteral( + "默认已框选整张图。滚轮可连续缩放;拖拽边或角调整裁剪,框内拖拽移动选区;选区外或留白处左键拖动平移视图。「重置」恢复整图选区。" + " 若已链接 libvips,超大图用流式解码与预览;否则回退 Qt。确定裁剪后坐标按原图逻辑像素对齐。"), + this); + hint->setWordWrap(true); root->addWidget(hint); m_view = new CropView(this); @@ -196,7 +528,7 @@ void ImageCropDialog::rebuildUi() { auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); m_okButton = buttons->button(QDialogButtonBox::Ok); - auto* resetBtn = new QPushButton(QStringLiteral("重置选择"), this); + auto* resetBtn = new QPushButton(QStringLiteral("重置"), this); buttons->addButton(resetBtn, QDialogButtonBox::ActionRole); connect(resetBtn, &QPushButton::clicked, this, &ImageCropDialog::onReset); @@ -213,7 +545,14 @@ QRect ImageCropDialog::selectedRectInImagePixels() const { if (!m_view) { return {}; } - return m_view->selectionInImagePixels(); + const QRect inPreview = m_view->selectionInImagePixels(); + if (!inPreview.isValid() || m_image.isNull()) { + return {}; + } + if (m_sourceLogicalSize.isValid() && m_sourceLogicalSize != m_image.size()) { + return core::image_decode::mapRectBetweenSizes(inPreview, m_image.size(), m_sourceLogicalSize); + } + return inPreview; } void ImageCropDialog::onReset() { @@ -225,4 +564,3 @@ void ImageCropDialog::onReset() { void ImageCropDialog::onOk() { accept(); } - diff --git a/client/gui/dialogs/ImageCropDialog.h b/client/gui/dialogs/ImageCropDialog.h index 2ced901..27d1432 100644 --- a/client/gui/dialogs/ImageCropDialog.h +++ b/client/gui/dialogs/ImageCropDialog.h @@ -3,6 +3,7 @@ #include #include #include +#include class QLabel; class QPushButton; @@ -30,5 +31,7 @@ private: QString m_imagePath; QImage m_image; + /// read() 前 reader.size() 得到的原图逻辑尺寸(与 m_image 可能为缩放预览不一致) + QSize m_sourceLogicalSize; }; diff --git a/client/gui/dialogs/InpaintPreviewDialog.cpp b/client/gui/dialogs/InpaintPreviewDialog.cpp index 87ee99a..bb8906d 100644 --- a/client/gui/dialogs/InpaintPreviewDialog.cpp +++ b/client/gui/dialogs/InpaintPreviewDialog.cpp @@ -28,7 +28,7 @@ static QScrollArea* wrapScroll(QWidget* child, QWidget* parent) { InpaintPreviewDialog::InpaintPreviewDialog(const QString& title, QWidget* parent) : QDialog(parent) { setModal(true); - setMinimumSize(860, 520); + setMinimumSize(720, 480); setWindowTitle(title); auto* root = new QVBoxLayout(this); @@ -49,7 +49,7 @@ InpaintPreviewDialog::InpaintPreviewDialog(const QString& title, QWidget* parent auto* btns = new QDialogButtonBox(this); btns->addButton(QStringLiteral("取消"), QDialogButtonBox::RejectRole); - btns->addButton(QStringLiteral("接受并写回"), QDialogButtonBox::AcceptRole); + btns->addButton(QStringLiteral("写回"), QDialogButtonBox::AcceptRole); connect(btns, &QDialogButtonBox::accepted, this, &QDialog::accept); connect(btns, &QDialogButtonBox::rejected, this, &QDialog::reject); root->addWidget(btns); diff --git a/client/gui/editor/EditorCanvas.cpp b/client/gui/editor/EditorCanvas.cpp index 6de3944..85574d6 100644 --- a/client/gui/editor/EditorCanvas.cpp +++ b/client/gui/editor/EditorCanvas.cpp @@ -19,7 +19,6 @@ #include #include #include -#include #include #include #include @@ -27,7 +26,13 @@ #include #include #include +#include +#include +#include +#include "core/image/ImageDecodeConfig.h" +#include "core/image/ImageFileLoader.h" +#include "core/large_image/VipsBackend.h" #include "core/library/EntityJson.h" #include "core/library/ToolJson.h" @@ -37,34 +42,43 @@ namespace { constexpr double kCameraRefViewportW = 1600.0; constexpr double kCameraRefViewportH = 900.0; +/// 画布自由缩放(世界→屏幕)上下限;与摄像机 viewScale 经 applyCameraViewport 换算后的 eff 对齐 +constexpr qreal kViewScaleMin = 0.001; +constexpr qreal kViewScaleMax = 400.0; + constexpr int kSamCropMargin = 32; constexpr int kMinStrokePointsSam = 4; constexpr int kMinStrokePointsManual = 8; + +[[nodiscard]] QPointF shapeCentroidWorld(const QVector& polyWorld, const QRectF& rect) { + if (!polyWorld.isEmpty()) { + return entity_cutout::polygonCentroid(polyWorld); + } + return rect.center(); +} constexpr int kMaxSamPointPrompts = 32; +constexpr char kMimeHotspotAnimationJson[] = "application/x-hfut-hotspot-animation+json"; static QImage readImageTolerant(const QString& absPath) { - if (absPath.isEmpty()) { - return {}; + return core::image_file::loadImageLimited(absPath, core::image_decode::kWorkspaceMaxPixels); +} + +/// 预览模式下:实体在屏上 footprint 较小时将贴图缩到约 2× 屏上最大边,降低过采样开销 +static QImage entityImageForPresentationDrawLoRes(const QImage& src, qreal screenWpx, qreal screenHpx) { + if (src.isNull()) { + return src; } -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) - // Qt 默认限制常见为 256MB;超大分辨率背景/深度可能会被拒绝。 - QImageReader::setAllocationLimit(1024); // MB -#endif - QImageReader reader(absPath); - reader.setAutoTransform(true); - const QSize sz = reader.size(); - if (sz.isValid()) { - // 防止极端大图导致内存占用爆炸:按像素数上限进行缩放读取。 - constexpr qint64 kMaxPixels = 160LL * 1000LL * 1000LL; // 160MP - const qint64 pixels = qint64(sz.width()) * qint64(sz.height()); - if (pixels > kMaxPixels) { - const double s = std::sqrt(double(kMaxPixels) / std::max(1.0, double(pixels))); - const int nw = std::max(1, int(std::lround(sz.width() * s))); - const int nh = std::max(1, int(std::lround(sz.height() * s))); - reader.setScaledSize(QSize(nw, nh)); - } + const int iw = src.width(); + const int ih = src.height(); + const qreal sm = std::max(screenWpx, screenHpx); + if (!(sm > 1.0)) { + return src; } - return reader.read(); + const int cap = std::clamp(static_cast(std::ceil(sm * 2.0)), 32, 4096); + if (std::max(iw, ih) <= cap) { + return src; + } + return src.scaled(QSize(cap, cap), Qt::KeepAspectRatio, Qt::SmoothTransformation); } QRectF cameraWorldViewportRect(const core::Project::Camera& cam) { @@ -94,20 +108,26 @@ QRectF clampRectTopLeftToBounds(const QRectF& rect, const QRectF& bounds) { return out; } -QVector snapStrokeToEdges(const QVector& strokeWorld, const QImage& bgImage, int searchRadiusPx) { - if (strokeWorld.size() < 3 || bgImage.isNull()) { +QVector snapStrokeToEdgesWorld(const QVector& strokeWorld, const QImage& bgGray, + const QPointF& originWorld, const QSize& extentWorld, + double searchRadiusWorld) { + if (strokeWorld.size() < 3 || bgGray.isNull() || extentWorld.width() < 1 || extentWorld.height() < 1) { return strokeWorld; } - QImage gray = bgImage.convertToFormat(QImage::Format_Grayscale8); - const int w = gray.width(); - const int h = gray.height(); + const int w = bgGray.width(); + const int h = bgGray.height(); + const double sx = static_cast(w) / static_cast(extentWorld.width()); + const double sy = static_cast(h) / static_cast(extentWorld.height()); + const int rPix = std::max(1, static_cast(std::ceil(searchRadiusWorld * std::min(sx, sy)))); + const double invSx = 1.0 / sx; + const double invSy = 1.0 / sy; + auto at = [&](int x, int y) -> int { x = std::clamp(x, 0, w - 1); y = std::clamp(y, 0, h - 1); - return static_cast(static_cast(gray.constScanLine(y))[x]); + return static_cast(static_cast(bgGray.constScanLine(y))[x]); }; auto gradMag = [&](int x, int y) -> int { - // 简易 Sobel 近似(整数) const int gx = -at(x - 1, y - 1) + at(x + 1, y - 1) + -2 * at(x - 1, y) + 2 * at(x + 1, y) + @@ -117,20 +137,24 @@ QVector snapStrokeToEdges(const QVector& strokeWorld, const QI at(x - 1, y + 1) + 2 * at(x, y + 1) + at(x + 1, y + 1); return std::abs(gx) + std::abs(gy); }; + QVector out; out.reserve(strokeWorld.size()); - const int r = std::max(1, searchRadiusPx); for (const QPointF& p : strokeWorld) { - const int cx = static_cast(std::round(p.x())); - const int cy = static_cast(std::round(p.y())); + const double lx = (p.x() - originWorld.x()) * sx; + const double ly = (p.y() - originWorld.y()) * sy; + const int cx = static_cast(std::round(lx)); + const int cy = static_cast(std::round(ly)); int bestX = cx; int bestY = cy; int bestG = -1; - for (int dy = -r; dy <= r; ++dy) { - for (int dx = -r; dx <= r; ++dx) { + for (int dy = -rPix; dy <= rPix; ++dy) { + for (int dx = -rPix; dx <= rPix; ++dx) { const int x = cx + dx; const int y = cy + dy; - if (x < 0 || y < 0 || x >= w || y >= h) continue; + if (x < 0 || y < 0 || x >= w || y >= h) { + continue; + } const int g = gradMag(x, y); if (g > bestG) { bestG = g; @@ -139,22 +163,19 @@ QVector snapStrokeToEdges(const QVector& strokeWorld, const QI } } } - out.push_back(QPointF(bestX, bestY)); + out.push_back(QPointF(originWorld.x() + (static_cast(bestX) + 0.5) * invSx, + originWorld.y() + (static_cast(bestY) + 0.5) * invSy)); } return out; } -bool buildSamSegmentPayloadFromStroke( - const QVector& strokeWorld, - const QImage& bgImage, - QByteArray& outCropPng, - QByteArray& outOverlayPng, - QPointF& outCropTopLeftWorld, - QJsonArray& outPointCoords, - QJsonArray& outPointLabels, - QJsonArray& outBoxXyxy -) { - if (strokeWorld.size() < kMinStrokePointsSam || bgImage.isNull()) { +bool buildSamSegmentPayloadFromStrokeMapped(const QVector& strokeWorld, const QImage& cropAny, + const QRect& cropWorldRect, QByteArray& outCropPng, + QByteArray& outOverlayPng, QPointF& outCropTopLeftWorld, + QJsonArray& outPointCoords, QJsonArray& outPointLabels, + QJsonArray& outBoxXyxy) { + if (strokeWorld.size() < kMinStrokePointsSam || cropAny.isNull() || cropWorldRect.width() < 1 || + cropWorldRect.height() < 1) { return false; } outCropPng.clear(); @@ -163,19 +184,18 @@ bool buildSamSegmentPayloadFromStroke( outPointLabels = QJsonArray{}; outBoxXyxy = QJsonArray{}; - const QRectF polyBr = QPolygonF(strokeWorld).boundingRect(); - if (polyBr.isEmpty()) { + const QImage cropRgb = cropAny.convertToFormat(QImage::Format_RGB888); + if (cropRgb.isNull()) { return false; } - const QRect cropRect = entity_cutout::clampRectToImage( - polyBr.adjusted(-kSamCropMargin, -kSamCropMargin, kSamCropMargin, kSamCropMargin).toAlignedRect(), - bgImage.size()); - if (cropRect.isEmpty()) { - return false; - } - outCropTopLeftWorld = cropRect.topLeft(); - const QImage cropRgb = bgImage.copy(cropRect).convertToFormat(QImage::Format_RGB888); + const QPointF origin = cropWorldRect.topLeft(); + outCropTopLeftWorld = origin; + const int cw = cropRgb.width(); + const int ch = cropRgb.height(); + const double sx = static_cast(cw) / static_cast(cropWorldRect.width()); + const double sy = static_cast(ch) / static_cast(cropWorldRect.height()); + QBuffer bufCrop(&outCropPng); if (!bufCrop.open(QIODevice::WriteOnly) || !cropRgb.save(&bufCrop, "PNG")) { outCropPng.clear(); @@ -183,10 +203,6 @@ bool buildSamSegmentPayloadFromStroke( } bufCrop.close(); - const QPointF origin = cropRect.topLeft(); - const int cw = cropRect.width(); - const int ch = cropRect.height(); - QImage overlay(cw, ch, QImage::Format_ARGB32_Premultiplied); overlay.fill(Qt::transparent); { @@ -200,7 +216,7 @@ bool buildSamSegmentPayloadFromStroke( QPolygonF local; local.reserve(strokeWorld.size()); for (const QPointF& w : strokeWorld) { - local.append(w - origin); + local.append(QPointF((w.x() - origin.x()) * sx, (w.y() - origin.y()) * sy)); } pop.drawPolyline(local); } @@ -211,27 +227,21 @@ bool buildSamSegmentPayloadFromStroke( } bufOv.close(); - // 关键修复: - // 用户“圈选”通常是在实体外侧画一圈。原实现把笔画点全当作前景点(1), - // 会让 SAM 倾向于把圈线/裁剪边缘当成前景,从而出现“沿小块图像边缘贴边”的 mask。 - // 新策略:圈内给一个前景点(1),圈线采样一些背景点(0)抑制外侧区域。 auto clampD = [](double v, double lo, double hi) { return std::clamp(v, lo, hi); }; - // 前景点:取笔画包围盒中心(通常落在圈内),并限制在裁剪范围内。 const QPointF centerWorld = QPolygonF(strokeWorld).boundingRect().center(); - const QPointF centerLocal = centerWorld - origin; + const QPointF centerLocal((centerWorld.x() - origin.x()) * sx, (centerWorld.y() - origin.y()) * sy); const double fgx = clampD(centerLocal.x(), 0.0, static_cast(cw - 1)); const double fgy = clampD(centerLocal.y(), 0.0, static_cast(ch - 1)); outPointCoords.append(QJsonArray{fgx, fgy}); outPointLabels.append(1); - // 背景点:在圈线(polyline)上均匀采样(最多 kMaxSamPointPrompts-1 个)。 const int n = static_cast(strokeWorld.size()); const int maxBg = std::max(0, kMaxSamPointPrompts - 1); if (n >= 2 && maxBg > 0) { const int step = std::max(1, (n + maxBg - 1) / maxBg); for (int i = 0; i < n; i += step) { - const QPointF L = strokeWorld[i] - origin; + const QPointF L((strokeWorld[i].x() - origin.x()) * sx, (strokeWorld[i].y() - origin.y()) * sy); const double bx = clampD(L.x(), 0.0, static_cast(cw - 1)); const double by = clampD(L.y(), 0.0, static_cast(ch - 1)); outPointCoords.append(QJsonArray{bx, by}); @@ -240,10 +250,10 @@ bool buildSamSegmentPayloadFromStroke( } const QRectF tight = QPolygonF(strokeWorld).boundingRect(); - double x1 = clampD(tight.left() - origin.x(), 0.0, static_cast(cw - 1)); - double y1 = clampD(tight.top() - origin.y(), 0.0, static_cast(ch - 1)); - double x2 = clampD(tight.right() - origin.x(), 0.0, static_cast(cw - 1)); - double y2 = clampD(tight.bottom() - origin.y(), 0.0, static_cast(ch - 1)); + double x1 = clampD((tight.left() - origin.x()) * sx, 0.0, static_cast(cw - 1)); + double y1 = clampD((tight.top() - origin.y()) * sy, 0.0, static_cast(ch - 1)); + double x2 = clampD((tight.right() - origin.x()) * sx, 0.0, static_cast(cw - 1)); + double y2 = clampD((tight.bottom() - origin.y()) * sy, 0.0, static_cast(ch - 1)); if (x2 <= x1) { x2 = std::min(static_cast(cw - 1), x1 + 1.0); } @@ -410,6 +420,11 @@ void EditorCanvas::dragEnterEvent(QDragEnterEvent* e) { if (!e || !e->mimeData()) { return; } + if (!m_presentationPreviewMode && m_presentationHotspotEditorActive && + e->mimeData()->hasFormat(QString::fromUtf8(kMimeHotspotAnimationJson))) { + e->acceptProposedAction(); + return; + } if (e->mimeData()->hasFormat(QStringLiteral("application/x-hfut-resource+json"))) { e->acceptProposedAction(); return; @@ -421,6 +436,11 @@ void EditorCanvas::dragMoveEvent(QDragMoveEvent* e) { if (!e || !e->mimeData()) { return; } + if (!m_presentationPreviewMode && m_presentationHotspotEditorActive && + e->mimeData()->hasFormat(QString::fromUtf8(kMimeHotspotAnimationJson))) { + e->acceptProposedAction(); + return; + } if (e->mimeData()->hasFormat(QStringLiteral("application/x-hfut-resource+json"))) { e->acceptProposedAction(); return; @@ -433,6 +453,23 @@ void EditorCanvas::dropEvent(QDropEvent* e) { QWidget::dropEvent(e); return; } + if (!m_presentationPreviewMode && m_presentationHotspotEditorActive && + e->mimeData()->hasFormat(QString::fromUtf8(kMimeHotspotAnimationJson))) { + const QByteArray bytes = e->mimeData()->data(QString::fromUtf8(kMimeHotspotAnimationJson)); + const auto doc = QJsonDocument::fromJson(bytes); + if (!doc.isObject()) { + e->ignore(); + return; + } + const QString animationId = doc.object().value(QStringLiteral("animationId")).toString(); + if (animationId.isEmpty()) { + e->ignore(); + return; + } + emit requestAddPresentationHotspotForAnimation(viewToWorld(e->position()), animationId); + e->acceptProposedAction(); + return; + } if (!e->mimeData()->hasFormat(QStringLiteral("application/x-hfut-resource+json"))) { QWidget::dropEvent(e); return; @@ -481,7 +518,6 @@ void EditorCanvas::dropEvent(QDropEvent* e) { ent.id.clear(); ent.imagePath.clear(); ent.entityPayloadPath.clear(); - ent.legacyAnimSidecarPath.clear(); ent.originWorld = dropWorld; @@ -540,8 +576,9 @@ void EditorCanvas::applyCameraViewport(const QPointF& centerWorld, double viewSc std::min(static_cast(std::max(1, width())) / kCameraRefViewportW, static_cast(std::max(1, height())) / kCameraRefViewportH); const double eff = std::max(1e-9, static_cast(viewScale)) * pixelRatio; - m_scale = std::clamp(static_cast(eff), 0.05, 50.0); + m_scale = std::clamp(static_cast(eff), kViewScaleMin, kViewScaleMax); m_pan = QPointF(width() / 2.0, height() / 2.0) - QPointF(centerWorld.x() * m_scale, centerWorld.y() * m_scale); + invalidateViewportLod(); update(); } @@ -549,6 +586,67 @@ QPointF EditorCanvas::viewCenterWorld() const { return viewToWorld(QPointF(width() / 2.0, height() / 2.0)); } +void EditorCanvas::setPresentationHotspots(const QVector& hotspots) { + m_presentationHotspots = hotspots; + bool found = false; + for (const auto& h : m_presentationHotspots) { + if (h.id == m_selectedPresentationHotspotId) { + found = true; + break; + } + } + if (!found) { + m_selectedPresentationHotspotId.clear(); + } + update(); +} + +void EditorCanvas::setPresentationHotspotEditorActive(bool on) { + if (m_presentationHotspotEditorActive == on) { + return; + } + m_presentationHotspotEditorActive = on; + if (!on) { + m_draggingHotspotIndex = -1; + m_dragging = false; + } + updateCursor(); + update(); +} + +void EditorCanvas::setPresentationHotspotPlaybackTargetEnabled(bool on) { + if (m_presentationHotspotPlaybackTargetEnabled == on) { + return; + } + m_presentationHotspotPlaybackTargetEnabled = on; + if (!on) { + if (m_presHoverTimer) { + m_presHoverTimer->stop(); + } + m_presHoverEntityIndex = -1; + m_presFocusedEntityIndex = -1; + m_presHoverPhase = 0.0; + m_presZoomAnimT = 0.0; + m_presZoomFinishingRestore = false; + m_presBgPanSession = false; + m_presBgDragDist = 0.0; + emit presentationInteractionDismissed(); + } + updateCursor(); + update(); +} + +void EditorCanvas::setSelectedPresentationHotspotId(const QString& id) { + m_selectedPresentationHotspotId = id; + update(); +} + +void EditorCanvas::clearPresentationHotspotSelection() { + m_selectedPresentationHotspotId.clear(); + m_draggingHotspotIndex = -1; + update(); +} + void EditorCanvas::setPresentationPreviewMode(bool on) { if (m_presentationPreviewMode == on) { return; @@ -568,6 +666,7 @@ void EditorCanvas::setPresentationPreviewMode(bool on) { m_presBgPanSession = false; m_presBgDragDist = 0.0; cancelBlackholeCopyResolve(); + m_draggingHotspotIndex = -1; if (on) { m_tool = Tool::Move; m_selectedEntity = -1; @@ -588,6 +687,9 @@ void EditorCanvas::setEntities(const QVector& entities, const QString prevSelectedId = (m_selectedEntity >= 0 && m_selectedEntity < m_entities.size()) ? m_entities[m_selectedEntity].id : QString(); + m_blackholeOverlayImageCache.clear(); + m_projectDirAbs = projectDirAbs; + m_entities.clear(); m_entities.reserve(entities.size()); @@ -612,24 +714,37 @@ void EditorCanvas::setEntities(const QVector& entities, v.animatedOriginWorld = originWorld; v.cutoutPolygonWorld = e.cutoutPolygonWorld; v.blackholeVisible = e.blackholeVisible; + v.blackholeOverlayPath = e.blackholeOverlayPath; + v.blackholeOverlayRect = e.blackholeOverlayRect; v.distanceScaleCalibMult = e.distanceScaleCalibMult; v.ignoreDistanceScale = e.ignoreDistanceScale; + v.priority = e.priority; - // 逐帧自动算 z:使用实体多边形质心作为锚点采样深度(O(1)),避免卡顿 - QVector polyTmp; - polyTmp.reserve(e.polygonLocal.size()); + const double userScaleAnimated = + core::sampleUserScale(e.userScaleKeys, m_currentFrame, e.userScale, core::KeyInterpolation::Linear); + v.userScale = std::max(1e-6, userScaleAnimated); + + // 逐帧自动算 z:采样点必须对“重锚枢轴(相对位置)”不敏感,否则会导致深度→缩放变化, + // 表现为“中心没改但看起来位置/形心漂移”。 + // + // 做法:先用工程给出的 depth 计算一次 scale0,构建 polygonWorld 得到稳定的形心,再用该形心采样深度, + // 然后用采样到的 z 计算最终 scale。 + const double ds01Base = depthToScale01(e.depth); + const double distScale0 = + e.ignoreDistanceScale ? 1.0 : distanceScaleFromDepth01(ds01Base, e.distanceScaleCalibMult); + const double scale0 = distScale0 * v.userScale; + + QVector poly0; + poly0.reserve(e.polygonLocal.size()); for (const auto& lp : e.polygonLocal) { - polyTmp.push_back(originWorld + lp); + poly0.push_back(originWorld + lp * scale0); } - const QPointF cTmp = polyTmp.isEmpty() ? originWorld : entity_cutout::polygonCentroid(polyTmp); - const int z = (!m_depthImage8.isNull()) ? sampleDepthAtPoint(m_depthImage8, cTmp) : e.depth; + const QPointF c0 = poly0.isEmpty() ? originWorld : entity_cutout::polygonCentroid(poly0); + const int z = (!m_depthImage8.isNull()) ? sampleDepthAtPoint(m_depthImage8, c0) : e.depth; v.depth = z; const double ds01 = depthToScale01(z); v.animatedDepthScale01 = ds01; - const double userScaleAnimated = - core::sampleUserScale(e.userScaleKeys, m_currentFrame, e.userScale, core::KeyInterpolation::Linear); - v.userScale = std::max(1e-6, userScaleAnimated); const double distScale = e.ignoreDistanceScale ? 1.0 : distanceScaleFromDepth01(ds01, e.distanceScaleCalibMult); const double scale = distScale * v.userScale; v.visualScale = scale; @@ -646,23 +761,46 @@ void EditorCanvas::setEntities(const QVector& entities, v.rect = v.pathWorld.boundingRect(); v.color = QColor(255, 120, 0, 70); - const QString imgRel = core::sampleImagePath(e.imageFrames, m_currentFrame, e.imagePath); - if (!imgRel.isEmpty() && !projectDirAbs.isEmpty()) { - const QString abs = QDir(projectDirAbs).filePath(imgRel); - if (QFileInfo::exists(abs)) { - QImage img(abs); + if (!e.runtimeImagePng.isEmpty()) { + QImage img; + img.loadFromData(e.runtimeImagePng, "PNG"); + if (!img.isNull() && img.format() != QImage::Format_ARGB32_Premultiplied) { + img = img.convertToFormat(QImage::Format_ARGB32_Premultiplied); + } + v.image = img; + } else { + const QString imgRel = e.imagePath; + if (imgRel.startsWith(QStringLiteral("pngb64:"))) { + const QByteArray b64 = imgRel.mid(QStringLiteral("pngb64:").size()).toLatin1(); + const QByteArray raw = QByteArray::fromBase64(b64); + QImage img; + img.loadFromData(raw, "PNG"); if (!img.isNull() && img.format() != QImage::Format_ARGB32_Premultiplied) { img = img.convertToFormat(QImage::Format_ARGB32_Premultiplied); } v.image = img; + } else if (!imgRel.isEmpty() && !projectDirAbs.isEmpty()) { + const QString abs = QDir(projectDirAbs).filePath(imgRel); + if (QFileInfo::exists(abs)) { + QImage img(abs); + if (!img.isNull() && img.format() != QImage::Format_ARGB32_Premultiplied) { + img = img.convertToFormat(QImage::Format_ARGB32_Premultiplied); + } + v.image = img; + } } } m_entities.push_back(v); } - // 绘制/命中顺序:深度小(远)先画,大(近)后画,近处盖住远处 + // 绘制/命中顺序: + // - priority 小(低)先画,大(高)后画,高优先级盖住低优先级 + // - priority 相同:深度小(远)先画,大(近)后画,近处盖住远处(等价于按距离缩放决定上下层) std::stable_sort(m_entities.begin(), m_entities.end(), [](const Entity& a, const Entity& b) { + if (a.priority != b.priority) { + return a.priority < b.priority; + } if (a.depth != b.depth) { return a.depth < b.depth; } @@ -719,12 +857,21 @@ void EditorCanvas::setEntities(const QVector& entities, void EditorCanvas::setTools(const QVector& tools, const QVector& opacities01) { m_tools.clear(); + // 需要用深度图为工具采样一个“近远”用于同优先级排序(与实体一致) + if (!m_depthAbsPath.isEmpty()) { + if (m_depthDirty) { + m_depthDirty = false; + QImage img = readImageTolerant(m_depthAbsPath); + m_depthImage8 = img.isNull() ? QImage() : img.convertToFormat(QImage::Format_Grayscale8); + } + } const qsizetype n = tools.size(); m_tools.reserve(n); for (qsizetype i = 0; i < n; ++i) { ToolView tv; tv.tool = tools[i]; tv.opacity = (i < opacities01.size()) ? std::clamp(opacities01[i], 0.0, 1.0) : 1.0; + tv.depthZ = (!m_depthImage8.isNull()) ? sampleDepthAtPoint(m_depthImage8, tv.tool.originWorld) : 0; m_tools.push_back(tv); } // 轨道变更:若当前选中的工具已不存在,则清除 @@ -749,6 +896,7 @@ void EditorCanvas::setTools(const QVector& tools, const QVe void EditorCanvas::setCameraOverlays(const QVector& cameras, const QString& selectedId, const QSet& tempHiddenCameraIds) { + const bool hadSelectedCamera = !m_selectedCameraId.isEmpty() || m_selectedCameraIndex >= 0 || m_draggingCamera; m_cameraOverlays = cameras; m_tempHiddenCameraIds = tempHiddenCameraIds; m_selectedCameraId = selectedId; @@ -764,6 +912,9 @@ void EditorCanvas::setCameraOverlays(const QVector& camer if (m_selectedCameraIndex < 0) { m_selectedCameraId.clear(); m_draggingCamera = false; + if (hadSelectedCamera) { + emit selectedCameraChanged(false, QString(), QPointF(), 1.0); + } } update(); } @@ -823,6 +974,10 @@ void EditorCanvas::setCurrentFrame(int frame) { } QPointF EditorCanvas::selectedAnimatedOriginWorld() const { + return selectedEntityPivotWorld(); +} + +QPointF EditorCanvas::selectedEntityPivotWorld() const { if (m_selectedEntity < 0 || m_selectedEntity >= m_entities.size()) { return {}; } @@ -1039,13 +1194,6 @@ bool EditorCanvas::startBlackholeCopyResolve(const QString& entityId) { return false; } ensurePixmapLoaded(); - if (m_bgImageDirty) { - m_bgImageDirty = false; - m_bgImage = readImageTolerant(m_bgAbsPath); - if (m_bgImage.format() != QImage::Format_ARGB32_Premultiplied && !m_bgImage.isNull()) { - m_bgImage = m_bgImage.convertToFormat(QImage::Format_ARGB32_Premultiplied); - } - } const QRectF bg = worldRectOfBackground(); if (bg.isNull()) { return false; @@ -1068,6 +1216,29 @@ bool EditorCanvas::startBlackholeCopyResolve(const QString& entityId) { srcRect.translate(shift, 0.0); srcRect = clampRectTopLeftToBounds(srcRect, bg); + m_bhCopyPatchValid = false; + m_bhCopyPatch = QImage(); + if (m_useViewportLod && !m_bgAbsPath.isEmpty()) { + QRect uni = + holeRect.united(srcRect).toAlignedRect().intersected(bg.toAlignedRect()); + if (uni.isValid() && uni.width() > 0 && uni.height() > 0 && + core::image_file::loadRegionToQImage(m_bgAbsPath, uni, 8192, 8192, &m_bhCopyPatch) && + !m_bhCopyPatch.isNull()) { + if (m_bhCopyPatch.format() != QImage::Format_ARGB32_Premultiplied) { + m_bhCopyPatch = m_bhCopyPatch.convertToFormat(QImage::Format_ARGB32_Premultiplied); + } + m_bhCopyPatchOrigin = uni.topLeft(); + m_bhCopyPatchValid = true; + } + } + if (!m_bhCopyPatchValid && m_bgImageDirty) { + m_bgImageDirty = false; + m_bgImage = readImageTolerant(m_bgAbsPath); + if (m_bgImage.format() != QImage::Format_ARGB32_Premultiplied && !m_bgImage.isNull()) { + m_bgImage = m_bgImage.convertToFormat(QImage::Format_ARGB32_Premultiplied); + } + } + m_selectedBlackholeEntityId = entityId; m_blackholeCopyResolveActive = true; m_blackholeCopyEntityId = entityId; @@ -1084,6 +1255,8 @@ void EditorCanvas::cancelBlackholeCopyResolve() { if (!m_blackholeCopyResolveActive) { return; } + m_bhCopyPatchValid = false; + m_bhCopyPatch = QImage(); m_blackholeCopyResolveActive = false; m_blackholeCopyEntityId.clear(); m_blackholeCopyHoleRect = QRectF(); @@ -1102,12 +1275,18 @@ void EditorCanvas::notifyBackgroundContentChanged() { update(); } +void EditorCanvas::notifyBlackholeOverlaysChanged() { + m_blackholeOverlayImageCache.clear(); + update(); +} + void EditorCanvas::setBackgroundImagePath(const QString& absolutePath) { if (m_bgAbsPath == absolutePath) { return; } cancelBlackholeCopyResolve(); m_bgAbsPath = absolutePath; + m_initialBgLoadFinishedEmitted = false; invalidatePixmap(); m_bgImageDirty = true; m_bgCutoutDirty = true; @@ -1115,6 +1294,28 @@ void EditorCanvas::setBackgroundImagePath(const QString& absolutePath) { m_bgImageCutout = QImage(); zoomToFit(); update(); + tryEmitInitialBackgroundLoadFinished(); +} + +void EditorCanvas::tryEmitInitialBackgroundLoadFinished() { + if (m_initialBgLoadFinishedEmitted) { + return; + } + if (m_bgAbsPath.isEmpty()) { + m_initialBgLoadFinishedEmitted = true; + emit initialBackgroundLoadFinished(); + return; + } + ensurePixmapLoaded(); + if (m_useViewportLod) { + if (!m_bgViewportImage.isNull()) { + m_initialBgLoadFinishedEmitted = true; + emit initialBackgroundLoadFinished(); + } + return; + } + m_initialBgLoadFinishedEmitted = true; + emit initialBackgroundLoadFinished(); } void EditorCanvas::setBackgroundVisible(bool on) { @@ -1210,26 +1411,29 @@ bool EditorCanvas::pendingPolygonContains(const QPointF& worldPos) const { void EditorCanvas::resetView() { m_scale = 1.0; m_pan = QPointF(0, 0); + invalidateViewportLod(); update(); } void EditorCanvas::zoomToFit() { ensurePixmapLoaded(); - if (m_bgPixmap.isNull() || width() <= 1 || height() <= 1) { + if ((!m_bgLogicalSize.isValid() && m_bgPixmap.isNull()) || width() <= 1 || height() <= 1) { resetView(); return; } const QSizeF viewSize = size(); - const QSizeF imgSize = m_bgPixmap.size(); + const QSizeF imgSize = + m_bgLogicalSize.isValid() ? QSizeF(m_bgLogicalSize) : QSizeF(m_bgPixmap.size()); const qreal sx = (viewSize.width() - 24.0) / imgSize.width(); const qreal sy = (viewSize.height() - 24.0) / imgSize.height(); - const qreal s = std::max(0.05, std::min(sx, sy)); + const qreal s = std::max(kViewScaleMin, std::min(sx, sy)); m_scale = s; // 让 world(0,0) 的图像左上角居中显示 const QSizeF draw(imgSize.width() * s, imgSize.height() * s); m_pan = QPointF((viewSize.width() - draw.width()) / 2.0, (viewSize.height() - draw.height()) / 2.0); + invalidateViewportLod(); update(); } @@ -1276,6 +1480,19 @@ void EditorCanvas::setCheckerboardVisible(bool on) { void EditorCanvas::invalidatePixmap() { m_pixmapDirty = true; m_bgPixmap = QPixmap(); + m_bgLogicalSize = QSize(); + m_useViewportLod = false; + m_bgViewportImage = QImage(); + m_bgViewportCacheRect = QRect(); + m_bgViewportDirty = true; + m_vpCachedDensity = 0.0; + m_vpPreferRegionDecode = false; + ++m_vpToken; +} + +void EditorCanvas::invalidateViewportLod() { + m_bgViewportDirty = true; + ++m_vpToken; } void EditorCanvas::ensurePixmapLoaded() const { @@ -1284,23 +1501,202 @@ void EditorCanvas::ensurePixmapLoaded() const { } m_pixmapDirty = false; m_bgPixmap = QPixmap(); - if (!m_bgAbsPath.isEmpty()) { - // 避免直接 QPixmap(path) 走默认 imageio 限制(超大图可能被 256MB 上限拒绝) - const QImage img = readImageTolerant(m_bgAbsPath); - if (!img.isNull()) { - m_bgPixmap = QPixmap::fromImage(img); - } + m_bgLogicalSize = QSize(); + m_useViewportLod = false; + if (m_bgAbsPath.isEmpty()) { + return; + } + + if (core::VipsBackend::isAvailable() && + core::image_file::probeImagePixelSize(m_bgAbsPath, &m_bgLogicalSize) && m_bgLogicalSize.isValid()) { + m_useViewportLod = true; + m_bgViewportDirty = true; + m_bgImageDirty = true; + m_bgCutoutDirty = true; + return; + } + + const QImage img = readImageTolerant(m_bgAbsPath); + if (!img.isNull()) { + m_bgPixmap = QPixmap::fromImage(img); + m_bgLogicalSize = img.size(); } m_bgImageDirty = true; m_bgCutoutDirty = true; } +void EditorCanvas::ensureBackgroundViewport() { + if (!m_useViewportLod || m_bgAbsPath.isEmpty() || !m_bgLogicalSize.isValid()) { + return; + } + + const QRectF bgRect(0, 0, m_bgLogicalSize.width(), m_bgLogicalSize.height()); + QRectF vis = visibleWorldRectF().intersected(bgRect); + if (vis.isEmpty()) { + m_bgViewportImage = QImage(); + m_bgViewportCacheRect = QRect(); + m_bgViewportDirty = false; + m_vpDecodeInFlight = false; + return; + } + + const QRect visI = vis.toAlignedRect(); + const qreal pr = devicePixelRatio(); + const qreal density = static_cast(m_scale) * pr; + const bool previewCameraHighQ = m_presentationPreviewMode && m_previewCameraViewLocked; + const qreal decodeBoost = previewCameraHighQ ? 1.35 : 1.0; + // 视口背景在屏幕上通常会经历一次缩放(世界坐标 -> 视图像素)。 + // 为了减少“发糊”,静止时做适度超采样:用更高分辨率解码,再由绘制阶段缩小。 + // 拖拽交互时降低超采样,优先帧率。 + const qreal superSample = m_draggingEntity ? 1.15 : (previewCameraHighQ ? 1.75 : 1.55); + const qreal densityTol = previewCameraHighQ ? 0.045 : 0.09; + + // 缓存仍覆盖视口且清晰度档位未变:直接复用(避免 invalidateViewportLod 仅置 dirty 又触发二次解码失败把画面清空) + // m_vpPreferRegionDecode 时不短路:必须在缩略图显示后立刻做一次区域细化 + if (!m_vpPreferRegionDecode && !m_bgViewportImage.isNull() && m_bgViewportCacheRect.contains(visI) && + m_vpCachedDensity > 1e-9 && + std::abs(density - m_vpCachedDensity) / m_vpCachedDensity < densityTol) { + m_bgViewportDirty = false; + return; + } + + if (m_vpDecodeInFlight.exchange(true)) { + return; + } + + const QString path = m_bgAbsPath; + const QSize logicalSize = m_bgLogicalSize; + const uint32_t myToken = m_vpToken.load(std::memory_order_relaxed); + + const qint64 imgArea = qint64(logicalSize.width()) * qint64(logicalSize.height()); + const qint64 visArea = qint64(visI.width()) * qint64(visI.height()); + // 预览且镜头锁定时禁用全图缩略路径,避免画面明显发糊 + const bool overviewMode = + !m_vpPreferRegionDecode && !previewCameraHighQ && imgArea > 0 && + (static_cast(visArea) / static_cast(imgArea) >= 0.68) && + core::VipsBackend::isAvailable(); + + // 扩大解码缓存:平移时尽量仍在同一张纹理内,避免每帧触发新的后台解码导致卡顿 + const qreal screenWorldW = (static_cast(width()) * pr) / std::max(m_scale, static_cast(1e-9)); + const qreal screenWorldH = (static_cast(height()) * pr) / std::max(m_scale, static_cast(1e-9)); + const qreal padX = std::max(vis.width() * 0.95, screenWorldW * 0.75); + const qreal padY = std::max(vis.height() * 0.95, screenWorldH * 0.75); + QRect req = + vis.adjusted(-padX, -padY, padX, padY).toAlignedRect().intersected(bgRect.toAlignedRect()); + if (req.width() <= 0 || req.height() <= 0) { + m_vpDecodeInFlight = false; + if (m_bgViewportImage.isNull()) { + m_bgViewportCacheRect = QRect(); + } + m_bgViewportDirty = false; + return; + } + + // 关键:解码目标分辨率必须以“req 区域在当前缩放下投影到屏幕的像素数”为基准。 + // 否则后台解码完成后替换纹理时,画面会因为额外的 drawImage 缩放而突然变糊。 + const int maxW = std::clamp(static_cast(std::ceil(req.width() * density * superSample * decodeBoost)), 256, 8192); + const int maxH = std::clamp(static_cast(std::ceil(req.height() * density * superSample * decodeBoost)), 256, 8192); + + const qint64 budget = + qint64(maxW) * qint64(maxH) * (overviewMode ? 2 : 1); // 缩略图阶段提高像素预算,减少首帧模糊感 + + (void)QtConcurrent::run([this, path, logicalSize, req, maxW, maxH, budget, density, overviewMode, + myToken]() { + QImage img; + bool ok = false; + if (overviewMode) { + ok = core::VipsBackend::loadThumbnailQImage(path, budget, &img) && !img.isNull(); + } + if (!ok) { + ok = core::image_file::loadRegionToQImage(path, req, maxW, maxH, &img) && !img.isNull(); + } + + QPointer self(this); + QTimer::singleShot( + 0, + this, + [self, myToken, path, img = std::move(img), ok, logicalSize, req, density, overviewMode]() mutable { + if (!self) { + return; + } + self->m_vpDecodeInFlight = false; + if (myToken != self->m_vpToken.load(std::memory_order_relaxed)) { + self->update(); + return; + } + if (path != self->m_bgAbsPath) { + self->update(); + return; + } + if (!ok || img.isNull()) { + // 保留已成功显示过的纹理,避免后续一次失败把整屏打成「加载失败」 + if (self->m_bgViewportImage.isNull()) { + self->m_bgViewportCacheRect = QRect(); + } + self->m_vpPreferRegionDecode = false; + self->m_bgViewportDirty = false; + self->update(); + self->tryEmitInitialBackgroundLoadFinished(); + return; + } + if (img.format() != QImage::Format_ARGB32_Premultiplied) { + img = img.convertToFormat(QImage::Format_ARGB32_Premultiplied); + } + self->m_bgViewportImage = std::move(img); + self->m_bgViewportCacheRect = + overviewMode ? QRect(QPoint(0, 0), logicalSize) : req; + self->m_bgViewportDirty = false; + self->m_vpCachedDensity = density; + if (overviewMode) { + self->m_vpPreferRegionDecode = true; + } else { + self->m_vpPreferRegionDecode = false; + } + self->update(); + self->tryEmitInitialBackgroundLoadFinished(); + }); + }); +} + +QRectF EditorCanvas::visibleWorldRectF() const { + const QPointF c0 = viewToWorld(QPointF(0, 0)); + const QPointF c1 = viewToWorld(QPointF(width(), 0)); + const QPointF c2 = viewToWorld(QPointF(width(), height())); + const QPointF c3 = viewToWorld(QPointF(0, height())); + const qreal minx = std::min({c0.x(), c1.x(), c2.x(), c3.x()}); + const qreal maxx = std::max({c0.x(), c1.x(), c2.x(), c3.x()}); + const qreal miny = std::min({c0.y(), c1.y(), c2.y(), c3.y()}); + const qreal maxy = std::max({c0.y(), c1.y(), c2.y(), c3.y()}); + QRectF rf(minx, miny, maxx - minx, maxy - miny); + return rf.normalized(); +} + +int EditorCanvas::backgroundLogicalWidth() const { + ensurePixmapLoaded(); + if (m_bgLogicalSize.isValid()) { + return m_bgLogicalSize.width(); + } + return m_bgPixmap.isNull() ? 0 : m_bgPixmap.width(); +} + +int EditorCanvas::backgroundLogicalHeight() const { + ensurePixmapLoaded(); + if (m_bgLogicalSize.isValid()) { + return m_bgLogicalSize.height(); + } + return m_bgPixmap.isNull() ? 0 : m_bgPixmap.height(); +} + void EditorCanvas::updateCursor() { if (m_blackholeCopyResolveActive) { setCursor(m_blackholeCopyDragging ? Qt::ClosedHandCursor : Qt::OpenHandCursor); return; } if (m_presentationPreviewMode) { + if (!m_presentationHotspotPlaybackTargetEnabled) { + setCursor(Qt::ArrowCursor); + return; + } if (m_presHoverEntityIndex >= 0) { setCursor(Qt::PointingHandCursor); } else { @@ -1318,6 +1714,12 @@ void EditorCanvas::updateCursor() { case Tool::CreateEntity: setCursor(Qt::CrossCursor); break; + case Tool::AddHotspot: + setCursor(Qt::CrossCursor); + break; + case Tool::MoveHotspot: + setCursor(Qt::OpenHandCursor); + break; } } @@ -1334,6 +1736,9 @@ QPointF EditorCanvas::worldToView(const QPointF& w) const { QRectF EditorCanvas::worldRectOfBackground() const { ensurePixmapLoaded(); + if (m_bgLogicalSize.isValid()) { + return QRectF(0, 0, m_bgLogicalSize.width(), m_bgLogicalSize.height()); + } if (m_bgPixmap.isNull()) { return {}; } @@ -1364,6 +1769,32 @@ int EditorCanvas::hitTestEntity(const QPointF& worldPos) const { return -1; } +int EditorCanvas::hitTestPresentationHotspot(const QPointF& worldPos) const { + const QPointF pv = worldToView(worldPos); + for (int i = static_cast(m_presentationHotspots.size()) - 1; i >= 0; --i) { + const QPointF cv = worldToView(m_presentationHotspots[i].centerWorld); + if (QLineF(pv, cv).length() <= 22.0) { + return i; + } + } + return -1; +} + +int EditorCanvas::hitTestHotspotPlayControl(const QPointF& worldPos) const { + if (!m_presentationHotspotPlaybackTargetEnabled) { + return -1; + } + const QPointF pv = worldToView(worldPos); + for (int i = static_cast(m_presentationHotspots.size()) - 1; i >= 0; --i) { + const QPointF cv = worldToView(m_presentationHotspots[i].centerWorld); + const QPointF playCenter = cv + QPointF(14.0, 0.0); + if (QLineF(pv, playCenter).length() <= 17.0) { + return i; + } + } + return -1; +} + void EditorCanvas::paintEvent(QPaintEvent* e) { Q_UNUSED(e); @@ -1384,21 +1815,20 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { ensurePixmapLoaded(); if (m_bgAbsPath.isEmpty()) { - p.setPen(palette().text().color()); - p.drawText(r.adjusted(12, 12, -12, -12), Qt::AlignCenter, - QStringLiteral("(暂无背景)\n右键项目树中的“背景”可更换/裁剪")); return; } - if (m_bgPixmap.isNull()) { - p.setPen(palette().text().color()); - p.drawText(r.adjusted(12, 12, -12, -12), Qt::AlignCenter, - QStringLiteral("(背景加载失败)\n%1").arg(m_bgAbsPath)); + if (m_useViewportLod) { + ensureBackgroundViewport(); + if (m_bgViewportImage.isNull()) { + return; + } + } else if (m_bgPixmap.isNull()) { + tryEmitInitialBackgroundLoadFinished(); return; } - // 预览:始终加载完整背景图,不做抠洞 const bool showBg = m_presentationPreviewMode || m_backgroundVisible; - if (showBg) { + if (showBg && !m_useViewportLod) { if (m_bgImageDirty) { m_bgImageDirty = false; m_bgImage = readImageTolerant(m_bgAbsPath); @@ -1407,7 +1837,7 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { } m_bgCutoutDirty = true; } - if (!m_presentationPreviewMode && m_bgCutoutDirty) { + if (m_bgCutoutDirty) { m_bgCutoutDirty = false; m_bgImageCutout = m_bgImage; for (const auto& ent : m_entities) { @@ -1425,24 +1855,57 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { t.scale(m_scale, m_scale); p.setTransform(t, true); - // 背景(预览:完整图;编辑:可抠洞 / 可隐藏看深度) + const qreal bw = static_cast(backgroundLogicalWidth()); + const qreal bh = static_cast(backgroundLogicalHeight()); + if (showBg) { - if (m_presentationPreviewMode) { - if (!m_bgImage.isNull()) { + if (m_useViewportLod) { + p.drawImage(QRectF(m_bgViewportCacheRect), m_bgViewportImage); + if (showBg) { + for (const auto& ent : m_entities) { + if (ent.blackholeVisible && !ent.cutoutPolygonWorld.isEmpty()) { + p.fillPath(entity_cutout::pathFromWorldPolygon(ent.cutoutPolygonWorld), Qt::black); + } + } + } + } else if (m_backgroundVisible || m_presentationPreviewMode) { + if (!m_bgImageCutout.isNull()) { + p.drawImage(QPointF(0, 0), m_bgImageCutout); + } else if (!m_bgImage.isNull()) { p.drawImage(QPointF(0, 0), m_bgImage); } else { p.drawPixmap(QPointF(0, 0), m_bgPixmap); } - } else if (m_backgroundVisible) { - if (!m_bgImageCutout.isNull()) { - p.drawImage(QPointF(0, 0), m_bgImageCutout); - } else { - p.drawPixmap(QPointF(0, 0), m_bgPixmap); - } } - if (!m_presentationPreviewMode && m_backgroundVisible) { + if (!m_presentationPreviewMode && m_backgroundVisible && bw > 0 && bh > 0) { p.setPen(QPen(QColor(0, 0, 0, 80), 1.0 / std::max(m_scale, 0.001))); - p.drawRect(QRectF(0, 0, m_bgPixmap.width(), m_bgPixmap.height()).adjusted(0, 0, -1, -1)); + p.drawRect(QRectF(0, 0, bw, bh).adjusted(0, 0, -1, -1)); + } + + if (!m_projectDirAbs.isEmpty()) { + for (const auto& ent : m_entities) { + if (ent.blackholeOverlayPath.isEmpty() || ent.blackholeOverlayRect.width() <= 0 || + ent.blackholeOverlayRect.height() <= 0) { + continue; + } + QImage ovl; + if (m_blackholeOverlayImageCache.contains(ent.id)) { + ovl = m_blackholeOverlayImageCache.value(ent.id); + } else { + const QString abs = QDir(m_projectDirAbs).filePath(ent.blackholeOverlayPath); + ovl = readImageTolerant(abs); + if (!ovl.isNull() && ovl.format() != QImage::Format_ARGB32_Premultiplied) { + ovl = ovl.convertToFormat(QImage::Format_ARGB32_Premultiplied); + } + if (!ovl.isNull()) { + m_blackholeOverlayImageCache.insert(ent.id, ovl); + } + } + if (ovl.isNull()) { + continue; + } + p.drawImage(QPointF(ent.blackholeOverlayRect.topLeft()), ovl); + } } } @@ -1471,26 +1934,31 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { if (!m_presentationPreviewMode && m_blackholeCopyResolveActive && !m_blackholeCopyHoleRect.isNull() && !m_blackholeCopySourceRect.isNull()) { - if (!m_bgImage.isNull()) { - const QRect srcRect = m_blackholeCopySourceRect.toAlignedRect(); - const QRect dstRect = m_blackholeCopyHoleRect.toAlignedRect(); - if (srcRect.isValid() && dstRect.isValid()) { - QPainterPath holePath; - for (const auto& ent : m_entities) { - if (ent.id == m_blackholeCopyEntityId && !ent.cutoutPolygonWorld.isEmpty()) { - holePath = entity_cutout::pathFromWorldPolygon(ent.cutoutPolygonWorld); - break; - } + const QRect srcRect = m_blackholeCopySourceRect.toAlignedRect(); + const QRect dstRect = m_blackholeCopyHoleRect.toAlignedRect(); + const bool havePreview = + (m_bhCopyPatchValid && !m_bhCopyPatch.isNull()) || !m_bgImage.isNull(); + if (havePreview && srcRect.isValid() && dstRect.isValid()) { + QPainterPath holePath; + for (const auto& ent : m_entities) { + if (ent.id == m_blackholeCopyEntityId && !ent.cutoutPolygonWorld.isEmpty()) { + holePath = entity_cutout::pathFromWorldPolygon(ent.cutoutPolygonWorld); + break; } - p.save(); - if (!holePath.isEmpty()) { - p.setClipPath(holePath); - } - p.setOpacity(0.75); - p.drawImage(dstRect.topLeft(), m_bgImage, srcRect); - p.setOpacity(1.0); - p.restore(); } + p.save(); + if (!holePath.isEmpty()) { + p.setClipPath(holePath); + } + p.setOpacity(0.75); + if (m_bhCopyPatchValid && !m_bhCopyPatch.isNull()) { + const QRect srcLocal = srcRect.translated(-m_bhCopyPatchOrigin); + p.drawImage(dstRect.topLeft(), m_bhCopyPatch, srcLocal); + } else { + p.drawImage(dstRect.topLeft(), m_bgImage, srcRect); + } + p.setOpacity(1.0); + p.restore(); } p.setBrush(Qt::NoBrush); @@ -1503,202 +1971,216 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { p.setPen(srcPen); p.drawRect(m_blackholeCopySourceRect); - const QPen textPen(QColor(70, 200, 255, 230)); - p.setPen(textPen); - const QPointF textPos = m_blackholeCopySourceRect.topLeft() + - QPointF(4.0 / std::max(m_scale, 0.001), - 14.0 / std::max(m_scale, 0.001)); - p.drawText(textPos, QStringLiteral("拖动取样框,松手应用")); } - // 实体元素(占位):后续可替换为真实数据 + struct DrawItem { + enum class Kind { Entity, Tool }; + Kind kind = Kind::Entity; + int index = -1; + int priority = 0; + int depth = 0; + }; + + QVector drawOrder; + drawOrder.reserve(m_entities.size() + m_tools.size()); for (int i = 0; i < m_entities.size(); ++i) { const auto& ent = m_entities[i]; - if (ent.opacity <= 0.001) { - continue; - } - if (!ent.id.isEmpty() && m_tempHiddenEntityIds.contains(ent.id)) { - continue; - } - const bool isDragPreview = (!m_presentationPreviewMode && m_draggingEntity && m_dragPreviewActive && i == m_selectedEntity); - if (!ent.polygonWorld.isEmpty()) { - // 优先绘制抠出来的实体图像(独立存在) - if (!ent.image.isNull()) { - if (isDragPreview) { - // 预览:先平移,再围绕“基准质心”缩放(避免把 delta 叠加两次导致错位) - const QPointF cBase = m_dragCentroidBase; - p.save(); - QTransform tr; - tr.translate(m_dragDelta.x(), m_dragDelta.y()); - tr.translate(cBase.x(), cBase.y()); - tr.scale(m_dragScaleRatio, m_dragScaleRatio); - tr.translate(-cBase.x(), -cBase.y()); - p.setTransform(tr, true); - const QSizeF sz(ent.image.width() * m_dragScaleBase, ent.image.height() * m_dragScaleBase); - const QRectF target(m_dragImageTopLeftBase, sz); - p.drawImage(target, ent.image); - p.restore(); - } else { - const qreal pop = - (m_presentationPreviewMode && i == m_presFocusedEntityIndex) ? 1.1 : 1.0; - const QSizeF sz0(ent.image.width() * ent.visualScale, ent.image.height() * ent.visualScale); - QRectF target; - if (pop > 1.001) { - const QRectF orig(ent.imageTopLeft, sz0); - const QPointF cen = orig.center(); - const QSizeF sz = orig.size() * pop; - target = QRectF(QPointF(cen.x() - sz.width() * 0.5, cen.y() - sz.height() * 0.5), sz); + if (ent.opacity <= 0.001) continue; + if (!ent.id.isEmpty() && m_tempHiddenEntityIds.contains(ent.id)) continue; + drawOrder.push_back(DrawItem{DrawItem::Kind::Entity, i, ent.priority, ent.depth}); + } + for (int i = 0; i < m_tools.size(); ++i) { + const auto& tv = m_tools[i]; + if (tv.opacity <= 0.001) continue; + if (!tv.tool.id.isEmpty() && m_tempHiddenToolIds.contains(tv.tool.id)) continue; + if (tv.tool.type != core::Project::Tool::Type::Bubble) continue; + drawOrder.push_back(DrawItem{DrawItem::Kind::Tool, i, tv.tool.priority, tv.depthZ}); + } + // priority 低先画、高后画;同 priority:远先画、近后画 + std::stable_sort(drawOrder.begin(), drawOrder.end(), [](const DrawItem& a, const DrawItem& b) { + if (a.priority != b.priority) return a.priority < b.priority; + if (a.depth != b.depth) return a.depth < b.depth; + if (a.kind != b.kind) return int(a.kind) < int(b.kind); + return a.index < b.index; + }); + + for (const auto& it : drawOrder) { + if (it.kind == DrawItem::Kind::Entity) { + const int i = it.index; + const auto& ent = m_entities[i]; + const bool isDragPreview = + (!m_presentationPreviewMode && m_draggingEntity && m_dragPreviewActive && i == m_selectedEntity); + if (!ent.polygonWorld.isEmpty()) { + if (!ent.image.isNull()) { + if (isDragPreview) { + const QPointF cBase = m_dragCentroidBase; + p.save(); + QTransform tr; + tr.translate(m_dragDelta.x(), m_dragDelta.y()); + tr.translate(cBase.x(), cBase.y()); + tr.scale(m_dragScaleRatio, m_dragScaleRatio); + tr.translate(-cBase.x(), -cBase.y()); + p.setTransform(tr, true); + const QSizeF sz(ent.image.width() * m_dragScaleBase, ent.image.height() * m_dragScaleBase); + const QRectF target(m_dragImageTopLeftBase, sz); + p.drawImage(target, ent.image); + p.restore(); } else { - target = QRectF(ent.imageTopLeft, sz0); + const qreal pop = + (m_presentationPreviewMode && i == m_presFocusedEntityIndex) ? 1.1 : 1.0; + const QSizeF sz0(ent.image.width() * ent.visualScale, ent.image.height() * ent.visualScale); + QRectF target; + if (pop > 1.001) { + const QRectF orig(ent.imageTopLeft, sz0); + const QPointF cen = orig.center(); + const QSizeF sz = orig.size() * pop; + target = QRectF(QPointF(cen.x() - sz.width() * 0.5, cen.y() - sz.height() * 0.5), sz); + } else { + target = QRectF(ent.imageTopLeft, sz0); + } + QImage drawImg = ent.image; + if (m_presentationPreviewMode && !ent.image.isNull()) { + const qreal pr = devicePixelRatio(); + const qreal pxW = std::abs(target.width() * m_scale * pr); + const qreal pxH = std::abs(target.height() * m_scale * pr); + drawImg = entityImageForPresentationDrawLoRes(ent.image, pxW, pxH); + } + p.drawImage(target, drawImg); + } + } else { + const QPolygonF poly(isDragPreview ? QPolygonF(m_dragPolyBase) : QPolygonF(ent.polygonWorld)); + p.setPen(Qt::NoPen); + p.setBrush(ent.color); + if (isDragPreview) { + const QPointF cBase = m_dragCentroidBase; + QTransform tr; + tr.translate(m_dragDelta.x(), m_dragDelta.y()); + tr.translate(cBase.x(), cBase.y()); + tr.scale(m_dragScaleRatio, m_dragScaleRatio); + tr.translate(-cBase.x(), -cBase.y()); + p.save(); + p.setTransform(tr, true); + p.drawPolygon(poly); + p.restore(); + } else { + p.drawPolygon(poly); + } + } + if (!m_presentationPreviewMode) { + p.setBrush(Qt::NoBrush); + p.setPen(QPen(QColor(0, 0, 0, 160), 1.0 / std::max(m_scale, 0.001))); + if (isDragPreview) { + const QPointF cBase = m_dragCentroidBase; + QTransform tr; + tr.translate(m_dragDelta.x(), m_dragDelta.y()); + tr.translate(cBase.x(), cBase.y()); + tr.scale(m_dragScaleRatio, m_dragScaleRatio); + tr.translate(-cBase.x(), -cBase.y()); + p.save(); + p.setTransform(tr, true); + p.drawPath(m_dragPathBase); + p.restore(); + } else { + p.drawPath(ent.pathWorld); } - p.drawImage(target, ent.image); } } else { - const QPolygonF poly(isDragPreview ? QPolygonF(m_dragPolyBase) : QPolygonF(ent.polygonWorld)); - p.setPen(Qt::NoPen); - p.setBrush(ent.color); - if (isDragPreview) { - const QPointF cBase = m_dragCentroidBase; - QTransform tr; - tr.translate(m_dragDelta.x(), m_dragDelta.y()); - tr.translate(cBase.x(), cBase.y()); - tr.scale(m_dragScaleRatio, m_dragScaleRatio); - tr.translate(-cBase.x(), -cBase.y()); - p.save(); - p.setTransform(tr, true); - p.drawPolygon(poly); - p.restore(); - } else { - p.drawPolygon(poly); + p.fillRect(ent.rect, ent.color); + if (!m_presentationPreviewMode) { + p.setPen(QPen(QColor(0, 0, 0, 120), 1.0 / std::max(m_scale, 0.001))); + p.drawRect(ent.rect); } } - if (!m_presentationPreviewMode) { - p.setBrush(Qt::NoBrush); - p.setPen(QPen(QColor(0, 0, 0, 160), 1.0 / std::max(m_scale, 0.001))); - if (isDragPreview) { - const QPointF cBase = m_dragCentroidBase; - QTransform tr; - tr.translate(m_dragDelta.x(), m_dragDelta.y()); - tr.translate(cBase.x(), cBase.y()); - tr.scale(m_dragScaleRatio, m_dragScaleRatio); - tr.translate(-cBase.x(), -cBase.y()); - p.save(); - p.setTransform(tr, true); - p.drawPath(m_dragPathBase); - p.restore(); + if (!m_presentationPreviewMode && i == m_selectedEntity) { + p.setPen(QPen(QColor(255, 120, 0, 220), 2.0 / std::max(m_scale, 0.001))); + if (!ent.polygonWorld.isEmpty()) { + if (isDragPreview) { + const QPointF cBase = m_dragCentroidBase; + QTransform tr; + tr.translate(m_dragDelta.x(), m_dragDelta.y()); + tr.translate(cBase.x(), cBase.y()); + tr.scale(m_dragScaleRatio, m_dragScaleRatio); + tr.translate(-cBase.x(), -cBase.y()); + p.save(); + p.setTransform(tr, true); + p.drawPath(m_dragPathBase); + p.restore(); + } else { + p.drawPath(ent.pathWorld); + } } else { - p.drawPath(ent.pathWorld); + p.drawRect(ent.rect.adjusted(-2, -2, 2, 2)); + } + } + if (!m_presentationPreviewMode && ent.id == m_selectedBlackholeEntityId && !ent.cutoutPolygonWorld.isEmpty()) { + p.setBrush(Qt::NoBrush); + QPen holePen(QColor(70, 200, 255, 230), 2.2 / std::max(m_scale, 0.001)); + holePen.setStyle(Qt::DashLine); + p.setPen(holePen); + p.drawPath(entity_cutout::pathFromWorldPolygon(ent.cutoutPolygonWorld)); + } + if (m_presentationPreviewMode && ent.opacity > 0.001) { + const bool showHover = (i == m_presHoverEntityIndex); + const bool showFocus = (i == m_presFocusedEntityIndex); + if (showHover || showFocus) { + p.setBrush(Qt::NoBrush); + if (showHover) { + const qreal pulse = 0.45 + 0.55 * std::sin(static_cast(m_presHoverPhase)); + const qreal lw = + (2.0 + 2.8 * pulse) / std::max(static_cast(m_scale), static_cast(0.001)); + p.setPen(QPen(QColor(255, 210, 80, static_cast(65 + 110 * pulse)), lw)); + if (!ent.pathWorld.isEmpty()) { + p.drawPath(ent.pathWorld); + } else { + p.drawRect(ent.rect); + } + } + if (showFocus) { + const qreal lw = 2.8 / std::max(static_cast(m_scale), static_cast(0.001)); + p.setPen(QPen(QColor(255, 120, 40, 230), lw)); + if (!ent.pathWorld.isEmpty()) { + p.drawPath(ent.pathWorld); + } else { + p.drawRect(ent.rect); + } + } } } } else { - p.fillRect(ent.rect, ent.color); - if (!m_presentationPreviewMode) { - p.setPen(QPen(QColor(0, 0, 0, 120), 1.0 / std::max(m_scale, 0.001))); - p.drawRect(ent.rect); - } - } - if (!m_presentationPreviewMode && i == m_selectedEntity) { - p.setPen(QPen(QColor(255, 120, 0, 220), 2.0 / std::max(m_scale, 0.001))); - if (!ent.polygonWorld.isEmpty()) { - if (isDragPreview) { - const QPointF cBase = m_dragCentroidBase; - QTransform tr; - tr.translate(m_dragDelta.x(), m_dragDelta.y()); - tr.translate(cBase.x(), cBase.y()); - tr.scale(m_dragScaleRatio, m_dragScaleRatio); - tr.translate(-cBase.x(), -cBase.y()); - p.save(); - p.setTransform(tr, true); - p.drawPath(m_dragPathBase); - p.restore(); - } else { - p.drawPath(ent.pathWorld); - } - } else { - p.drawRect(ent.rect.adjusted(-2, -2, 2, 2)); - } - } - if (!m_presentationPreviewMode && ent.id == m_selectedBlackholeEntityId && !ent.cutoutPolygonWorld.isEmpty()) { - p.setBrush(Qt::NoBrush); - QPen holePen(QColor(70, 200, 255, 230), 2.2 / std::max(m_scale, 0.001)); - holePen.setStyle(Qt::DashLine); - p.setPen(holePen); - p.drawPath(entity_cutout::pathFromWorldPolygon(ent.cutoutPolygonWorld)); - } - if (m_presentationPreviewMode && ent.opacity > 0.001) { - const bool showHover = (i == m_presHoverEntityIndex); - const bool showFocus = (i == m_presFocusedEntityIndex); - if (showHover || showFocus) { - p.setBrush(Qt::NoBrush); - if (showHover) { - const qreal pulse = 0.45 + 0.55 * std::sin(static_cast(m_presHoverPhase)); - const qreal lw = - (2.0 + 2.8 * pulse) / std::max(static_cast(m_scale), static_cast(0.001)); - p.setPen(QPen(QColor(255, 210, 80, static_cast(65 + 110 * pulse)), lw)); - if (!ent.pathWorld.isEmpty()) { - p.drawPath(ent.pathWorld); - } else { - p.drawRect(ent.rect); - } - } - if (showFocus) { - const qreal lw = 2.8 / std::max(static_cast(m_scale), static_cast(0.001)); - p.setPen(QPen(QColor(255, 120, 40, 230), lw)); - if (!ent.pathWorld.isEmpty()) { - p.drawPath(ent.pathWorld); - } else { - p.drawRect(ent.rect); - } - } - } - } - } + const int i = it.index; + const auto& tv = m_tools[i]; + const auto& tool = tv.tool; + const double opacity = std::clamp(tv.opacity, 0.0, 1.0); - // 工具:对话气泡(world 坐标),按 opacity 淡入淡出 - for (int i = 0; i < m_tools.size(); ++i) { - const auto& tv = m_tools[i]; - const auto& tool = tv.tool; - const double opacity = std::clamp(tv.opacity, 0.0, 1.0); - // tool.visible 仅表示“基础可见性”,动画可见性由 opacity(关键帧+淡入淡出)驱动 - if (opacity <= 0.001) { - continue; - } - if (!tool.id.isEmpty() && m_tempHiddenToolIds.contains(tool.id)) { - continue; - } - if (tool.type != core::Project::Tool::Type::Bubble) { - continue; - } - const BubbleLayoutWorld lay = bubbleLayoutWorld(tool); - const QPainterPath& path = lay.path; - const QRectF& body = lay.bodyRect; + const BubbleLayoutWorld lay = bubbleLayoutWorld(tool); + const QPainterPath& path = lay.path; + const QRectF& body = lay.bodyRect; - QColor fill(255, 255, 255, int(220 * opacity)); - QColor border(0, 0, 0, int(120 * opacity)); - p.setBrush(fill); - p.setPen(QPen(border, 1.2 / std::max(m_scale, 0.001))); - p.drawPath(path); - - // 文本 - if (!tool.text.trimmed().isEmpty()) { - p.setPen(QColor(10, 10, 10, int(230 * opacity))); - QFont f = p.font(); - f.setPixelSize(std::clamp(tool.fontPx, 8, 120)); - p.setFont(f); - QTextOption opt; - opt.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); - if (tool.align == core::Project::Tool::TextAlign::Left) opt.setAlignment(Qt::AlignLeft | Qt::AlignVCenter); - else if (tool.align == core::Project::Tool::TextAlign::Right) opt.setAlignment(Qt::AlignRight | Qt::AlignVCenter); - else opt.setAlignment(Qt::AlignHCenter | Qt::AlignVCenter); - const QRectF textRect = body.adjusted(10, 8, -10, -8); - p.drawText(textRect, tool.text, opt); - } - - // 选中描边 - if (!m_presentationPreviewMode && i == m_selectedTool) { - p.setBrush(Qt::NoBrush); - p.setPen(QPen(QColor(80, 160, 255, 220), 2.0 / std::max(m_scale, 0.001))); + QColor fill(255, 255, 255, int(220 * opacity)); + QColor border(0, 0, 0, int(120 * opacity)); + p.setBrush(fill); + p.setPen(QPen(border, 1.2 / std::max(m_scale, 0.001))); p.drawPath(path); + + if (!tool.text.trimmed().isEmpty()) { + p.setPen(QColor(10, 10, 10, int(230 * opacity))); + QFont f = p.font(); + f.setPixelSize(std::clamp(tool.fontPx, 8, 120)); + p.setFont(f); + QTextOption opt; + opt.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); + if (tool.align == core::Project::Tool::TextAlign::Left) opt.setAlignment(Qt::AlignLeft | Qt::AlignVCenter); + else if (tool.align == core::Project::Tool::TextAlign::Right) opt.setAlignment(Qt::AlignRight | Qt::AlignVCenter); + else opt.setAlignment(Qt::AlignHCenter | Qt::AlignVCenter); + const QRectF textRect = body.adjusted(10, 8, -10, -8); + p.drawText(textRect, tool.text, opt); + } + + if (!m_presentationPreviewMode && i == m_selectedTool) { + p.setBrush(Qt::NoBrush); + p.setPen(QPen(QColor(80, 160, 255, 220), 2.0 / std::max(m_scale, 0.001))); + p.drawPath(path); + } } } @@ -1756,7 +2238,8 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { // 坐标轴/刻度:绘制在画布最外层,背景越界时贴边显示 ensurePixmapLoaded(); - if (!m_presentationPreviewMode && m_worldAxesVisible && !m_bgPixmap.isNull()) { + if (!m_presentationPreviewMode && m_worldAxesVisible && backgroundLogicalWidth() > 0 && + backgroundLogicalHeight() > 0) { const QPointF originView = worldToView(QPointF(0, 0)); const qreal axisX = std::clamp(originView.x(), 0.0, static_cast(width())); const qreal axisY = std::clamp(originView.y(), 0.0, static_cast(height())); @@ -1794,8 +2277,8 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { const QPointF w1 = viewToWorld(QPointF(width(), height())); double a = std::min(w0.x(), w1.x()); double b = std::max(w0.x(), w1.x()); - a = std::clamp(a, 0.0, double(m_bgPixmap.width())); - b = std::clamp(b, 0.0, double(m_bgPixmap.width())); + a = std::clamp(a, 0.0, double(backgroundLogicalWidth())); + b = std::clamp(b, 0.0, double(backgroundLogicalWidth())); return {a, b}; }; auto visibleWorldYRange = [&]() -> std::pair { @@ -1803,8 +2286,8 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { const QPointF w1 = viewToWorld(QPointF(width(), height())); double a = std::min(w0.y(), w1.y()); double b = std::max(w0.y(), w1.y()); - a = std::clamp(a, 0.0, double(m_bgPixmap.height())); - b = std::clamp(b, 0.0, double(m_bgPixmap.height())); + a = std::clamp(a, 0.0, double(backgroundLogicalHeight())); + b = std::clamp(b, 0.0, double(backgroundLogicalHeight())); return {a, b}; }; @@ -1823,7 +2306,7 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { const double start = std::floor(xmin / stepWorld) * stepWorld; int iTick = 0; for (double x = start; x <= xmax + 1e-9; x += stepWorld, ++iTick) { - const double xc = std::clamp(x, 0.0, double(m_bgPixmap.width())); + const double xc = std::clamp(x, 0.0, double(backgroundLogicalWidth())); const QPointF vx = worldToView(QPointF(xc, 0)); if (vx.x() < -50 || vx.x() > width() + 50) { continue; @@ -1844,7 +2327,7 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { const double start = std::floor(ymin / stepWorld) * stepWorld; int iTick = 0; for (double y = start; y <= ymax + 1e-9; y += stepWorld, ++iTick) { - const double yc = std::clamp(y, 0.0, double(m_bgPixmap.height())); + const double yc = std::clamp(y, 0.0, double(backgroundLogicalHeight())); const QPointF vy = worldToView(QPointF(0, yc)); if (vy.y() < -50 || vy.y() > height() + 50) { continue; @@ -1872,13 +2355,44 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { } const QPointF originView = worldToView(originWorld); + // 中心(形心)十字锚点:独立于坐标轴手柄,始终可见 + { + const qreal h = 7.0; + QPen outline(QColor(15, 15, 18, 235)); + outline.setWidth(3); + outline.setCapStyle(Qt::FlatCap); + QPen core(QColor(255, 248, 220, 252)); + core.setWidth(1); + core.setCapStyle(Qt::FlatCap); + p.setPen(outline); + p.drawLine(QPointF(originView.x() - h, originView.y()), QPointF(originView.x() + h, originView.y())); + p.drawLine(QPointF(originView.x(), originView.y() - h), QPointF(originView.x(), originView.y() + h)); + p.setPen(core); + p.drawLine(QPointF(originView.x() - h, originView.y()), QPointF(originView.x() + h, originView.y())); + p.drawLine(QPointF(originView.x(), originView.y() - h), QPointF(originView.x(), originView.y() + h)); + } + + // 变换原点(枢轴):与形心不同时显示 + { + const QPointF pivotWorld = ent.animatedOriginWorld; + const double sepWorld = QLineF(originWorld, pivotWorld).length(); + if (sepWorld > 0.5) { + const QPointF pv = worldToView(pivotWorld); + const qreal pivotDotR = 4.5; + p.setPen(QPen(QColor(40, 40, 48, 220), 1)); + p.setBrush(QColor(255, 120, 210, 235)); + p.drawEllipse(pv, pivotDotR, pivotDotR); + p.setBrush(Qt::NoBrush); + } + } + const qreal len = 56.0; QPen xPen(QColor(220, 60, 60, 220)); xPen.setWidth(2); QPen yPen(QColor(60, 180, 90, 220)); yPen.setWidth(2); - // X 轴 + // X 轴(从形心出发,用于拖拽平移) p.setPen(xPen); p.drawLine(originView, QPointF(originView.x() + len, originView.y())); p.drawRect(QRectF(QPointF(originView.x() + len - 4, originView.y() - 4), QSizeF(8, 8))); @@ -1894,11 +2408,40 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { p.drawText(QPointF(originView.x() + 6, originView.y() + len + 14), QStringLiteral("Y")); } } + + if (!m_presentationHotspots.isEmpty()) { + if (m_presentationPreviewMode && m_presentationHotspotPlaybackTargetEnabled) { + p.setRenderHint(QPainter::Antialiasing, true); + for (const auto& h : m_presentationHotspots) { + const QPointF cv = worldToView(h.centerWorld); + p.setPen(QPen(QColor(255, 248, 220, 235), 2)); + p.setBrush(QColor(30, 34, 42, 170)); + p.drawEllipse(cv, 16.0, 16.0); + QPolygonF tri; + tri << (cv + QPointF(-3.0, -6.0)) << (cv + QPointF(-3.0, 6.0)) << (cv + QPointF(8.0, 0.0)); + p.setBrush(QColor(255, 248, 220, 250)); + p.drawPolygon(tri); + } + p.setRenderHint(QPainter::Antialiasing, false); + } else if (m_presentationHotspotEditorActive) { + p.setRenderHint(QPainter::Antialiasing, true); + for (const auto& h : m_presentationHotspots) { + const QPointF cv = worldToView(h.centerWorld); + const bool sel = (h.id == m_selectedPresentationHotspotId); + p.setPen(QPen(sel ? QColor(255, 210, 72) : QColor(92, 200, 255), 2)); + p.setBrush(QColor(24, 28, 36, 150)); + p.drawEllipse(cv, 14.0, 14.0); + } + p.setRenderHint(QPainter::Antialiasing, false); + } + } + + tryEmitInitialBackgroundLoadFinished(); } void EditorCanvas::resizeEvent(QResizeEvent* e) { QWidget::resizeEvent(e); - // 仅触发重绘;pixmap 只缓存原图 + invalidateViewportLod(); update(); } @@ -1927,6 +2470,39 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) { } emit hoveredWorldPosDepthChanged(wp0, z0); + if (!m_presentationPreviewMode && m_presentationHotspotEditorActive && e->button() == Qt::LeftButton) { + if (m_tool == Tool::AddHotspot) { + emit requestAddPresentationHotspot(wp0); + e->accept(); + return; + } + if (m_tool == Tool::MoveHotspot) { + const int hi = hitTestPresentationHotspot(wp0); + if (hi >= 0) { + m_dragging = true; + m_draggingHotspotIndex = hi; + m_hotspotDragOffsetWorld = wp0 - m_presentationHotspots[hi].centerWorld; + m_selectedPresentationHotspotId = m_presentationHotspots[hi].id; + m_lastMouseView = e->position(); + setCursor(Qt::ClosedHandCursor); + emit selectedPresentationHotspotChanged(m_selectedPresentationHotspotId); + } else { + m_selectedPresentationHotspotId.clear(); + emit selectedPresentationHotspotChanged(QString()); + } + update(); + e->accept(); + return; + } + if (m_tool == Tool::Move) { + m_dragging = true; + m_lastMouseView = e->position(); + setCursor(Qt::ClosedHandCursor); + e->accept(); + return; + } + } + if (m_blackholeCopyResolveActive) { if (e->button() == Qt::LeftButton) { QRectF src = m_blackholeCopySourceRect; @@ -1947,7 +2523,19 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) { } if (m_presentationPreviewMode) { + if (!m_presentationHotspotPlaybackTargetEnabled) { + e->accept(); + return; + } if (e->button() == Qt::LeftButton) { + if (m_presentationHotspotPlaybackTargetEnabled) { + const int playIx = hitTestHotspotPlayControl(wp0); + if (playIx >= 0 && playIx < m_presentationHotspots.size()) { + emit hotspotPlayRequested(m_presentationHotspots[playIx].id); + e->accept(); + return; + } + } const int hit = hitTestEntity(wp0); if (hit >= 0) { const auto& ent = m_entities[hit]; @@ -2061,17 +2649,43 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) { } } - // 工具(气泡)优先命中:绘制在实体之后,交互也应优先 - for (qsizetype i = m_tools.size(); i > 0; --i) { - const qsizetype idx = i - 1; - const auto& tv = m_tools[idx]; + // 命中测试:按绘制顺序从上到下(priority/深度),找到顶层的实体或工具 + struct HitItem { + enum class Kind { Entity, Tool }; + Kind kind = Kind::Entity; + int index = -1; + int priority = 0; + int depth = 0; + }; + QVector order; + order.reserve(m_entities.size() + m_tools.size()); + for (int i = 0; i < m_entities.size(); ++i) { + const auto& ent = m_entities[i]; + if (ent.opacity <= 0.001) continue; + if (!ent.id.isEmpty() && m_tempHiddenEntityIds.contains(ent.id)) continue; + order.push_back(HitItem{HitItem::Kind::Entity, i, ent.priority, ent.depth}); + } + for (int i = 0; i < m_tools.size(); ++i) { + const auto& tv = m_tools[i]; if (tv.opacity <= 0.001) continue; if (!tv.tool.id.isEmpty() && m_tempHiddenToolIds.contains(tv.tool.id)) continue; if (tv.tool.type != core::Project::Tool::Type::Bubble) continue; - const QPainterPath path = bubblePathWorld(tv.tool); - if (path.contains(worldPos)) { + order.push_back(HitItem{HitItem::Kind::Tool, i, tv.tool.priority, tv.depthZ}); + } + std::stable_sort(order.begin(), order.end(), [](const HitItem& a, const HitItem& b) { + if (a.priority != b.priority) return a.priority < b.priority; + if (a.depth != b.depth) return a.depth < b.depth; + if (a.kind != b.kind) return int(a.kind) < int(b.kind); + return a.index < b.index; + }); + for (qsizetype i = order.size(); i > 0; --i) { + const auto& it = order[i - 1]; + if (it.kind == HitItem::Kind::Tool) { + const auto& tv = m_tools[it.index]; + const QPainterPath path = bubblePathWorld(tv.tool); + if (!path.contains(worldPos)) continue; clearCameraSelection(); - m_selectedTool = static_cast(idx); + m_selectedTool = it.index; m_selectedEntity = -1; m_draggingTool = true; m_dragMode = DragMode::Free; @@ -2082,17 +2696,59 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) { update(); return; } + const auto& ent = m_entities[it.index]; + bool hit = false; + if (!ent.pathWorld.isEmpty()) hit = ent.pathWorld.contains(worldPos); + else if (!ent.polygonWorld.isEmpty()) hit = entity_cutout::pathFromWorldPolygon(ent.polygonWorld).contains(worldPos); + else hit = ent.rect.contains(worldPos); + if (!hit) continue; + // 选择实体(保持原逻辑) + clearCameraSelection(); + m_selectedEntity = it.index; + m_selectedTool = -1; + m_draggingTool = false; + m_draggingEntity = true; + m_dragMode = DragMode::Free; + emit entityDragActiveChanged(true); + const QRectF r = m_entities[m_selectedEntity].rect.isNull() && !m_entities[m_selectedEntity].polygonWorld.isEmpty() + ? entity_cutout::pathFromWorldPolygon(m_entities[m_selectedEntity].polygonWorld).boundingRect() + : m_entities[m_selectedEntity].rect; + m_entities[m_selectedEntity].rect = r; + { + const auto& he = m_entities[m_selectedEntity]; + const QPointF cRef = shapeCentroidWorld(he.polygonWorld, he.rect); + const double s = std::max(1e-6, he.visualScale); + m_entityDragAnchorLocal = (worldPos - cRef) / s; + m_entityDragStartCentroidWorld = cRef; + } + // drag preview baseline + m_dragPreviewActive = true; + m_dragDelta = QPointF(0, 0); + m_dragOriginBase = m_entities[m_selectedEntity].animatedOriginWorld; + m_dragRectBase = m_entities[m_selectedEntity].rect; + m_dragImageTopLeftBase = m_entities[m_selectedEntity].imageTopLeft; + m_dragScaleBase = std::max(1e-6, m_entities[m_selectedEntity].visualScale); + m_dragScaleRatio = 1.0; + m_dragPolyBase = m_entities[m_selectedEntity].polygonWorld; + m_dragPathBase = m_entities[m_selectedEntity].pathWorld; + m_dragCentroidBase = + m_dragPolyBase.isEmpty() ? m_dragRectBase.center() : entity_cutout::polygonCentroid(m_dragPolyBase); + const QPointF origin = !m_entities[m_selectedEntity].polygonWorld.isEmpty() + ? entity_cutout::polygonCentroid(m_entities[m_selectedEntity].polygonWorld) + : m_entities[m_selectedEntity].rect.center(); + emit selectedEntityChanged(true, m_entities[m_selectedEntity].id, m_entities[m_selectedEntity].depth, origin); + emit selectedToolChanged(false, QString(), QPointF()); + update(); + return; } // 优先:若已选中实体,且点在 gizmo 手柄上,则开启轴约束拖动 if (m_selectedEntity >= 0 && m_selectedEntity < m_entities.size()) { const auto& ent = m_entities[m_selectedEntity]; const bool isDragPreview = (m_draggingEntity && m_dragPreviewActive); - QPointF originWorld = ent.rect.center(); + QPointF originWorld = shapeCentroidWorld(ent.polygonWorld, ent.rect); if (isDragPreview) { originWorld = m_dragCentroidBase + m_dragDelta; - } else if (!ent.polygonWorld.isEmpty()) { - originWorld = entity_cutout::polygonCentroid(ent.polygonWorld); } const QPointF originView = worldToView(originWorld); const GizmoHit gh = hitTestGizmo(e->position(), originView); @@ -2109,8 +2765,13 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) { ? entity_cutout::pathFromWorldPolygon(m_entities[m_selectedEntity].polygonWorld).boundingRect() : m_entities[m_selectedEntity].rect; m_entities[m_selectedEntity].rect = r; - m_entityDragOffsetOriginWorld = viewToWorld(e->position()) - m_entities[m_selectedEntity].animatedOriginWorld; - m_entityDragStartAnimatedOrigin = m_entities[m_selectedEntity].animatedOriginWorld; + { + const auto& se = m_entities[m_selectedEntity]; + const QPointF cRef = shapeCentroidWorld(se.polygonWorld, se.rect); + const double s = std::max(1e-6, se.visualScale); + m_entityDragAnchorLocal = (viewToWorld(e->position()) - cRef) / s; + m_entityDragStartCentroidWorld = cRef; + } // drag preview baseline m_dragPreviewActive = true; m_dragDelta = QPointF(0, 0); @@ -2149,8 +2810,13 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) { ? entity_cutout::pathFromWorldPolygon(ent.polygonWorld).boundingRect() : ent.rect; m_entities[m_selectedEntity].rect = r; - m_entityDragOffsetOriginWorld = worldPos - m_entities[m_selectedEntity].animatedOriginWorld; - m_entityDragStartAnimatedOrigin = m_entities[m_selectedEntity].animatedOriginWorld; + { + const auto& se = m_entities[m_selectedEntity]; + const QPointF cRef = shapeCentroidWorld(se.polygonWorld, se.rect); + const double s = std::max(1e-6, se.visualScale); + m_entityDragAnchorLocal = (worldPos - cRef) / s; + m_entityDragStartCentroidWorld = cRef; + } // drag preview baseline m_dragPreviewActive = true; m_dragDelta = QPointF(0, 0); @@ -2181,8 +2847,13 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) { ? entity_cutout::pathFromWorldPolygon(m_entities[hit].polygonWorld).boundingRect() : m_entities[hit].rect; m_entities[hit].rect = r; - m_entityDragOffsetOriginWorld = worldPos - m_entities[hit].animatedOriginWorld; - m_entityDragStartAnimatedOrigin = m_entities[hit].animatedOriginWorld; + { + const auto& he = m_entities[hit]; + const QPointF cRef = shapeCentroidWorld(he.polygonWorld, he.rect); + const double s = std::max(1e-6, he.visualScale); + m_entityDragAnchorLocal = (worldPos - cRef) / s; + m_entityDragStartCentroidWorld = cRef; + } // drag preview baseline m_dragPreviewActive = true; m_dragDelta = QPointF(0, 0); @@ -2247,6 +2918,18 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) { } if (m_presentationPreviewMode) { + if (!m_presentationHotspotPlaybackTargetEnabled) { + if (m_presHoverEntityIndex >= 0 || (m_presHoverTimer && m_presHoverTimer->isActive()) || + m_presHoverPhase != 0.0) { + if (m_presHoverTimer) { + m_presHoverTimer->stop(); + } + m_presHoverEntityIndex = -1; + m_presHoverPhase = 0.0; + updateCursor(); + update(); + } + } else { const int h = hitTestEntity(wp); if (h != m_presHoverEntityIndex) { m_presHoverEntityIndex = h; @@ -2261,6 +2944,7 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) { m_presHoverTimer->stop(); m_presHoverPhase = 0.0; } + } } if (!m_dragging) { @@ -2272,6 +2956,13 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) { const QPointF deltaView = cur - m_lastMouseView; m_lastMouseView = cur; + if (m_draggingHotspotIndex >= 0 && m_draggingHotspotIndex < m_presentationHotspots.size()) { + const QPointF worldPos = viewToWorld(cur); + m_presentationHotspots[m_draggingHotspotIndex].centerWorld = worldPos - m_hotspotDragOffsetWorld; + update(); + return; + } + if (m_pendingDragging && !m_presentationPreviewMode && m_pendingPolyWorld.size() >= 3) { const QPointF curWorld = viewToWorld(e->position()); const QPointF delta = curWorld - m_pendingLastMouseWorld; @@ -2319,8 +3010,11 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) { if (m_draggingEntity && m_selectedEntity >= 0 && m_selectedEntity < m_entities.size()) { const QPointF worldPos = viewToWorld(cur); auto& ent = m_entities[m_selectedEntity]; - const QPointF newOrigin = worldPos - m_entityDragOffsetOriginWorld; - QPointF delta = newOrigin - ent.animatedOriginWorld; + const double curScale = std::max(1e-6, ent.visualScale); + const QPointF centroidNow = + m_dragPreviewActive ? (m_dragCentroidBase + m_dragDelta) : shapeCentroidWorld(ent.polygonWorld, ent.rect); + const QPointF newCentroid = worldPos - m_entityDragAnchorLocal * curScale; + QPointF delta = newCentroid - centroidNow; // 轴约束:只允许沿 X 或 Y 平移(world 坐标) if (m_dragMode == DragMode::AxisX) { @@ -2363,8 +3057,8 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) { m_depthImage8 = img.isNull() ? QImage() : img.convertToFormat(QImage::Format_Grayscale8); } if (!m_depthImage8.isNull()) { - // 深度采样点:使用“预览质心的平移后位置” - const QPointF c = (m_dragPreviewActive ? (m_dragCentroidBase + m_dragDelta) : ent.animatedOriginWorld); + const QPointF c = + m_dragPreviewActive ? (m_dragCentroidBase + m_dragDelta) : shapeCentroidWorld(ent.polygonWorld, ent.rect); const int depthZ = sampleDepthAtPoint(m_depthImage8, c); ent.depth = depthZ; @@ -2374,7 +3068,8 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) { (ent.ignoreDistanceScale ? 1.0 : distanceScaleFromDepth01(ds01, ent.distanceScaleCalibMult)) * ent.userScale; ent.visualScale = newScale; if (m_dragPreviewActive) { - m_dragScaleRatio = std::clamp(newScale / std::max(1e-6, m_dragScaleBase), 0.02, 50.0); + m_dragScaleRatio = + std::clamp(newScale / std::max(1e-6, m_dragScaleBase), 0.02, static_cast(kViewScaleMax)); } } } @@ -2382,7 +3077,10 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) { // 属性面板/状态预览:按 30Hz 刷新即可,拖动视觉仍满帧跟手 if (nowMs - m_lastPreviewEmitMs >= 33) { m_lastPreviewEmitMs = nowMs; - emit selectedEntityPreviewChanged(ent.id, ent.depth, ent.animatedOriginWorld); + const QPointF previewCenter = + m_dragPreviewActive ? (m_dragCentroidBase + m_dragDelta) + : shapeCentroidWorld(ent.polygonWorld, ent.rect); + emit selectedEntityPreviewChanged(ent.id, ent.depth, previewCenter); } update(); return; @@ -2445,18 +3143,70 @@ void EditorCanvas::mouseReleaseEvent(QMouseEvent* e) { } else if (m_entityCreateSegmentMode == EntityCreateSegmentMode::Snap) { if (m_strokeWorld.size() >= kMinStrokePointsManual) { ensurePixmapLoaded(); - const QVector snapped = snapStrokeToEdges(m_strokeWorld, m_bgImage, 6); - setPendingEntityPolygonWorld(snapped); + const QRectF polyBr = QPolygonF(m_strokeWorld).boundingRect(); + const QRect cropWorld = entity_cutout::clampRectToImage( + polyBr.adjusted(-kSamCropMargin, -kSamCropMargin, kSamCropMargin, kSamCropMargin) + .toAlignedRect(), + QSize(backgroundLogicalWidth(), backgroundLogicalHeight())); + QImage crop; + if (!cropWorld.isEmpty() && + core::image_file::loadRegionToQImage(m_bgAbsPath, cropWorld, 4096, 4096, &crop) && + !crop.isNull()) { + const QImage gray = crop.convertToFormat(QImage::Format_Grayscale8); + const QVector snapped = + snapStrokeToEdgesWorld(m_strokeWorld, gray, cropWorld.topLeft(), cropWorld.size(), 6.0); + setPendingEntityPolygonWorld(snapped); + } else if (!m_bgImage.isNull()) { + const QRect cw = entity_cutout::clampRectToImage( + polyBr.adjusted(-kSamCropMargin, -kSamCropMargin, kSamCropMargin, kSamCropMargin) + .toAlignedRect(), + m_bgImage.size()); + const QImage gray = m_bgImage.copy(cw).convertToFormat(QImage::Format_Grayscale8); + const QVector snapped = + snapStrokeToEdgesWorld(m_strokeWorld, gray, cw.topLeft(), cw.size(), 6.0); + setPendingEntityPolygonWorld(snapped); + } } } else if (m_strokeWorld.size() >= kMinStrokePointsSam) { ensurePixmapLoaded(); + const QRectF polyBr = QPolygonF(m_strokeWorld).boundingRect(); + QRect cropWorld = entity_cutout::clampRectToImage( + polyBr.adjusted(-kSamCropMargin, -kSamCropMargin, kSamCropMargin, kSamCropMargin) + .toAlignedRect(), + QSize(backgroundLogicalWidth(), backgroundLogicalHeight())); + QImage crop; + if (!cropWorld.isEmpty()) { + (void)core::image_file::loadRegionToQImage(m_bgAbsPath, cropWorld, 4096, 4096, &crop); + } + if (crop.isNull() && !m_bgImage.isNull()) { + cropWorld = entity_cutout::clampRectToImage( + polyBr.adjusted(-kSamCropMargin, -kSamCropMargin, kSamCropMargin, kSamCropMargin) + .toAlignedRect(), + m_bgImage.size()); + crop = m_bgImage.copy(cropWorld); + } + if (crop.isNull()) { + m_bgImageDirty = false; + m_bgImage = readImageTolerant(m_bgAbsPath); + if (!m_bgImage.isNull() && + m_bgImage.format() != QImage::Format_ARGB32_Premultiplied) { + m_bgImage = m_bgImage.convertToFormat(QImage::Format_ARGB32_Premultiplied); + } + cropWorld = entity_cutout::clampRectToImage( + polyBr.adjusted(-kSamCropMargin, -kSamCropMargin, kSamCropMargin, kSamCropMargin) + .toAlignedRect(), + m_bgImage.size()); + crop = m_bgImage.copy(cropWorld); + } QByteArray cropPng; QByteArray ovPng; QPointF cropOrigin; QJsonArray pts; QJsonArray labs; QJsonArray box; - if (buildSamSegmentPayloadFromStroke(m_strokeWorld, m_bgImage, cropPng, ovPng, cropOrigin, pts, labs, box)) { + if (!crop.isNull() && + buildSamSegmentPayloadFromStrokeMapped(m_strokeWorld, crop, cropWorld, cropPng, ovPng, cropOrigin, + pts, labs, box)) { emit requestSamSegment(cropPng, ovPng, cropOrigin, pts, labs, box); } } @@ -2471,7 +3221,9 @@ void EditorCanvas::mouseReleaseEvent(QMouseEvent* e) { // 关键:提交“平移量”必须与缩放无关。 // 拖动过程中可能实时按深度重算 visualScale(导致 rect/topLeft 随缩放变化), // 因此不能用 rect.topLeft 差值作为移动 delta,否则松手会错位。 - const QPointF delta = ent.animatedOriginWorld - m_entityDragStartAnimatedOrigin; + const QPointF endCentroid = m_dragPreviewActive ? (m_dragCentroidBase + m_dragDelta) + : shapeCentroidWorld(ent.polygonWorld, ent.rect); + const QPointF delta = endCentroid - m_entityDragStartCentroidWorld; bool sentMove = false; if (!ent.id.isEmpty() && (!qFuzzyIsNull(delta.x()) || !qFuzzyIsNull(delta.y()))) { @@ -2506,6 +3258,13 @@ void EditorCanvas::mouseReleaseEvent(QMouseEvent* e) { } } + if (m_draggingHotspotIndex >= 0 && e->button() == Qt::LeftButton && + m_draggingHotspotIndex < m_presentationHotspots.size()) { + const auto& h = m_presentationHotspots[m_draggingHotspotIndex]; + emit presentationHotspotMoved(h.id, h.centerWorld); + m_draggingHotspotIndex = -1; + } + m_dragging = false; if (m_pendingDragging && e->button() == Qt::LeftButton) { m_pendingDragging = false; @@ -2551,7 +3310,7 @@ void EditorCanvas::wheelEvent(QWheelEvent* e) { const qreal steps = e->angleDelta().y() / 120.0; const qreal factor = std::pow(1.15, steps); - const qreal newScale = std::clamp(m_scale * factor, 0.05, 50.0); + const qreal newScale = std::clamp(m_scale * factor, kViewScaleMin, kViewScaleMax); if (qFuzzyCompare(newScale, m_scale)) { return; } @@ -2560,6 +3319,7 @@ void EditorCanvas::wheelEvent(QWheelEvent* e) { // 让“光标指向的 world 点”缩放后仍落在光标处 const QPointF afterView = worldToView(beforeWorld); m_pan += (cursorView - afterView); + invalidateViewportLod(); update(); e->accept(); } diff --git a/client/gui/editor/EditorCanvas.h b/client/gui/editor/EditorCanvas.h index bcd765a..f30a1df 100644 --- a/client/gui/editor/EditorCanvas.h +++ b/client/gui/editor/EditorCanvas.h @@ -2,13 +2,17 @@ #include "core/domain/Project.h" +#include + #include #include #include #include #include +#include #include #include +#include #include #include #include @@ -22,7 +26,7 @@ class QDropEvent; class EditorCanvas final : public QWidget { Q_OBJECT public: - enum class Tool { Move, Zoom, CreateEntity }; + enum class Tool { Move, Zoom, CreateEntity, AddHotspot, MoveHotspot }; Q_ENUM(Tool) explicit EditorCanvas(QWidget* parent = nullptr); @@ -81,6 +85,13 @@ public: /// 退出「点击实体放大」状态并平滑回到进入前的视图(预览模式) void clearPresentationEntityFocus(); + void setPresentationHotspots(const QVector& hotspots); + void setPresentationHotspotEditorActive(bool on); + void setPresentationHotspotPlaybackTargetEnabled(bool on); + void setSelectedPresentationHotspotId(const QString& id); + QString selectedPresentationHotspotId() const { return m_selectedPresentationHotspotId; } + void clearPresentationHotspotSelection(); + void setEntities(const QVector& entities, const QVector& opacities01, const QString& projectDirAbs); @@ -105,9 +116,13 @@ public: void cancelBlackholeCopyResolve(); /// 背景图片文件内容被外部写盘更新(路径未变)时,强制重新加载缓存 void notifyBackgroundContentChanged(); + /// 黑洞修复覆盖层(独立 PNG)更新,不修改 background 原图 + void notifyBlackholeOverlaysChanged(); - // 与动画求值一致的原点/缩放(用于 K 帧与自动关键帧) + /// 当前帧求值后的变换原点(枢轴),与关键帧/父子偏移一致 QPointF selectedAnimatedOriginWorld() const; + /// 同 selectedAnimatedOriginWorld(语义名) + QPointF selectedEntityPivotWorld() const; double selectedDepthScale01() const; QPointF selectedEntityCentroidWorld() const; double selectedDistanceScaleMultiplier() const; @@ -145,6 +160,13 @@ signals: void presentationEntityIntroRequested(const QString& entityId, QPointF anchorViewPoint); /// 预览模式下应关闭介绍层(空白处轻点、Esc、开始还原缩放时由画布侧触发) void presentationInteractionDismissed(); + /// 首张背景已可绘制,或整图同步路径已就绪(用于打开工程时关闭加载对话框) + void initialBackgroundLoadFinished(); + void requestAddPresentationHotspot(const QPointF& centerWorld); + void requestAddPresentationHotspotForAnimation(const QPointF& centerWorld, const QString& animationId); + void presentationHotspotMoved(const QString& id, const QPointF& centerWorld); + void selectedPresentationHotspotChanged(const QString& id); + void hotspotPlayRequested(const QString& hotspotId); protected: void paintEvent(QPaintEvent* e) override; @@ -161,6 +183,14 @@ protected: private: void ensurePixmapLoaded() const; void invalidatePixmap(); + /// libvips + 视口分块:滚轮放大只拉高可见区域的解码分辨率(非整图降采样) + void invalidateViewportLod(); + void ensureBackgroundViewport(); + void tryEmitInitialBackgroundLoadFinished(); + + QRectF visibleWorldRectF() const; + int backgroundLogicalWidth() const; + int backgroundLogicalHeight() const; void updateCursor(); QPointF viewToWorld(const QPointF& v) const; @@ -169,6 +199,10 @@ private: bool isPointNearPendingVertex(const QPointF& worldPos, int* outIndex) const; bool pendingPolygonContains(const QPointF& worldPos) const; + int hitTestPresentationHotspot(const QPointF& worldPos) const; + /// 预览 none:热点内「播」触点命中则返回热点下标,否则 -1 + int hitTestHotspotPlayControl(const QPointF& worldPos) const; + void tickPresentationZoomAnimation(); void tickPresentationHoverAnimation(); void beginPresentationZoomTowardEntity(int entityIndex); @@ -192,10 +226,13 @@ private: double userScale = 1.0; // 与深度距离缩放相乘 double distanceScaleCalibMult = 0.0; // 与 Project::Entity 一致;0=未校准 bool ignoreDistanceScale = false; + int priority = 1; QPointF animatedOriginWorld; double animatedDepthScale01 = 0.5; double opacity = 1.0; // 0..1(由可见性轨道求值) bool blackholeVisible = true; + QString blackholeOverlayPath; + QRect blackholeOverlayRect; }; int hitTestEntity(const QPointF& worldPos) const; @@ -203,11 +240,30 @@ private: struct ToolView { core::Project::Tool tool; double opacity = 1.0; // 0..1 + int depthZ = 0; }; private: + QString m_projectDirAbs; + QHash m_blackholeOverlayImageCache; QString m_bgAbsPath; bool m_backgroundVisible = true; + mutable QSize m_bgLogicalSize; // 原图逻辑像素(与项目/实体世界坐标一致) + mutable bool m_useViewportLod = false; + mutable QImage m_bgViewportImage; // 仅覆盖 m_bgViewportCacheRect + mutable QRect m_bgViewportCacheRect; + mutable bool m_bgViewportDirty = true; + /// 记录上次视口纹理对应的「世界→屏幕」像素密度,滚轮缩放后需换更高分辨率纹理 + mutable qreal m_vpCachedDensity = 0.0; + /// 递增以使进行中的后台解码结果作废(路径变更 / 缩放 / 平移需刷新纹理) + std::atomic m_vpToken{0}; + std::atomic m_vpDecodeInFlight{false}; + /// 全图缩略解码成功后置位,下一帧强制区域解码以尽快变清晰 + mutable bool m_vpPreferRegionDecode = false; + bool m_initialBgLoadFinishedEmitted = false; + QImage m_bhCopyPatch; // 黑洞「复制背景」预览:原图子区域 + QPoint m_bhCopyPatchOrigin; + bool m_bhCopyPatchValid = false; mutable QPixmap m_bgPixmap; mutable bool m_pixmapDirty = true; mutable QImage m_bgImage; // 原背景(用于抠图/填充) @@ -226,6 +282,12 @@ private: bool m_gridVisible = true; bool m_checkerboardVisible = true; bool m_presentationPreviewMode = false; + bool m_presentationHotspotEditorActive = false; + bool m_presentationHotspotPlaybackTargetEnabled = false; + QVector m_presentationHotspots; + QString m_selectedPresentationHotspotId; + int m_draggingHotspotIndex = -1; + QPointF m_hotspotDragOffsetWorld; Tool m_tool = Tool::Move; EntityCreateSegmentMode m_entityCreateSegmentMode = EntityCreateSegmentMode::Manual; @@ -236,9 +298,9 @@ private: bool m_draggingEntity = false; bool m_drawingEntity = false; QPointF m_lastMouseView; - // 拖动以“实体原点 animatedOriginWorld”为基准,避免因缩放导致 rect/topLeft 抖动 - QPointF m_entityDragOffsetOriginWorld; - QPointF m_entityDragStartAnimatedOrigin; + // 拖动锚点(局部坐标,相对形心):解决拖动过程中距离缩放变化导致的“抖动/闪烁” + QPointF m_entityDragAnchorLocal; + QPointF m_entityDragStartCentroidWorld; // 拖动性能优化:拖动过程中不逐点修改 polygonWorld,而是保留基准形状+增量参数,在 paint 时做变换预览 bool m_dragPreviewActive = false; QVector m_dragPolyBase; diff --git a/client/gui/editor/EntityCutoutUtils.cpp b/client/gui/editor/EntityCutoutUtils.cpp index f0440df..c1af50d 100644 --- a/client/gui/editor/EntityCutoutUtils.cpp +++ b/client/gui/editor/EntityCutoutUtils.cpp @@ -68,7 +68,23 @@ QImage extractEntityImage(const QImage& bg, const QVector& polyWorld, Q outTopLeftWorld = {}; return {}; } - const QRect bbox = clampRectToImage(path.boundingRect().toAlignedRect(), bg.size()); + // bbox 必须只由顶点 min/max 推导,不能依赖 QPainterPath::boundingRect(): + // 在坐标含小数时,boundingRect 的浮点误差会造成稳定的“右下”偏移。 + double minX = polyWorld.front().x(); + double minY = polyWorld.front().y(); + double maxX = polyWorld.front().x(); + double maxY = polyWorld.front().y(); + for (const auto& p : polyWorld) { + minX = std::min(minX, double(p.x())); + minY = std::min(minY, double(p.y())); + maxX = std::max(maxX, double(p.x())); + maxY = std::max(maxY, double(p.y())); + } + const int left = std::clamp(int(std::floor(minX)), 0, bg.width()); + const int top = std::clamp(int(std::floor(minY)), 0, bg.height()); + const int right = std::clamp(int(std::ceil(maxX)), 0, bg.width() - 1); + const int bottom = std::clamp(int(std::ceil(maxY)), 0, bg.height() - 1); + const QRect bbox = clampRectToImage(QRect(QPoint(left, top), QPoint(right, bottom)), bg.size()); if (bbox.isNull()) { outTopLeftWorld = {}; return {}; diff --git a/client/gui/library/ResourceLibraryDock.cpp b/client/gui/library/ResourceLibraryDock.cpp index 92b4efd..71a2080 100644 --- a/client/gui/library/ResourceLibraryDock.cpp +++ b/client/gui/library/ResourceLibraryDock.cpp @@ -255,7 +255,7 @@ void ResourceLibraryDock::rebuildCombinedList() { it->setText(r.displayName); it->setIcon(QIcon(makePreviewPixmap(r))); it->setData(Qt::UserRole, i); - it->setToolTip(r.resourceId); + it->setToolTip({}); list->addItem(it); } } diff --git a/client/gui/main_window/MainWindow.cpp b/client/gui/main_window/MainWindow.cpp index e0cef96..0d50505 100644 --- a/client/gui/main_window/MainWindow.cpp +++ b/client/gui/main_window/MainWindow.cpp @@ -16,13 +16,18 @@ #include "props/EntityPropertySection.h" #include "props/ToolPropertySection.h" #include "props/CameraPropertySection.h" +#include "props/HotspotPropertySection.h" #include "timeline/TimelineWidget.h" +#include "timeline/TimelineEditorModel.h" #include "dialogs/FrameAnimationDialog.h" +#include "dialogs/AnimationSchemeSettingsDialog.h" #include "dialogs/EntityIntroPopup.h" #include "library/ResourceLibraryDock.h" #include "core/library/ResourceLibraryProvider.h" #include "core/library/OnlineResourceLibraryProvider.h" #include "core/eval/ProjectEvaluator.h" +#include "core/image/ImageDecodeConfig.h" +#include "core/image/ImageFileLoader.h" #include #include @@ -35,14 +40,18 @@ #include #include #include +#include #include #include #include +#include #include #include #include +#include #include #include +#include #include #include #include @@ -66,6 +75,7 @@ #include #include #include +#include #include #include #include @@ -93,24 +103,55 @@ namespace { -/// 右侧项目树/属性 dock 水平方向可拖到的最小宽度(须小于 kRightDockAutoHideBelow,否则无法触发自动隐藏) -constexpr int kRightDockMinimumWidth = 80; -/// 列宽小于此值时自动隐藏右侧两 dock -constexpr int kRightDockAutoHideBelow = 92; -/// 右侧 dock 列最大宽度,避免过宽挤占画布 -constexpr int kRightDockMaximumWidth = 252; -/// 属性区表单内容最大宽度(dock 仍可略宽,两侧留白,避免 SpinBox 被拉得过开) -constexpr int kPropertyPanelContentMaxWidth = 232; +/// 列宽小于此值时自动隐藏右侧两 dock(需小于下方列最小宽度,拖窄时才可能触发) +constexpr int kRightDockAutoHideBelow = 64; +/// 无内容或内容极窄时的下限;实际最小宽度取 max(本值, 属性栈/项目树 minimumSizeHint)。 +constexpr int kRightDockColumnFallbackMinWidth = 168; +constexpr int kRightDockColumnStartupWidth = 220; /// 启动时垂直分割高度:项目树较矮、属性区较高 -constexpr int kProjectTreeDockStartupHeight = 148; -constexpr int kPropertiesDockStartupHeight = 392; +constexpr int kProjectTreeDockStartupHeight = 170; +constexpr int kPropertiesDockStartupHeight = 380; +constexpr char kMimeHotspotAnimationJson[] = "application/x-hfut-hotspot-animation+json"; + +[[nodiscard]] QString firstAnimationSchemeIdNonNoneWs(const core::Project& proj) { + for (const auto& a : proj.animations()) { + if (a.id != QStringLiteral("none")) + return a.id; + } + return {}; +} + +[[nodiscard]] QVector> nonNoneAnimationIdLabelPairsWs(const core::Project& proj) { + QVector> out; + for (const auto& a : proj.animations()) { + if (a.id == QStringLiteral("none")) { + continue; + } + out.push_back(qMakePair(a.id, a.name.isEmpty() ? a.id : a.name)); + } + return out; +} + +[[nodiscard]] QString nextPresentationHotspotIdWs(const QVector& hs) { + QSet used; + for (const auto& h : hs) { + used.insert(h.id); + } + int n = static_cast(hs.size()) + 1; + for (int guard = 0; guard < 100000; ++guard, ++n) { + const QString c = QStringLiteral("hotspot-%1").arg(n); + if (!used.contains(c)) + return c; + } + return QStringLiteral("hotspot-1"); +} void polishCompactToolButton(QToolButton* b, int px = 40) { if (!b) return; b->setFixedSize(px, px); b->setFocusPolicy(Qt::NoFocus); b->setAutoRaise(true); - b->setIconSize(QSize(px - 14, px - 14)); + b->setIconSize(QSize(std::max(12, px - 16), std::max(12, px - 16))); } void setToolButtonIconOrText(QToolButton* b, const QString& themeName, const QString& text) { @@ -134,12 +175,12 @@ const char* kEditorToolRailQss = R"( #EditorToolRail { background-color: palette(base); border: 1px solid palette(midlight); - border-radius: 10px; + border-radius: 0px; } #EditorToolRail QToolButton { border: 1px solid transparent; - border-radius: 8px; - padding: 2px; + border-radius: 0px; + padding: 0px; background: transparent; } #EditorToolRail QToolButton:hover { @@ -155,15 +196,24 @@ const char* kFloatingModeDockQss = R"( #FloatingModeDock { background-color: palette(base); border: 1px solid palette(midlight); - border-radius: 10px; + border-radius: 0px; +} +#FloatingModeDock QToolButton { + border: 1px solid transparent; + border-radius: 0px; + padding: 0px; + background: transparent; +} +#FloatingModeDock QToolButton:hover { + background: palette(midlight); } #FloatingModeDock QComboBox { background-color: palette(button); color: palette(button-text); border: 1px solid palette(mid); - border-radius: 6px; - padding: 4px 6px; - min-height: 22px; + border-radius: 0px; + padding: 1px 3px; + min-height: 17px; } #FloatingModeDock QComboBox:hover { background-color: palette(light); @@ -171,6 +221,25 @@ const char* kFloatingModeDockQss = R"( #FloatingModeDock QComboBox:focus { border: 1px solid palette(highlight); } +#FloatingModeDock QComboBox::drop-down { + border: 0; + width: 0px; + background: transparent; +} +#FloatingModeDock QComboBox::down-arrow { + image: none; + width: 0px; + height: 0px; +} +#FloatingModeDock QComboBox QAbstractItemView { + border-radius: 0px; + padding: 1px 0px; + min-width: 84px; +} +#FloatingModeDock QComboBox QAbstractItemView::item { + min-height: 18px; + padding: 2px 8px; +} )"; /// 避免滚轮先被 QScrollArea 吃掉,导致内嵌 QDoubleSpinBox 无法用滚轮调节 @@ -227,6 +296,8 @@ public: QWidget* modeDock = nullptr; QWidget* toolDock = nullptr; QWidget* previewPlaybackBar = nullptr; + QWidget* previewArrowLeft = nullptr; + QWidget* previewArrowRight = nullptr; void relayoutFloaters() { if (canvas) { @@ -271,6 +342,25 @@ public: toolDock->raise(); } + constexpr int arrowW = 44; + constexpr int arrowH = 72; + + auto placeArrowCenterY = [&](QWidget* arrow, bool leftSide) { + if (!arrow || !arrow->isVisible()) { + return; + } + arrow->setFixedSize(arrowW, arrowH); + const int y = std::max(kMargin, (height() - arrowH) / 2); + if (leftSide) { + arrow->move(kMargin, y); + } else { + arrow->move(std::max(kMargin, width() - arrowW - kMargin), y); + } + arrow->raise(); + }; + placeArrowCenterY(previewArrowLeft, true); + placeArrowCenterY(previewArrowRight, false); + if (previewPlaybackBar && previewPlaybackBar->isVisible()) { if (QLayout* lay = previewPlaybackBar->layout()) { lay->activate(); @@ -388,6 +478,8 @@ public: setDropIndicatorShown(true); setDragDropMode(QAbstractItemView::DragDrop); setDefaultDropAction(Qt::MoveAction); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setWordWrap(true); } // parentKind / parentId 为空表示“解除父子关系” @@ -474,9 +566,62 @@ protected: } }; +class HotspotAnimationListWidget final : public QListWidget { +public: + explicit HotspotAnimationListWidget(QWidget* parent = nullptr) : QListWidget(parent) { + setSelectionMode(QAbstractItemView::SingleSelection); + setDragEnabled(true); + setDragDropMode(QAbstractItemView::DragOnly); + setDefaultDropAction(Qt::CopyAction); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setWordWrap(true); + setSpacing(4); + } + + void setAnimations(const QVector>& animations) { + clear(); + if (animations.isEmpty()) { + auto* item = new QListWidgetItem(QStringLiteral("暂无可拖动动画")); + item->setFlags(Qt::ItemIsEnabled); + addItem(item); + return; + } + for (const auto& animation : animations) { + auto* item = new QListWidgetItem(animation.second, this); + item->setData(Qt::UserRole, animation.first); + item->setFlags(item->flags() | Qt::ItemIsDragEnabled); + item->setToolTip(animation.second); + } + } + +protected: + void startDrag(Qt::DropActions supportedActions) override { + Q_UNUSED(supportedActions); + auto* item = currentItem(); + if (!item) { + return; + } + const QString animationId = item->data(Qt::UserRole).toString(); + if (animationId.isEmpty()) { + return; + } + + QJsonObject payload; + payload.insert(QStringLiteral("animationId"), animationId); + payload.insert(QStringLiteral("label"), item->text()); + + auto* mime = new QMimeData(); + mime->setData(QString::fromUtf8(kMimeHotspotAnimationJson), + QJsonDocument(payload).toJson(QJsonDocument::Compact)); + + auto* drag = new QDrag(this); + drag->setMimeData(mime); + drag->exec(Qt::CopyAction); + } +}; + } // namespace -/// @brief MainWindow 类实现,负责构建和管理主界面,包括菜单、停靠窗口和属性面板。 MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { // 设置窗口大小 @@ -489,18 +634,34 @@ MainWindow::MainWindow(QWidget* parent) createTimelineDock(); createResourceLibraryDock(); - if (m_previewBtnPlay && m_previewBtnPause && m_btnPlay) { - connect(m_previewBtnPlay, &QToolButton::clicked, this, [this]() { - if (m_btnPlay && !m_btnPlay->isChecked()) { - m_btnPlay->setChecked(true); + m_previewPlayTimer = new QTimer(this); + connect(m_previewPlayTimer, &QTimer::timeout, this, [this]() { + if (!m_workspace.isOpen() || !m_previewRequested || !m_previewPlaying) { + return; + } + const QString id = effectivePreviewAnimationId(); + if (id == QStringLiteral("none")) { + return; + } + const int len = m_workspace.animationLengthFramesForId(id); + if (len <= 0) { + return; + } + if (m_previewFrame >= len - 1) { + if (m_workspace.animationLoopsForId(id)) { + m_previewFrame = 0; + } else { + m_previewFrame = len - 1; + stopPreviewPlayback(); + refreshEditorPage(); + syncPreviewPlaybackBar(); + return; } - }); - connect(m_previewBtnPause, &QToolButton::clicked, this, [this]() { - if (m_btnPlay && m_btnPlay->isChecked()) { - m_btnPlay->setChecked(false); - } - }); - } + } else { + ++m_previewFrame; + } + refreshEditorPage(); + }); syncPreviewPlaybackBar(); refreshProjectTree(); @@ -526,7 +687,6 @@ MainWindow::MainWindow(QWidget* parent) }); setWindowTitle("工具"); - statusBar()->showMessage("就绪"); } void MainWindow::createTimelineDock() { @@ -544,23 +704,27 @@ void MainWindow::createTimelineDock() { m_btnPlay = new QToolButton(bar); m_btnPlay->setText(QStringLiteral("▶")); m_btnPlay->setCheckable(true); - m_btnPlay->setToolTip(QStringLiteral("播放 / 暂停")); + m_btnPlay->setToolTip({}); polishCompactToolButton(m_btnPlay, 34); layout->addWidget(m_btnPlay); m_schemeSelector = new QComboBox(bar); m_schemeSelector->setMinimumWidth(140); - m_schemeSelector->setToolTip(QStringLiteral("动画方案")); + m_schemeSelector->setToolTip({}); layout->addWidget(m_schemeSelector); m_timeline = new TimelineWidget(bar); m_timeline->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::MinimumExpanding); layout->addWidget(m_timeline, 1); + // 阶段1:把时间轴视图状态抽到 model(先做最小接入,后续再把命中/交互迁移过去) + m_timelineModel = new TimelineEditorModel(); + m_timeline->setModel(m_timelineModel); + // 合并后的关键帧按钮:一次写入位置 + userScale auto* btnKeyCombined = new QToolButton(bar); setToolButtonIconOrText(btnKeyCombined, QStringLiteral("media-record"), QStringLiteral("关键帧")); - btnKeyCombined->setToolTip(QStringLiteral("在当前帧记录实体或摄像机的位置与缩放关键帧")); + btnKeyCombined->setToolTip({}); polishCompactToolButton(btnKeyCombined, 34); layout->addWidget(btnKeyCombined); @@ -577,18 +741,18 @@ void MainWindow::createTimelineDock() { m_playTimer = new QTimer(this); connect(m_playTimer, &QTimer::timeout, this, [this]() { - if (!m_timeline || !m_workspace.isOpen()) { + if (!m_timeline || !m_workspace.isOpen() || m_previewRequested) { return; } - // 简化:无编排,globalFrame==localFrame,固定 600 帧循环 - m_currentFrame = (m_currentFrame + 1) % core::Project::kClipFixedFrames; + const int len = m_workspace.activeAnimationLengthFrames(); + m_currentFrame = (m_currentFrame + 1) % std::max(1, len); m_timeline->setCurrentFrame(m_currentFrame); }); connect(m_btnPlay, &QToolButton::toggled, this, &MainWindow::onTogglePlay); connect(m_timeline, &TimelineWidget::frameScrubbed, this, [this](int v) { // 轻量实时预览:只更新画布帧,不做 refreshEditorPage 的全量重建 - m_currentFrame = std::clamp(v, 0, core::Project::kClipFixedFrames - 1); + m_currentFrame = std::clamp(v, 0, m_workspace.activeAnimationLengthFrames() - 1); if (m_editorCanvas && m_workspace.isOpen()) { // 需要重新求值实体几何/贴图轨道,否则拖动实体与属性变更在非 0 帧会失效 m_timelineScrubbing = true; @@ -640,83 +804,63 @@ void MainWindow::createTimelineDock() { }); connect(m_timeline, &TimelineWidget::frameCommitted, this, [this](int v) { // 松手再做一次较重刷新(如果后续还有需要同步的 UI) - m_currentFrame = std::clamp(v, 0, core::Project::kClipFixedFrames - 1); + m_currentFrame = std::clamp(v, 0, m_workspace.activeAnimationLengthFrames() - 1); refreshEditorPage(); }); connect(btnKeyCombined, &QToolButton::clicked, this, &MainWindow::onInsertCombinedKey); - // 方案切换(下拉里包含“新建方案…”) + // 动画切换(下拉里包含“新建动画…”);帧数在创建时手动输入。 connect(m_schemeSelector, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int idx) { if (!m_workspace.isOpen() || !m_schemeSelector) return; - const QString schemeId = m_schemeSelector->itemData(idx).toString(); - // 特殊项:新建方案 - if (schemeId == QStringLiteral("__create__")) { - auto& proj = m_workspace.project(); - - auto nextId = [](const QString& prefix, const QStringList& existing) { - int n = 1; - while (existing.contains(prefix + QString::number(n))) ++n; - return prefix + QString::number(n); - }; - - QStringList clipIds; - for (const auto& c : proj.animationClips()) clipIds << c.id; - QStringList schemeIds; - for (const auto& s : proj.animationSchemes()) schemeIds << s.id; - - const QString clipId = nextId(QStringLiteral("clip-"), clipIds); - core::Project::AnimationClip clip; - clip.id = clipId; - clip.name = QStringLiteral("Clip_%1").arg(proj.animationClips().size() + 1, 3, 10, QChar('0')); - auto clips = proj.animationClips(); - clips.push_back(clip); - proj.setAnimationClips(clips); - - const QString schemeNewId = nextId(QStringLiteral("scheme-"), schemeIds); - core::Project::NlaStrip st; - st.id = QStringLiteral("strip-1"); - st.clipId = clipId; - st.startSlot = 0; - st.slotLen = 1; - st.enabled = true; - - core::Project::NlaTrack tr; - tr.id = QStringLiteral("track-1"); - tr.name = QStringLiteral("Track"); - tr.strips = {st}; - - core::Project::AnimationScheme scheme; - scheme.id = schemeNewId; - scheme.name = QStringLiteral("方案_%1").arg(proj.animationSchemes().size() + 1, 3, 10, QChar('0')); - scheme.tracks = {tr}; - - auto schemes = proj.animationSchemes(); - schemes.push_back(scheme); - proj.setAnimationSchemes(schemes); - proj.setActiveSchemeId(schemeNewId); - proj.setSelectedStripId(st.id); - - m_workspace.save(); + const QString animId = m_schemeSelector->itemData(idx).toString(); + if (animId == QStringLiteral("__create__")) { + AnimationSchemeSettingsDialog dlg(AnimationSchemeSettingsDialog::Mode::Create, m_workspace, this); + const int dlgResult = dlg.exec(); + const QString restoreId = m_workspace.project().activeAnimationId(); + if (m_schemeSelector) { + m_schemeSelector->blockSignals(true); + int ridx = -1; + for (int i = 0; i < m_schemeSelector->count(); ++i) { + if (m_schemeSelector->itemData(i).toString() == restoreId) { + ridx = i; + break; + } + } + if (ridx >= 0) { + m_schemeSelector->setCurrentIndex(ridx); + } + m_schemeSelector->blockSignals(false); + } + if (dlgResult != QDialog::Accepted) { + return; + } refreshEditorPage(); return; } - if (schemeId.isEmpty()) return; - if (schemeId == m_workspace.project().activeSchemeId()) return; - m_workspace.project().setActiveSchemeId(schemeId); - // 切换时默认选中该方案第一条带 - const auto* scheme = m_workspace.project().findSchemeById(schemeId); - QString firstStrip; - if (scheme) { - for (const auto& tr : scheme->tracks) { - if (!tr.strips.isEmpty()) { - firstStrip = tr.strips.front().id; - break; - } + if (animId.isEmpty()) return; + if (animId == m_workspace.project().activeAnimationId()) return; + m_workspace.project().setActiveAnimationId(animId); + m_workspace.save(); + if (animId == QStringLiteral("none")) { + m_animationRequested = false; + m_hotspotRequested = false; + if (m_modeSelector) { + m_modeSelector->blockSignals(true); + m_modeSelector->setCurrentIndex(0); + m_modeSelector->blockSignals(false); + } + } else { + m_animationRequested = true; + m_hotspotRequested = false; + if (m_modeSelector) { + m_modeSelector->blockSignals(true); + m_modeSelector->setCurrentIndex(1); + m_modeSelector->blockSignals(false); } } - m_workspace.project().setSelectedStripId(firstStrip); - m_workspace.save(); refreshEditorPage(); + refreshProjectTree(); + updateUiEnabledState(); }); connect(m_timeline, &TimelineWidget::contextMenuRequested, this, [this](const QPoint& globalPos, int frame) { @@ -732,17 +876,19 @@ void MainWindow::createTimelineDock() { const bool entityKeyUi = (m_workspace.isOpen() && !m_selectedEntityId.isEmpty()); const bool toolKeyUi = (m_workspace.isOpen() && m_hasSelectedTool && !m_selectedToolId.isEmpty()); const bool cameraKeyUi = (m_workspace.isOpen() && m_hasSelectedCamera && !m_selectedCameraId.isEmpty()); - actDeleteKey->setEnabled(m_workspace.isOpen() && m_timeline->hasSelectedKeyframe() && + const bool animEditable = (m_workspace.isOpen() && + m_workspace.project().activeAnimationId() != QStringLiteral("none")); + actDeleteKey->setEnabled(animEditable && m_timeline->hasSelectedKeyframe() && (entityKeyUi || toolKeyUi || cameraKeyUi)); const int selA = m_timeline->selectionStart(); const int selB = m_timeline->selectionEnd(); const bool hasRange = (selA >= 0 && selB >= 0); actClear->setEnabled(hasRange); - actAnim->setEnabled(hasRange && !m_selectedEntityId.isEmpty() && m_workspace.isOpen()); + actAnim->setEnabled(animEditable && hasRange && !m_selectedEntityId.isEmpty()); // 右键命中帧:用鼠标位置对应的 frame // 右键命中 localFrame:globalFrame==localFrame - m_currentFrame = std::clamp(frame, 0, core::Project::kClipFixedFrames - 1); + m_currentFrame = std::clamp(frame, 0, m_workspace.activeAnimationLengthFrames() - 1); if (m_editorCanvas) m_editorCanvas->setCurrentFrame(m_currentFrame); QAction* chosen = execPopupMenuAboveFullscreen(menu, globalPos, this); @@ -786,9 +932,9 @@ void MainWindow::createTimelineDock() { return; } if (chosen == actSetStart) { - m_timelineRangeStart = m_currentFrame % core::Project::kClipFixedFrames; + m_timelineRangeStart = m_currentFrame; if (m_timelineRangeEnd < 0) { - m_timelineRangeEnd = m_currentFrame % core::Project::kClipFixedFrames; + m_timelineRangeEnd = m_currentFrame; } if (m_timelineRangeEnd < m_timelineRangeStart) { std::swap(m_timelineRangeStart, m_timelineRangeEnd); @@ -799,9 +945,9 @@ void MainWindow::createTimelineDock() { return; } if (chosen == actSetEnd) { - m_timelineRangeEnd = m_currentFrame % core::Project::kClipFixedFrames; + m_timelineRangeEnd = m_currentFrame; if (m_timelineRangeStart < 0) { - m_timelineRangeStart = m_currentFrame % core::Project::kClipFixedFrames; + m_timelineRangeStart = m_currentFrame; } if (m_timelineRangeEnd < m_timelineRangeStart) { std::swap(m_timelineRangeStart, m_timelineRangeEnd); @@ -824,7 +970,7 @@ void MainWindow::createTimelineDock() { return; } const int fs = 0; - const int fe = core::Project::kClipFixedFrames - 1; + const int fe = m_workspace.activeAnimationLengthFrames() - 1; const int a = std::clamp(std::min(selA, selB), fs, fe); const int b = std::clamp(std::max(selA, selB), fs, fe); if (a > b) { @@ -862,40 +1008,13 @@ void MainWindow::createResourceLibraryDock() { } void MainWindow::syncCreateEntityToolButtonTooltip() { - if (!m_btnCreateEntity || !m_editorCanvas) { + if (!m_btnCreateEntity) { return; } - using Mode = EditorCanvas::EntityCreateSegmentMode; - const Mode m = m_editorCanvas->entityCreateSegmentMode(); - if (m == Mode::Manual) { - m_btnCreateEntity->setToolTip(QStringLiteral("创建实体:手动分割\n" - "再次单击本按钮可选择:手动/吸附/模型")); - } else if (m == Mode::Snap) { - m_btnCreateEntity->setToolTip(QStringLiteral("创建实体:吸附分割(前端边缘吸附算法)\n" - "再次单击本按钮可选择:手动/吸附/模型")); - } else { - m_btnCreateEntity->setToolTip(QStringLiteral("创建实体:模型分割(SAM)\n" - "再次单击本按钮可选择:手动/吸附/模型")); - } + m_btnCreateEntity->setToolTip({}); } -void MainWindow::updateStatusBarText() { - // 坐标系:图片左上角为 (0,0),单位为像素(world 坐标与背景像素一致) - if (m_hasSelectedEntity) { - statusBar()->showMessage(QStringLiteral("实体中心 (%1, %2, %3) | 鼠标 (%4, %5, %6)") - .arg(m_selectedEntityOrigin.x(), 0, 'f', 1) - .arg(m_selectedEntityOrigin.y(), 0, 'f', 1) - .arg(m_selectedEntityDepth) - .arg(m_lastWorldPos.x(), 0, 'f', 1) - .arg(m_lastWorldPos.y(), 0, 'f', 1) - .arg(m_lastWorldZ)); - } else { - statusBar()->showMessage(QStringLiteral("鼠标 (%1, %2, %3)") - .arg(m_lastWorldPos.x(), 0, 'f', 1) - .arg(m_lastWorldPos.y(), 0, 'f', 1) - .arg(m_lastWorldZ)); - } -} +void MainWindow::updateStatusBarText() {} void MainWindow::onComputeDepth() { if (!m_workspace.isOpen() || !m_workspace.hasBackground()) { @@ -973,7 +1092,6 @@ void MainWindow::computeDepthAsync() { // 用户取消也会走这里(OperationCanceledError 等) if (netErrStr.contains(QStringLiteral("canceled"), Qt::CaseInsensitive) || netErr == QNetworkReply::OperationCanceledError) { - statusBar()->showMessage(QStringLiteral("已取消计算深度")); return; } QMessageBox::warning(this, QStringLiteral("深度"), QStringLiteral("网络错误:%1").arg(netErrStr)); @@ -1003,7 +1121,6 @@ void MainWindow::computeDepthAsync() { return; } - statusBar()->showMessage(QStringLiteral("深度已计算")); refreshEditorPage(); refreshProjectTree(); updateUiEnabledState(); @@ -1014,34 +1131,279 @@ void MainWindow::computeDepthAsync() { } void MainWindow::onTogglePlay(bool on) { + if (m_previewRequested) { + if (m_btnPlay) { + m_btnPlay->blockSignals(true); + m_btnPlay->setChecked(false); + m_btnPlay->blockSignals(false); + m_btnPlay->setText(QStringLiteral("▶")); + } + if (m_playTimer) { + m_playTimer->stop(); + } + m_playing = false; + return; + } m_playing = on; if (m_btnPlay) { m_btnPlay->setText(on ? QStringLiteral("⏸") : QStringLiteral("▶")); } if (m_playTimer) { if (on) { - const int fps = 30; // 固定 30fps 播放 - m_playTimer->start(1000 / fps); + const int fps = m_workspace.isOpen() ? m_workspace.activeAnimationFps() : 30; + m_playTimer->start(1000 / std::max(1, fps)); } else { m_playTimer->stop(); } } +} + +void MainWindow::stopEditorPlayback() { + m_playing = false; + if (m_playTimer) { + m_playTimer->stop(); + } + if (m_btnPlay) { + m_btnPlay->blockSignals(true); + m_btnPlay->setChecked(false); + m_btnPlay->blockSignals(false); + m_btnPlay->setText(QStringLiteral("▶")); + } +} + +void MainWindow::stopPreviewPlayback() { + m_previewPlaying = false; + if (m_previewPlayTimer) { + m_previewPlayTimer->stop(); + } syncPreviewPlaybackBar(); } -void MainWindow::syncPreviewPlaybackBar() { - if (!m_previewBtnPlay || !m_previewBtnPause) { +void MainWindow::startPreviewPlayback() { + if (!m_workspace.isOpen() || !m_previewRequested) { return; } - m_previewBtnPlay->setEnabled(!m_playing); - m_previewBtnPause->setEnabled(m_playing); + if (effectivePreviewAnimationId() == QStringLiteral("none")) { + stopPreviewPlayback(); + return; + } + m_previewPlaying = true; + const QString id = effectivePreviewAnimationId(); + const int fps = m_workspace.animationFpsForId(id); + if (m_previewPlayTimer) { + m_previewPlayTimer->start(1000 / std::max(1, fps)); + } + syncPreviewPlaybackBar(); +} + +void MainWindow::togglePreviewPlayback() { + if (m_previewPlaying) { + stopPreviewPlayback(); + } else { + startPreviewPlayback(); + } +} + +QVector MainWindow::previewAnimationSchemeIdsNonNone() const { + QVector out; + if (!m_workspace.isOpen()) { + return out; + } + for (const auto& a : m_workspace.project().animations()) { + if (a.id == QStringLiteral("none")) + continue; + out.push_back(a.id); + } + return out; +} + +QString MainWindow::effectivePreviewAnimationId() const { + if (!m_workspace.isOpen()) { + return QStringLiteral("none"); + } + const core::Project& pr = m_workspace.project(); + const bool hasSlotNone = pr.findAnimationById(QStringLiteral("none")) != nullptr; + + QString want = m_previewAnimationId.isEmpty() ? QStringLiteral("none") : m_previewAnimationId; + if (want == QStringLiteral("none")) { + if (hasSlotNone) + return QStringLiteral("none"); + const QString fb = firstAnimationSchemeIdNonNoneWs(pr); + return fb.isEmpty() ? QStringLiteral("none") : fb; + } + if (pr.findAnimationById(want)) { + return want; + } + if (hasSlotNone) + return QStringLiteral("none"); + const QString fb = firstAnimationSchemeIdNonNoneWs(pr); + return fb.isEmpty() ? QStringLiteral("none") : fb; +} + +void MainWindow::refreshPreviewSchemeCombo() { + if (!m_previewSchemeCombo || !m_workspace.isOpen()) { + return; + } + const QString keep = effectivePreviewAnimationId(); + m_previewSchemeCombo->blockSignals(true); + m_previewSchemeCombo->clear(); + QString noneLabel = QStringLiteral("画布"); + for (const auto& a : m_workspace.project().animations()) { + if (a.id == QStringLiteral("none")) { + if (!a.name.isEmpty()) + noneLabel = a.name; + break; + } + } + if (m_workspace.project().findAnimationById(QStringLiteral("none"))) { + m_previewSchemeCombo->addItem(noneLabel, QStringLiteral("none")); + } + for (const auto& a : m_workspace.project().animations()) { + if (a.id == QStringLiteral("none")) { + continue; + } + const QString label = a.name.isEmpty() ? a.id : a.name; + m_previewSchemeCombo->addItem(label, a.id); + } + int idx = -1; + for (int i = 0; i < m_previewSchemeCombo->count(); ++i) { + if (m_previewSchemeCombo->itemData(i).toString() == keep) { + idx = i; + break; + } + } + if (idx >= 0) { + m_previewSchemeCombo->setCurrentIndex(idx); + } else if (m_previewSchemeCombo->count() > 0) { + m_previewSchemeCombo->setCurrentIndex(0); + } + m_previewSchemeCombo->blockSignals(false); +} + +void MainWindow::requestPreviewSchemeSwitchTo(const QString& id, bool showProgress) { + if (!m_previewRequested || !m_workspace.isOpen() || id.isEmpty()) { + return; + } + const bool isNone = (id == QStringLiteral("none")); + if (isNone) { + if (!m_workspace.project().findAnimationById(QStringLiteral("none"))) { + return; + } + } else { + bool allowed = false; + for (const QString& cand : previewAnimationSchemeIdsNonNone()) { + if (cand == id) { + allowed = true; + break; + } + } + if (!allowed) + return; + } + + stopPreviewPlayback(); + m_previewAnimationId = id; + m_previewFrame = 0; + + auto finishUi = [this]() { + refreshPreviewSchemeCombo(); + refreshEditorPage(); + if (effectivePreviewAnimationId() != QStringLiteral("none")) + startPreviewPlayback(); + else + syncPreviewPlaybackBar(); + }; + + if (showProgress && !isNone) { + QProgressDialog dlg(QStringLiteral("加载中…"), QString(), 0, 0, this); + dlg.setCancelButton(nullptr); + dlg.setWindowModality(Qt::ApplicationModal); + dlg.setMinimumDuration(0); + dlg.show(); + QApplication::processEvents(QEventLoop::DialogExec | QEventLoop::AllEvents); + } + (void)core::eval::evaluateAtFrame(m_workspace.project(), 0, 10, id); + finishUi(); +} + +void MainWindow::switchPreviewSchemeAdjacent(int delta) { + QVector ids = previewAnimationSchemeIdsNonNone(); + if (ids.size() <= 1) { + return; + } + const QString cur = effectivePreviewAnimationId(); + int ix = 0; + for (int i = 0; i < ids.size(); ++i) { + if (ids[i] == cur) { + ix = i; + break; + } + } + const int n = static_cast(ids.size()); + ix = ((ix + delta) % n + n) % n; + requestPreviewSchemeSwitchTo(ids[ix], true); +} + +void MainWindow::onPreviewSchemeComboChanged(int idx) { + if (!m_previewSchemeCombo || !m_workspace.isOpen() || !m_previewRequested || idx < 0) { + return; + } + const QString animId = m_previewSchemeCombo->itemData(idx).toString(); + if (animId.isEmpty() || animId == m_previewAnimationId) { + return; + } + requestPreviewSchemeSwitchTo(animId, true); +} + +void MainWindow::syncHotspotEnterPreviewCombo() { + const bool any = previewAnimationSchemeIdsNonNone().size() > 0; + if (m_btnHotspotEnterPreview) { + m_btnHotspotEnterPreview->setEnabled(any); + } +} + +void MainWindow::syncEditorRailForMode() { + const bool open = m_workspace.isOpen(); + const bool hotspotRail = open && m_hotspotRequested && !m_previewRequested; + const bool entityRail = open && !m_previewRequested && !m_hotspotRequested && !m_animationRequested; + const bool animationRail = open && !m_previewRequested && m_animationRequested; + if (m_railBtnMove) + m_railBtnMove->setVisible(open && !m_previewRequested); + if (m_railBtnZoom) + m_railBtnZoom->setVisible(entityRail || animationRail); + if (m_btnCreateEntity) + m_btnCreateEntity->setVisible(entityRail); + if (m_btnToggleDepthOverlay) + m_btnToggleDepthOverlay->setVisible(entityRail || animationRail); + if (m_railBtnFit) + m_railBtnFit->setVisible(open && !m_previewRequested); + if (m_btnRailAddHotspot) + m_btnRailAddHotspot->setVisible(false); + if (m_btnRailMoveHotspot) + m_btnRailMoveHotspot->setVisible(hotspotRail); + if (m_btnHotspotEnterPreview) + m_btnHotspotEnterPreview->setVisible(hotspotRail); + if (m_canvasHost) { + static_cast(m_canvasHost)->relayoutFloaters(); + } +} + +void MainWindow::syncPreviewPlaybackBar() { + if (!m_previewBtnPlay) { + return; + } + m_previewBtnPlay->setText(m_previewPlaying ? QStringLiteral("暂停") : QStringLiteral("播放")); } void MainWindow::onInsertCombinedKey() { if (!m_editorCanvas || !m_workspace.isOpen()) { return; } - const int lf = m_currentFrame % core::Project::kClipFixedFrames; + if (m_workspace.project().activeAnimationId() == QStringLiteral("none")) { + // “无”是静态模式:不允许写关键帧 + return; + } + const int lf = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); if (m_hasSelectedCamera && !m_selectedCameraId.isEmpty()) { const core::eval::ResolvedProjectFrame rf = core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); @@ -1072,11 +1434,40 @@ void MainWindow::onInsertCombinedKey() { void MainWindow::createMenus() { createFileMenu(); createEditMenu(); + createAnimationMenu(); createViewMenu(); createWindowMenu(); createHelpMenu(); } +void MainWindow::createAnimationMenu() { + QMenu* m = menuBar()->addMenu(QStringLiteral("动画")); + m_actionAnimationSettings = m->addAction(QStringLiteral("当前方案动画设置…")); + connect(m_actionAnimationSettings, &QAction::triggered, this, [this]() { + if (!m_workspace.isOpen()) { + return; + } + QString targetId; + if (m_previewRequested) { + targetId = effectivePreviewAnimationId(); + } else { + targetId = m_workspace.project().activeAnimationId(); + } + if (targetId.isEmpty() || targetId == QStringLiteral("none")) { + QMessageBox::information(this, + QStringLiteral("动画"), + QStringLiteral("当前无可编辑的动画方案(「无」为静态模式)。")); + return; + } + AnimationSchemeSettingsDialog dlg(AnimationSchemeSettingsDialog::Mode::Edit, m_workspace, this); + dlg.setEditTarget(targetId); + if (dlg.exec() == QDialog::Accepted) { + refreshPreviewSchemeCombo(); + refreshEditorPage(); + } + }); +} + void MainWindow::createFileMenu() { auto m_fileMenu = menuBar()->addMenu(QString()); m_fileMenu->setTitle("文件"); @@ -1230,11 +1621,27 @@ void MainWindow::createViewMenu() { viewMenu->addSeparator(); m_actionEnterPreview = viewMenu->addAction(QStringLiteral("进入预览展示")); connect(m_actionEnterPreview, &QAction::triggered, this, [this]() { + m_animationRequested = false; + m_hotspotRequested = false; + m_previewAnimationId = QStringLiteral("none"); + m_previewFrame = 0; + if (m_modeSelector) { + m_modeSelector->blockSignals(true); + m_modeSelector->setCurrentIndex(3); + m_modeSelector->blockSignals(false); + } setPreviewRequested(true); }); m_actionBackToEditor = viewMenu->addAction(QStringLiteral("返回编辑模式")); connect(m_actionBackToEditor, &QAction::triggered, this, [this]() { + m_animationRequested = false; + m_hotspotRequested = false; + if (m_modeSelector) { + m_modeSelector->blockSignals(true); + m_modeSelector->setCurrentIndex(0); + m_modeSelector->blockSignals(false); + } setPreviewRequested(false); }); } @@ -1280,24 +1687,28 @@ void MainWindow::createWindowMenu() { void MainWindow::createProjectTreeDock() { m_dockProjectTree = new QDockWidget(QStringLiteral("项目树"), this); - m_dockProjectTree->setToolTip(QStringLiteral("右键条目可切换编辑与预览。")); + m_dockProjectTree->setToolTip({}); m_dockProjectTree->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea | Qt::BottomDockWidgetArea | Qt::TopDockWidgetArea); m_dockProjectTree->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetClosable); - m_dockProjectTree->setMinimumWidth(kRightDockMinimumWidth); + m_dockProjectTree->setMinimumWidth(kRightDockColumnFallbackMinWidth); auto* dockContent = new QWidget(m_dockProjectTree); + dockContent->setObjectName(QStringLiteral("ProjectTreeDockContent")); auto* dockLayout = new QVBoxLayout(dockContent); - dockLayout->setContentsMargins(4, 4, 4, 4); - dockLayout->setSpacing(4); + dockLayout->setContentsMargins(6, 6, 6, 6); + dockLayout->setSpacing(0); m_projectTree = new ProjectTreeWidget(dockContent); + m_projectTree->setObjectName(QStringLiteral("ProjectTree")); m_projectTree->setColumnCount(2); m_projectTree->setHeaderHidden(true); m_projectTree->setRootIsDecorated(true); - m_projectTree->setIndentation(14); + m_projectTree->setIndentation(16); m_projectTree->setUniformRowHeights(true); + m_projectTree->setAnimated(true); + m_projectTree->setExpandsOnDoubleClick(true); // 允许拖拽来设置父子关系;真正的父子逻辑由 dropEvent 发信号驱动,不使用默认内部移动 if (m_projectTree->header()) { m_projectTree->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents); @@ -1409,35 +1820,107 @@ void MainWindow::createProjectTreeDock() { auto* treeScroll = new SpinFriendlyScrollArea(dockContent); treeScroll->setWidgetResizable(true); treeScroll->setFrameShape(QFrame::NoFrame); - treeScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); + treeScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); treeScroll->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + treeScroll->setAlignment(Qt::AlignLeft | Qt::AlignTop); treeScroll->setWidget(m_projectTree); - dockLayout->addWidget(treeScroll, 1); + + auto* hotspotAnimPage = new QWidget(dockContent); + auto* hotspotAnimLayout = new QVBoxLayout(hotspotAnimPage); + hotspotAnimLayout->setContentsMargins(0, 0, 0, 0); + hotspotAnimLayout->setSpacing(6); + auto* hotspotAnimHint = new QLabel(QStringLiteral("拖动动画到画布创建热点"), hotspotAnimPage); + hotspotAnimHint->setWordWrap(true); + hotspotAnimLayout->addWidget(hotspotAnimHint); + + m_hotspotAnimationList = new HotspotAnimationListWidget(hotspotAnimPage); + m_hotspotAnimationList->setObjectName(QStringLiteral("PropertyPanelListWidget")); + m_hotspotAnimationList->setMinimumHeight(120); + hotspotAnimLayout->addWidget(m_hotspotAnimationList, 1); + + m_rightTopDockStack = new QStackedWidget(dockContent); + m_rightTopDockStack->addWidget(treeScroll); + m_rightTopDockStack->addWidget(hotspotAnimPage); + dockLayout->addWidget(m_rightTopDockStack, 1); m_dockProjectTree->setWidget(dockContent); m_dockProjectTree->installEventFilter(this); m_dockProperties = new QDockWidget(QStringLiteral("属性"), this); - m_dockProperties->setToolTip(QStringLiteral("在项目树中右键条目可预览或操作背景。")); + m_dockProperties->setToolTip({}); m_dockProperties->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea | Qt::BottomDockWidgetArea | Qt::TopDockWidgetArea); m_dockProperties->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetClosable); - m_dockProperties->setMinimumWidth(200); + m_dockProperties->setMinimumWidth(kRightDockColumnFallbackMinWidth); m_bgPropertySection = new gui::BackgroundPropertySection(); m_blackholePropertySection = new gui::BlackholePropertySection(); m_entityPropertySection = new gui::EntityPropertySection(); m_cameraPropertySection = new gui::CameraPropertySection(); m_toolPropertySection = new gui::ToolPropertySection(); + m_hotspotPropertySection = new gui::HotspotPropertySection(); m_propertyStack = new QStackedWidget(); - m_propertyStack->setContentsMargins(4, 4, 4, 4); - m_propertyStack->setMaximumWidth(kPropertyPanelContentMaxWidth); + m_propertyStack->setObjectName(QStringLiteral("PropertyDockContent")); + m_propertyStack->setContentsMargins(6, 6, 6, 6); m_propertyStack->addWidget(m_bgPropertySection); m_propertyStack->addWidget(m_blackholePropertySection); m_propertyStack->addWidget(m_entityPropertySection); m_propertyStack->addWidget(m_cameraPropertySection); m_propertyStack->addWidget(m_toolPropertySection); + m_propertyStack->addWidget(m_hotspotPropertySection); + + connect(m_hotspotPropertySection, &gui::HotspotPropertySection::targetAnimationChanged, this, + [this](const QString& hotspotId, const QString& animationId) { + QVector hs = m_workspace.project().presentationHotspots(); + for (auto& h : hs) { + if (h.id == hotspotId) { + h.targetAnimationId = animationId; + break; + } + } + m_workspace.applyPresentationHotspots(hs, true, QStringLiteral("热点")); + refreshEditorPage(); + }); + connect(m_hotspotPropertySection, &gui::HotspotPropertySection::hotspotSelected, this, + [this](const QString& hotspotId) { + m_selectedHotspotId = hotspotId; + m_hasSelectedEntity = false; + m_selectedEntityId.clear(); + m_selectedEntityDisplayNameCache.clear(); + m_hasSelectedTool = false; + m_selectedToolId.clear(); + m_hasSelectedCamera = false; + m_selectedCameraId.clear(); + m_selectedBlackholeEntityId.clear(); + if (m_editorCanvas) { + m_editorCanvas->clearEntitySelection(); + m_editorCanvas->clearCameraSelection(); + if (hotspotId.isEmpty()) { + m_editorCanvas->clearPresentationHotspotSelection(); + } else { + m_editorCanvas->setSelectedPresentationHotspotId(hotspotId); + } + } + refreshPropertyPanel(); + syncProjectTreeFromCanvasSelection(); + }); + connect(m_hotspotPropertySection, &gui::HotspotPropertySection::deleteRequested, this, + [this](const QString& hotspotId) { + QVector hs = m_workspace.project().presentationHotspots(); + for (int i = 0; i < hs.size(); ++i) { + if (hs[i].id == hotspotId) { + hs.remove(i); + break; + } + } + m_selectedHotspotId.clear(); + if (m_editorCanvas) { + m_editorCanvas->clearPresentationHotspotSelection(); + } + m_workspace.applyPresentationHotspots(hs, true, QStringLiteral("热点")); + refreshEditorPage(); + }); connect(m_bgPropertySection, &gui::BackgroundPropertySection::backgroundVisibleToggled, this, [this](bool on) { if (!m_workspace.isOpen()) return; @@ -1475,15 +1958,15 @@ void MainWindow::createProjectTreeDock() { }); connect(m_entityPropertySection, &gui::EntityPropertySection::visibleToggled, this, [this](bool on) { if (m_selectedEntityId.isEmpty()) return; - const int f = std::clamp(m_currentFrame, 0, core::Project::kClipFixedFrames - 1); + const int f = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); if (!m_workspace.setEntityVisibilityKey(m_selectedEntityId, f, on)) return; refreshEditorPage(); }); - connect(m_entityPropertySection, &gui::EntityPropertySection::pivotEdited, this, [this](double x, double y) { + connect(m_entityPropertySection, &gui::EntityPropertySection::centerPositionEdited, this, [this](double x, double y) { if (m_selectedEntityId.isEmpty() || !m_editorCanvas) return; const double s = m_editorCanvas->selectedCombinedScale(); if (s <= 1e-9) return; - QPointF targetPivot(x, y); + const int f = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); QString parentId; for (const auto& e : m_workspace.entities()) { if (e.id == m_selectedEntityId) { @@ -1491,54 +1974,31 @@ void MainWindow::createProjectTreeDock() { break; } } - if (!parentId.isEmpty()) { - const auto rf = core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); - for (const auto& pe : rf.entities) { - if (pe.entity.id == parentId) { - targetPivot += pe.entity.originWorld; - break; - } - } - for (const auto& pt : rf.tools) { - if (pt.tool.id == parentId) { - targetPivot += pt.tool.originWorld; - break; - } - } - } - if (!m_workspace.reanchorEntityPivot(m_selectedEntityId, m_currentFrame, targetPivot, s)) return; - refreshEditorPage(); - refreshDopeSheet(); - }); - connect(m_entityPropertySection, &gui::EntityPropertySection::centroidEdited, this, [this](double x, double y) { - if (m_selectedEntityId.isEmpty() || !m_editorCanvas) return; - const double s = m_editorCanvas->selectedCombinedScale(); - if (s <= 1e-9) return; - QPointF targetCentroid(x, y); - QString parentId; - for (const auto& e : m_workspace.entities()) { - if (e.id == m_selectedEntityId) { - parentId = e.parentId; - break; - } - } - if (!parentId.isEmpty()) { - const auto rf = core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); - for (const auto& pe : rf.entities) { - if (pe.entity.id == parentId) { - targetCentroid += pe.entity.originWorld; - break; - } - } - for (const auto& pt : rf.tools) { - if (pt.tool.id == parentId) { - targetCentroid += pt.tool.originWorld; - break; - } - } - } const bool autoKey = m_chkAutoKeyframe && m_chkAutoKeyframe->isChecked(); - if (!m_workspace.moveEntityCentroidTo(m_selectedEntityId, m_currentFrame, targetCentroid, s, autoKey)) return; + QPointF targetCentroid(x, y); + if (!parentId.isEmpty()) { + const QPointF parentC = m_workspace.entityCentroidWorldAtFrame(parentId, f); + targetCentroid = parentC + QPointF(x, y); + } + if (!m_workspace.moveEntityCentroidTo(m_selectedEntityId, f, targetCentroid, s, autoKey)) return; + refreshEditorPage(); + refreshDopeSheet(); + }); + connect(m_entityPropertySection, &gui::EntityPropertySection::relativeToOwnCenterEdited, this, [this](double x, double y) { + if (m_selectedEntityId.isEmpty() || !m_editorCanvas) return; + const double s = m_editorCanvas->selectedCombinedScale(); + if (s <= 1e-9) return; + const int f = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); + const QPointF C = m_workspace.entityCentroidWorldAtFrame(m_selectedEntityId, f); + const QPointF targetPivot = C + QPointF(x, y); + if (!m_workspace.reanchorEntityPivot(m_selectedEntityId, f, targetPivot, s)) return; + refreshEditorPage(); + refreshDopeSheet(); + }); + connect(m_entityPropertySection, &gui::EntityPropertySection::priorityEdited, this, [this](int v) { + if (m_selectedEntityId.isEmpty()) return; + const int f = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); + if (!m_workspace.setEntityPriorityKey(m_selectedEntityId, f, v)) return; refreshEditorPage(); refreshDopeSheet(); }); @@ -1578,7 +2038,40 @@ void MainWindow::createProjectTreeDock() { } m_entityPropertySection->appendIntroImagePath(rel); }); - + connect(m_entityPropertySection, &gui::EntityPropertySection::spriteEditRequested, this, [this]() { + if (m_selectedEntityId.isEmpty() || !m_workspace.isOpen()) { + return; + } + const int f = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); + if (m_workspace.project().activeAnimationId() == QStringLiteral("none")) { + const QString imagePath = QFileDialog::getOpenFileName( + this, + QStringLiteral("选择默认贴图"), + {}, + QStringLiteral("图片 (*.png *.jpg *.jpeg *.webp *.bmp);;所有文件 (*)")); + if (imagePath.isEmpty()) return; + ImageCropDialog crop(imagePath, this); + if (crop.exec() != QDialog::Accepted) return; + const QRect cropRect = crop.hasValidSelection() ? crop.selectedRectInImagePixels() : QRect(); + QImage img = core::image_file::loadImageLimited(imagePath, core::image_decode::kWorkspaceMaxPixels); + if (img.isNull()) return; + if (cropRect.isValid()) { + img = img.copy(cropRect); + } + if (img.format() != QImage::Format_ARGB32_Premultiplied) { + img = img.convertToFormat(QImage::Format_ARGB32_Premultiplied); + } + if (!m_workspace.setEntityDefaultImage(m_selectedEntityId, img)) return; + refreshEditorPage(); + refreshProjectTree(); + refreshDopeSheet(); + return; + } + FrameAnimationDialog dlg(m_workspace, m_selectedEntityId, f, f, this); + dlg.exec(); + refreshEditorPage(); + refreshDopeSheet(); + }); connect(m_toolPropertySection, &gui::ToolPropertySection::textCommitted, this, [this](const QString& text) { if (m_selectedToolId.isEmpty() || !m_workspace.isOpen()) return; m_workspace.setToolText(m_selectedToolId, text); @@ -1603,9 +2096,9 @@ void MainWindow::createProjectTreeDock() { m_workspace.setToolAlign(m_selectedToolId, a); refreshEditorPage(); }); - connect(m_toolPropertySection, &gui::ToolPropertySection::positionEdited, this, [this](double x, double y) { + connect(m_toolPropertySection, &gui::ToolPropertySection::positionEdited, this, [this](int x, int y) { if (m_selectedToolId.isEmpty() || !m_workspace.isOpen()) return; - const int f = std::clamp(m_currentFrame, 0, core::Project::kClipFixedFrames - 1); + const int f = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); const auto rf = core::eval::evaluateAtFrame(m_workspace.project(), f, 10); QPointF currentWorld; QPointF parentWorld; @@ -1636,16 +2129,25 @@ void MainWindow::createProjectTreeDock() { } } } - const QPointF targetWorld = parentId.isEmpty() ? QPointF(x, y) : (parentWorld + QPointF(x, y)); + const QPointF targetWorld = + parentId.isEmpty() ? QPointF(static_cast(x), static_cast(y)) + : (parentWorld + QPointF(static_cast(x), static_cast(y))); const QPointF delta = targetWorld - currentWorld; if (qFuzzyIsNull(delta.x()) && qFuzzyIsNull(delta.y())) return; if (!m_workspace.moveToolBy(m_selectedToolId, delta, f, true)) return; refreshEditorPage(); refreshDopeSheet(); }); + connect(m_toolPropertySection, &gui::ToolPropertySection::priorityEdited, this, [this](int v) { + if (m_selectedToolId.isEmpty() || !m_workspace.isOpen()) return; + const int f = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); + if (!m_workspace.setToolPriorityKey(m_selectedToolId, f, v)) return; + refreshEditorPage(); + refreshDopeSheet(); + }); connect(m_toolPropertySection, &gui::ToolPropertySection::visibleToggled, this, [this](bool on) { if (m_selectedToolId.isEmpty() || !m_workspace.isOpen()) return; - const int f = std::clamp(m_currentFrame, 0, core::Project::kClipFixedFrames - 1); + const int f = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); if (!m_workspace.setToolVisibilityKey(m_selectedToolId, f, on)) return; refreshEditorPage(); }); @@ -1656,14 +2158,15 @@ void MainWindow::createProjectTreeDock() { refreshProjectTree(); refreshPropertyPanel(); }); - connect(m_cameraPropertySection, &gui::CameraPropertySection::centerEdited, this, [this](double x, double y) { + connect(m_cameraPropertySection, &gui::CameraPropertySection::centerEdited, this, [this](int x, int y) { if (m_selectedCameraId.isEmpty() || !m_workspace.isOpen()) return; - if (!m_workspace.setCameraCenterWorld(m_selectedCameraId, QPointF(x, y))) return; + if (!m_workspace.setCameraCenterWorld(m_selectedCameraId, + QPointF(static_cast(x), static_cast(y)))) return; refreshEditorPage(); }); connect(m_cameraPropertySection, &gui::CameraPropertySection::viewScaleEdited, this, [this](double vs) { if (m_selectedCameraId.isEmpty() || !m_workspace.isOpen()) return; - const int f = std::clamp(m_currentFrame, 0, core::Project::kClipFixedFrames - 1); + const int f = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); if (!m_workspace.setCameraViewScaleValue(m_selectedCameraId, vs, f)) return; refreshEditorPage(); refreshDopeSheet(); @@ -1684,8 +2187,9 @@ void MainWindow::createProjectTreeDock() { auto* propScroll = new SpinFriendlyScrollArea(m_dockProperties); propScroll->setWidgetResizable(true); propScroll->setFrameShape(QFrame::NoFrame); - propScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); + propScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); propScroll->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + propScroll->setAlignment(Qt::AlignLeft | Qt::AlignTop); propScroll->setWidget(m_propertyStack); m_dockProperties->setWidget(propScroll); @@ -1694,6 +2198,9 @@ void MainWindow::createProjectTreeDock() { resizeDocks({m_dockProjectTree, m_dockProperties}, {kProjectTreeDockStartupHeight, kPropertiesDockStartupHeight}, Qt::Vertical); + resizeDocks({m_dockProjectTree, m_dockProperties}, + {kRightDockColumnStartupWidth, kRightDockColumnStartupWidth}, + Qt::Horizontal); connect(m_dockProjectTree, &QDockWidget::visibilityChanged, this, [this](bool visible) { if (m_actionToggleProjectTree) { @@ -1710,33 +2217,47 @@ void MainWindow::createProjectTreeDock() { } }); - // 停靠时限制右侧列最大宽度;浮动时解除,避免属性/项目树窗口过窄难用 - const auto applyRightDockColumnMaxWidth = [](QDockWidget* dock) { - if (!dock) { - return; - } - dock->setMaximumWidth(dock->isFloating() ? QWIDGETSIZE_MAX : kRightDockMaximumWidth); - }; - applyRightDockColumnMaxWidth(m_dockProjectTree); - applyRightDockColumnMaxWidth(m_dockProperties); - connect(m_dockProjectTree, &QDockWidget::topLevelChanged, this, - [applyRightDockColumnMaxWidth, this](bool) { - applyRightDockColumnMaxWidth(m_dockProjectTree); - applyRightDockColumnMaxWidth(m_dockProperties); - }); - connect(m_dockProperties, &QDockWidget::topLevelChanged, this, - [applyRightDockColumnMaxWidth, this](bool) { - applyRightDockColumnMaxWidth(m_dockProjectTree); - applyRightDockColumnMaxWidth(m_dockProperties); - }); + connect(m_propertyStack, &QStackedWidget::currentChanged, this, [this](int) { + QTimer::singleShot(0, this, [this]() { updateRightDockColumnMinimumWidthFromContent(); }); + }); + QTimer::singleShot(0, this, [this]() { updateRightDockColumnMinimumWidthFromContent(); }); +} + +void MainWindow::updateRightDockColumnMinimumWidthFromContent() { + if (!m_dockProjectTree || !m_dockProperties || !m_propertyStack) { + return; + } + QWidget* rightTopContent = m_projectTree; + if (m_rightTopDockStack && m_rightTopDockStack->currentWidget()) { + rightTopContent = m_rightTopDockStack->currentWidget(); + } + if (!rightTopContent) { + return; + } + rightTopContent->updateGeometry(); + m_propertyStack->updateGeometry(); + const int hintTree = rightTopContent->minimumSizeHint().width(); + const int hintProp = m_propertyStack->minimumSizeHint().width(); + constexpr int kDockFramePad = 12; + // 属性面板里的数值文本会随内容变化(例如帧号/坐标/比例等),导致 minimumSizeHint 动态变宽。 + // 若把它直接作为 dock 列最小宽度,会出现“数字变化 → 列宽越撑越大”的体验问题。 + // 这里将属性区对列宽的影响上限限制在启动宽度,避免被动态文本推动无限变宽; + // 项目树仍允许按自身最小宽度撑开,防止树项被过度裁切。 + const int hintPropCapped = std::min(hintProp, kRightDockColumnStartupWidth); + const int fromContent = std::max(hintTree, hintPropCapped) + kDockFramePad; + const int w = std::max(kRightDockColumnFallbackMinWidth, fromContent); + m_dockProjectTree->setMinimumWidth(w); + m_dockProperties->setMinimumWidth(w); } void MainWindow::refreshPropertyPanel() { if (!m_bgPropertySection || !m_blackholePropertySection || !m_entityPropertySection || - !m_cameraPropertySection || !m_toolPropertySection || !m_propertyStack) { + !m_cameraPropertySection || !m_toolPropertySection || !m_hotspotPropertySection || !m_propertyStack) { return; } + QTimer::singleShot(0, this, [this]() { updateRightDockColumnMinimumWidthFromContent(); }); + if (!m_workspace.isOpen()) { m_bgPropertySection->setProjectClosedAppearance(); m_bgAbsCache.clear(); @@ -1746,10 +2267,12 @@ void MainWindow::refreshPropertyPanel() { if (bgAbs != m_bgAbsCache) { m_bgAbsCache = bgAbs; if (!bgAbs.isEmpty() && QFileInfo::exists(bgAbs)) { - const QImage img(bgAbs); - m_bgSizeTextCache = - img.isNull() ? QStringLiteral("-") - : QStringLiteral("%1 × %2").arg(img.width()).arg(img.height()); + QSize sz; + if (core::image_file::probeImagePixelSize(bgAbs, &sz)) { + m_bgSizeTextCache = QStringLiteral("%1 × %2").arg(sz.width()).arg(sz.height()); + } else { + m_bgSizeTextCache = QStringLiteral("-"); + } } else { m_bgSizeTextCache = QStringLiteral("-"); } @@ -1765,58 +2288,52 @@ void MainWindow::refreshPropertyPanel() { m_bgPropertySection->syncDepthOverlayChecked(m_editorCanvas->depthOverlayEnabled()); } - auto activeClipForUi = [this]() -> const core::Project::AnimationClip* { - if (!m_workspace.isOpen()) return nullptr; - const auto& allClips = m_workspace.project().animationClips(); - const auto* scheme = m_workspace.project().activeSchemeOrNull(); - const core::Project::AnimationClip* clip = nullptr; - if (scheme) { - const QString stripId = m_workspace.project().selectedStripId(); - const core::Project::NlaStrip* chosenStrip = nullptr; - if (!stripId.isEmpty()) { - for (const auto& tr : scheme->tracks) { - for (const auto& st : tr.strips) { - if (st.id == stripId) { - chosenStrip = &st; - break; - } - } - if (chosenStrip) break; - } - } - if (!chosenStrip) { - for (const auto& tr : scheme->tracks) { - for (const auto& st : tr.strips) { - if (st.enabled && !st.muted) { - chosenStrip = &st; - break; - } - } - if (chosenStrip) break; - } - } - if (chosenStrip) { - clip = m_workspace.project().findClipById(chosenStrip->clipId); - } - } - if (!clip && !allClips.isEmpty()) clip = &allClips.front(); - return clip; - }; - - auto visAtFrame = [](const QVector& keys, int frame, bool defaultVisible) { - if (keys.isEmpty()) return defaultVisible; - bool cur = defaultVisible; + auto boolFromAnimTrackHold = [&](core::Project::AnimationTargetKind kind, + const QString& targetId, + core::Project::AnimationProperty prop, + int frame, + bool fallback) -> bool { + if (!m_workspace.isOpen() || targetId.isEmpty()) return fallback; + const core::Project::Animation* anim = m_workspace.project().activeAnimationOrNull(); + if (!anim) return fallback; + bool cur = fallback; int best = -1; - for (const auto& k : keys) { - if (k.frame <= frame && k.frame >= best) { - best = k.frame; - cur = k.value; + for (const auto& t : anim->tracks) { + if (t.targetKind != kind || t.targetId != targetId || t.property != prop) continue; + for (const auto& k : t.keys) { + if (k.frame <= frame && k.frame >= best) { + best = k.frame; + cur = k.boolValue; + } } } return cur; }; - const bool cameraUi = m_hasSelectedCamera && m_workspace.isOpen() && !m_selectedCameraId.isEmpty(); + const bool hotspotPropUi = m_workspace.isOpen() && m_hotspotRequested && !m_previewRequested; + if (hotspotPropUi) { + m_cameraPropertySection->clearDisconnected(); + bool hasSelectedHotspot = m_selectedHotspotId.isEmpty(); + for (const auto& hotspot : m_workspace.project().presentationHotspots()) { + if (hotspot.id == m_selectedHotspotId) { + hasSelectedHotspot = true; + break; + } + } + if (!hasSelectedHotspot) { + m_selectedHotspotId.clear(); + } + m_hotspotPropertySection->applyState(m_workspace.project().presentationHotspots(), + m_selectedHotspotId, + nonNoneAnimationIdLabelPairsWs(m_workspace.project())); + m_propertyStack->setCurrentWidget(m_hotspotPropertySection); + m_dockProperties->setWindowTitle(QStringLiteral("属性 — 热点")); + return; + } + + const bool cameraUi = currentUiMode() == UiMode::AnimationEditor && + m_workspace.project().activeAnimationId() != QStringLiteral("none") && + m_hasSelectedCamera && m_workspace.isOpen() && !m_selectedCameraId.isEmpty(); if (cameraUi) { m_entityPropertySection->clearDisconnected(); m_toolPropertySection->clearDisconnected(); @@ -1842,8 +2359,7 @@ void MainWindow::refreshPropertyPanel() { if (toolUi) { m_cameraPropertySection->clearDisconnected(); gui::ToolPropertyUiState st; - const int f = std::clamp(m_currentFrame, 0, core::Project::kClipFixedFrames - 1); - const auto* clip = activeClipForUi(); + const int f = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); QString parentId; const auto rf = core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); for (const auto& rt : rf.tools) { @@ -1857,6 +2373,7 @@ void MainWindow::refreshPropertyPanel() { if (t.id == m_selectedToolId) { st.displayName = t.displayName.isEmpty() ? t.id : t.displayName; st.text = t.text; + st.priority = t.priority; { const double x = std::clamp(t.bubblePointerT01, 0.0, 1.0) * 1000.0; st.pointerTThousandths = static_cast(x + 0.5); @@ -1865,11 +2382,12 @@ void MainWindow::refreshPropertyPanel() { st.alignIndex = (t.align == core::Project::Tool::TextAlign::Left) ? 0 : (t.align == core::Project::Tool::TextAlign::Right) ? 2 : 1; - const QVector keys = - (clip && clip->toolVisibilityKeys.contains(t.id)) - ? clip->toolVisibilityKeys.value(t.id) - : QVector{}; - st.visible = visAtFrame(keys, f, t.visible); + // 可见性:以新动画轨道为准(Hold);若无轨道则回退工具静态 visible + st.visible = boolFromAnimTrackHold(core::Project::AnimationTargetKind::Tool, + t.id, + core::Project::AnimationProperty::Visibility, + f, + t.visible); break; } } @@ -1943,9 +2461,9 @@ void MainWindow::refreshPropertyPanel() { bool ignoreDist = false; bool entVisible = true; QString parentId; + int priority = 1; core::EntityIntroContent intro; - const int f = std::clamp(m_currentFrame, 0, core::Project::kClipFixedFrames - 1); - const auto* clip = activeClipForUi(); + const int f = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); for (const auto& e : m_workspace.entities()) { if (e.id == m_selectedEntityId) { displayName = e.displayName; @@ -1953,47 +2471,45 @@ void MainWindow::refreshPropertyPanel() { intro = e.intro; ignoreDist = e.ignoreDistanceScale; parentId = e.parentId; - const QVector keys = - (clip && clip->entityVisibilityKeys.contains(e.id)) - ? clip->entityVisibilityKeys.value(e.id) - : QVector{}; - entVisible = visAtFrame(keys, f, e.visible); + priority = e.priority; + // 可见性:以新动画轨道为准(Hold);若无轨道则回退实体静态 visible + entVisible = boolFromAnimTrackHold(core::Project::AnimationTargetKind::Entity, + e.id, + core::Project::AnimationProperty::Visibility, + f, + e.visible); break; } } m_selectedEntityDisplayNameCache = displayName; + const QPointF centroidW = m_editorCanvas->selectedEntityCentroidWorld(); + const QPointF pivotW = m_editorCanvas->selectedEntityPivotWorld(); + gui::EntityPropertyUiState st; st.displayName = displayName.isEmpty() ? m_selectedEntityId : displayName; st.depthZ = m_selectedEntityDepth; st.distanceScaleText = QStringLiteral("%1(自动)").arg(m_editorCanvas->selectedDistanceScaleMultiplier(), 0, 'f', 3); - st.pivot = m_editorCanvas->selectedAnimatedOriginWorld(); - st.centroid = m_editorCanvas->selectedEntityCentroidWorld(); - if (!parentId.isEmpty()) { - QPointF parentWorld; - const auto rf = core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); - for (const auto& pe : rf.entities) { - if (pe.entity.id == parentId) { - parentWorld = pe.entity.originWorld; - break; - } - } - if (qFuzzyIsNull(parentWorld.x()) && qFuzzyIsNull(parentWorld.y())) { - for (const auto& pt : rf.tools) { - if (pt.tool.id == parentId) { - parentWorld = pt.tool.originWorld; - break; - } - } - } - st.pivot -= parentWorld; - st.centroid -= parentWorld; - st.parentRelativeMode = true; - } + st.hasParent = !parentId.isEmpty(); + st.centerPosition = + st.hasParent ? (centroidW - m_workspace.entityCentroidWorldAtFrame(parentId, f)) : centroidW; + st.relativeToOwnCenter = pivotW - centroidW; + st.priority = priority; st.userScale = userScale; st.ignoreDistanceScale = ignoreDist; st.visible = entVisible; + { + const QByteArray png = m_workspace.entityImagePngAt(m_selectedEntityId, f); + if (!png.isEmpty()) { + QImage img; + img.loadFromData(png, "PNG"); + if (!img.isNull() && img.format() != QImage::Format_ARGB32_Premultiplied) { + img = img.convertToFormat(QImage::Format_ARGB32_Premultiplied); + } + st.spritePreview = img; + } + } st.intro = intro; m_entityPropertySection->applyState(st); m_propertyStack->setCurrentWidget(m_entityPropertySection); @@ -2008,43 +2524,42 @@ void MainWindow::refreshEntityPropertyPanelFast() { if (!entUi) { return; } + const int f = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); + QString parentId; + int priority = 1; + for (const auto& e : m_workspace.entities()) { + if (e.id == m_selectedEntityId) { + parentId = e.parentId; + priority = e.priority; + break; + } + } + const QPointF centroidW = m_editorCanvas->selectedEntityCentroidWorld(); + const QPointF pivotW = m_editorCanvas->selectedEntityPivotWorld(); + gui::EntityPropertyUiState st; const QString dn = m_selectedEntityDisplayNameCache; st.displayName = dn.isEmpty() ? m_selectedEntityId : dn; st.depthZ = m_selectedEntityDepth; st.distanceScaleText = QStringLiteral("%1(自动)").arg(m_editorCanvas->selectedDistanceScaleMultiplier(), 0, 'f', 3); - st.pivot = m_editorCanvas->selectedAnimatedOriginWorld(); - st.centroid = m_editorCanvas->selectedEntityCentroidWorld(); - QString parentId; - for (const auto& e : m_workspace.entities()) { - if (e.id == m_selectedEntityId) { - parentId = e.parentId; - break; - } - } - if (!parentId.isEmpty()) { - QPointF parentWorld; - const auto rf = core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); - for (const auto& pe : rf.entities) { - if (pe.entity.id == parentId) { - parentWorld = pe.entity.originWorld; - break; - } - } - if (qFuzzyIsNull(parentWorld.x()) && qFuzzyIsNull(parentWorld.y())) { - for (const auto& pt : rf.tools) { - if (pt.tool.id == parentId) { - parentWorld = pt.tool.originWorld; - break; - } - } - } - st.pivot -= parentWorld; - st.centroid -= parentWorld; - st.parentRelativeMode = true; - } + st.hasParent = !parentId.isEmpty(); + st.centerPosition = + st.hasParent ? (centroidW - m_workspace.entityCentroidWorldAtFrame(parentId, f)) : centroidW; + st.relativeToOwnCenter = pivotW - centroidW; + st.priority = priority; st.userScale = m_editorCanvas->selectedUserScale(); + { + const QByteArray png = m_workspace.entityImagePngAt(m_selectedEntityId, f); + if (!png.isEmpty()) { + QImage img; + img.loadFromData(png, "PNG"); + if (!img.isNull() && img.format() != QImage::Format_ARGB32_Premultiplied) { + img = img.convertToFormat(QImage::Format_ARGB32_Premultiplied); + } + st.spritePreview = img; + } + } // ignoreDistanceScale 在拖动中不变更,fast path 不必更新(避免再遍历 entities) m_entityPropertySection->applyState(st); // 拖动中不切换 stack、不改 dock 标题,避免多余布局开销 @@ -2054,6 +2569,19 @@ void MainWindow::refreshProjectTree() { if (!m_projectTree) { return; } + const bool hotspotPanel = m_workspace.isOpen() && currentUiMode() == UiMode::HotspotEditor; + if (m_hotspotAnimationList) { + static_cast(m_hotspotAnimationList) + ->setAnimations(m_workspace.isOpen() ? nonNoneAnimationIdLabelPairsWs(m_workspace.project()) + : QVector>{}); + } + if (m_rightTopDockStack) { + m_rightTopDockStack->setCurrentIndex(hotspotPanel ? 1 : 0); + } + if (m_dockProjectTree) { + m_dockProjectTree->setWindowTitle(hotspotPanel ? QStringLiteral("动画面板") + : QStringLiteral("项目树")); + } m_projectTree->clear(); const int iconPm = style()->pixelMetric(QStyle::PM_SmallIconSize); @@ -2068,39 +2596,32 @@ void MainWindow::refreshProjectTree() { // “眼睛”按钮(固定尺寸,各行一致);canvasTempOnly 表示仅画布临时显隐,与属性里工程可见性无关 auto makeEye = [this, eyeSide, iconPm](bool visible, bool canvasTempOnly = false) -> QToolButton* { auto* btn = new QToolButton(m_projectTree); + btn->setObjectName(QStringLiteral("ProjectTreeEyeButton")); btn->setFixedSize(eyeSide, eyeSide); btn->setIconSize(QSize(iconPm, iconPm)); btn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); btn->setAutoRaise(true); btn->setCheckable(true); btn->setChecked(visible); - if (canvasTempOnly) { - btn->setToolTip(visible ? QStringLiteral("画布显示中。点击可仅在此视图中暂时隐藏(工程可见性在「属性」)") - : QStringLiteral("画布已暂时隐藏。点击恢复显示(工程可见性在「属性」)")); - } else { - btn->setToolTip(visible ? QStringLiteral("隐藏") : QStringLiteral("显示")); - } + btn->setToolTip({}); // 尽量用主题图标,失败则退化为文本 const QIcon onIcon = QIcon::fromTheme(QStringLiteral("view-visible")); const QIcon offIcon = QIcon::fromTheme(QStringLiteral("view-hidden")); if (!onIcon.isNull() && !offIcon.isNull()) { btn->setIcon(visible ? onIcon : offIcon); } else { - btn->setText(visible ? QStringLiteral("👁") : QStringLiteral("×")); + // 某些 Linux 环境没有主题图标/emoji 字体,使用明确的文字回退,保证可见。 + btn->setText(visible ? QStringLiteral("显") : QStringLiteral("隐")); } connect(btn, &QToolButton::toggled, this, [btn, canvasTempOnly](bool on) { - if (canvasTempOnly) { - btn->setToolTip(on ? QStringLiteral("画布显示中。点击可仅在此视图中暂时隐藏(工程可见性在「属性」)") - : QStringLiteral("画布已暂时隐藏。点击恢复显示(工程可见性在「属性」)")); - } else { - btn->setToolTip(on ? QStringLiteral("隐藏") : QStringLiteral("显示")); - } + Q_UNUSED(canvasTempOnly); + btn->setToolTip({}); const QIcon visIcon = QIcon::fromTheme(QStringLiteral("view-visible")); const QIcon hidIcon = QIcon::fromTheme(QStringLiteral("view-hidden")); if (!visIcon.isNull() && !hidIcon.isNull()) { btn->setIcon(on ? visIcon : hidIcon); } else { - btn->setText(on ? QStringLiteral("👁") : QStringLiteral("×")); + btn->setText(on ? QStringLiteral("显") : QStringLiteral("隐")); } }); return btn; @@ -2160,7 +2681,7 @@ void MainWindow::refreshProjectTree() { const QString holeName = e->blackholeId.isEmpty() ? QStringLiteral("blackhole-%1").arg(e->id) : e->blackholeId; it->setText(1, QStringLiteral("黑洞 · %1").arg(base)); - it->setToolTip(1, QStringLiteral("节点:%1").arg(holeName)); + it->setToolTip(1, {}); it->setTextAlignment(1, Qt::AlignRight | Qt::AlignVCenter); it->setData(0, Qt::UserRole, QStringLiteral("blackhole")); it->setData(0, Qt::UserRole + 1, e->id); // 绑定实体 id,便于定位 cutout 多边形 @@ -2255,7 +2776,9 @@ void MainWindow::refreshProjectTree() { addSubtree(QString(), nullptr); - if (m_workspace.isOpen()) { + if (m_workspace.isOpen() && + currentUiMode() == UiMode::AnimationEditor && + m_workspace.project().activeAnimationId() != QStringLiteral("none")) { for (const auto& c : m_workspace.cameras()) { auto* it = new QTreeWidgetItem(m_projectTree); it->setText(1, c.displayName.isEmpty() ? QStringLiteral("摄像机") : c.displayName); @@ -2284,6 +2807,7 @@ void MainWindow::refreshProjectTree() { m_projectTree->header()->setSectionResizeMode(1, QHeaderView::Stretch); } syncProjectTreeFromCanvasSelection(); + QTimer::singleShot(0, this, [this]() { updateRightDockColumnMinimumWidthFromContent(); }); } void MainWindow::syncProjectTreeFromCanvasSelection() { @@ -2442,6 +2966,9 @@ void MainWindow::updateUiEnabledState() { if (!(projectOpen && hasBg) && m_previewRequested) { m_previewRequested = false; } + if (!(projectOpen && hasBg) && m_hotspotRequested) { + m_hotspotRequested = false; + } // 背景为空白时:禁止除“设置背景”外的其它操作 if (m_actionUndo) m_actionUndo->setEnabled(projectOpen && hasBg && m_workspace.canUndo()); @@ -2450,6 +2977,7 @@ void MainWindow::updateUiEnabledState() { if (m_actionPaste) m_actionPaste->setEnabled(projectOpen && hasBg); if (m_actionEnterPreview) m_actionEnterPreview->setEnabled(projectOpen && hasBg && !m_previewRequested); if (m_actionBackToEditor) m_actionBackToEditor->setEnabled(projectOpen && m_previewRequested); + if (m_actionAnimationSettings) m_actionAnimationSettings->setEnabled(projectOpen && hasBg); if (m_btnToggleDepthOverlay) m_btnToggleDepthOverlay->setEnabled(hasDepth); if (m_bgPropertySection) m_bgPropertySection->setDepthOverlayCheckEnabled(hasDepth); // 创建实体不强依赖深度(无深度时 depth=0),但更推荐先算深度 @@ -2477,11 +3005,27 @@ void MainWindow::updateUiEnabledState() { if (m_modeSelector) { m_modeSelector->setEnabled(projectOpen); m_modeSelector->blockSignals(true); - m_modeSelector->setCurrentIndex(m_previewRequested ? 1 : 0); + int mix = 0; + if (m_previewRequested) + mix = 3; + else if (m_animationRequested) + mix = 1; + else if (m_hotspotRequested) + mix = 2; + m_modeSelector->setCurrentIndex(mix); m_modeSelector->setItemData(0, projectOpen ? QVariant() : QVariant(0), Qt::UserRole - 1); - m_modeSelector->setItemData(1, (projectOpen && hasBg) ? QVariant() : QVariant(0), Qt::UserRole - 1); + m_modeSelector->setItemData(1, projectOpen ? QVariant() : QVariant(0), Qt::UserRole - 1); + m_modeSelector->setItemData(2, (projectOpen && hasBg) ? QVariant() : QVariant(0), Qt::UserRole - 1); + m_modeSelector->setItemData(3, (projectOpen && hasBg) ? QVariant() : QVariant(0), Qt::UserRole - 1); m_modeSelector->blockSignals(false); } + if (m_previewBackToCanvasBtn) { + m_previewBackToCanvasBtn->setVisible(m_previewRequested && projectOpen && + effectivePreviewAnimationId() != QStringLiteral("none")); + } + if (projectOpen && hasBg) { + syncHotspotEnterPreviewCombo(); + } // 统一套用两态 UI 策略(欢迎/编辑) applyUiMode(currentUiMode()); @@ -2529,12 +3073,21 @@ MainWindow::UiMode MainWindow::currentUiMode() const { if (m_previewRequested && m_workspace.hasBackground()) { return UiMode::Preview; } - return UiMode::Editor; + if (m_animationRequested) { + return UiMode::AnimationEditor; + } + if (m_hotspotRequested && m_workspace.hasBackground()) { + return UiMode::HotspotEditor; + } + return UiMode::EntityEditor; } void MainWindow::applyUiMode(UiMode mode) { - const bool projectOpen = (mode == UiMode::Editor || mode == UiMode::Preview); + const bool projectOpen = + (mode == UiMode::EntityEditor || mode == UiMode::AnimationEditor || + mode == UiMode::HotspotEditor || mode == UiMode::Preview); const bool preview = (mode == UiMode::Preview); + const bool animationEditor = (mode == UiMode::AnimationEditor); // 中央页面:欢迎 / 工作区(编辑与预览共用同一画布,仅状态不同) if (!projectOpen) { @@ -2546,7 +3099,7 @@ void MainWindow::applyUiMode(UiMode mode) { // Dock 显隐策略: // - Welcome:所有 dock 必须隐藏,确保“未打开项目时只显示欢迎界面” - // - Editor:按照默认规则显示(当前只有项目树) + // - Entity/Animation/Hotspot:显示编辑相关 dock // - Preview:默认隐藏 dock,提供“纯展示”视图 if (m_dockProjectTree) { if (!projectOpen || preview) { @@ -2570,7 +3123,7 @@ void MainWindow::applyUiMode(UiMode mode) { } } if (m_dockTimeline) { - m_dockTimeline->setVisible(projectOpen && !preview); + m_dockTimeline->setVisible(projectOpen && !preview && animationEditor); } if (m_dockResourceLibrary) { // Preview 维持“纯展示”,Welcome 也隐藏;Editor 允许用户手动打开 @@ -2585,9 +3138,18 @@ void MainWindow::applyUiMode(UiMode mode) { if (m_floatingToolDock) { m_floatingToolDock->setVisible(projectOpen && !preview); } + syncEditorRailForMode(); if (m_previewPlaybackBar) { m_previewPlaybackBar->setVisible(projectOpen && preview); } + if (!preview || !projectOpen) { + if (m_previewArrowLeft) { + m_previewArrowLeft->hide(); + } + if (m_previewArrowRight) { + m_previewArrowRight->hide(); + } + } if (m_canvasHost) { m_canvasHost->updateGeometry(); static_cast(m_canvasHost)->relayoutFloaters(); @@ -2611,9 +3173,10 @@ void MainWindow::applyUiMode(UiMode mode) { m_actionToggleProperties->blockSignals(false); } if (m_actionToggleTimeline) { - m_actionToggleTimeline->setEnabled(projectOpen && !preview); + m_actionToggleTimeline->setEnabled(projectOpen && !preview && animationEditor); m_actionToggleTimeline->blockSignals(true); - m_actionToggleTimeline->setChecked(projectOpen && !preview && m_dockTimeline && m_dockTimeline->isVisible()); + m_actionToggleTimeline->setChecked(projectOpen && !preview && animationEditor && + m_dockTimeline && m_dockTimeline->isVisible()); m_actionToggleTimeline->blockSignals(false); } if (m_actionToggleResourceLibrary) { @@ -2657,8 +3220,24 @@ void MainWindow::showProjectRootContextMenu(const QPoint& globalPos) { return; } if (chosen == actPreview) { + m_animationRequested = false; + m_hotspotRequested = false; + m_previewAnimationId = QStringLiteral("none"); + m_previewFrame = 0; + if (m_modeSelector) { + m_modeSelector->blockSignals(true); + m_modeSelector->setCurrentIndex(3); + m_modeSelector->blockSignals(false); + } setPreviewRequested(true); } else if (chosen == actBack) { + m_animationRequested = false; + m_hotspotRequested = false; + if (m_modeSelector) { + m_modeSelector->blockSignals(true); + m_modeSelector->setCurrentIndex(0); + m_modeSelector->blockSignals(false); + } setPreviewRequested(false); } } @@ -2672,52 +3251,59 @@ void MainWindow::rebuildCentralPages() { shellLayout->setSpacing(0); shellLayout->addWidget(m_centerStack, 1); - // 欢迎页(左:操作说明;右:最近项目,类似 Qt Creator) + // 欢迎页(两张 card:左侧快速开始 + 右侧最近项目) m_pageWelcome = new QWidget(m_centerStack); + m_pageWelcome->setObjectName(QStringLiteral("WelcomePage")); auto* welcomeRoot = new QHBoxLayout(m_pageWelcome); - welcomeRoot->setContentsMargins(40, 40, 40, 40); - welcomeRoot->setSpacing(32); + welcomeRoot->setContentsMargins(28, 28, 28, 28); + welcomeRoot->setSpacing(18); - auto* welcomeLeft = new QVBoxLayout(); - welcomeLeft->setSpacing(16); + auto* leftCard = new QFrame(m_pageWelcome); + leftCard->setObjectName(QStringLiteral("WelcomeCard")); + leftCard->setFrameShape(QFrame::NoFrame); + leftCard->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); + leftCard->setMinimumWidth(360); + leftCard->setMaximumWidth(460); + auto* leftOuter = new QVBoxLayout(leftCard); + leftOuter->setContentsMargins(18, 18, 18, 18); + leftOuter->setSpacing(12); - auto* title = new QLabel(QStringLiteral("欢迎使用"), m_pageWelcome); - QFont f = title->font(); - f.setPointSize(f.pointSize() + 6); - f.setBold(true); - title->setFont(f); - welcomeLeft->addWidget(title); + auto* title = new QLabel(QStringLiteral("欢迎使用"), leftCard); + title->setObjectName(QStringLiteral("WelcomeTitle")); + leftOuter->addWidget(title); - auto* desc = new QLabel(QStringLiteral("请创建或打开一个项目。"), m_pageWelcome); + auto* desc = new QLabel(QStringLiteral("创建或打开一个项目以开始编辑。"), leftCard); + desc->setObjectName(QStringLiteral("WelcomeDesc")); desc->setWordWrap(true); - welcomeLeft->addWidget(desc); + leftOuter->addWidget(desc); auto* buttonsRow = new QHBoxLayout(); - auto* btnCreate = new QPushButton(QStringLiteral("创建项目"), m_pageWelcome); - auto* btnOpen = new QPushButton(QStringLiteral("打开项目"), m_pageWelcome); + buttonsRow->setSpacing(10); + auto* btnCreate = new QPushButton(QStringLiteral("创建项目"), leftCard); + btnCreate->setObjectName(QStringLiteral("WelcomePrimaryButton")); + auto* btnOpen = new QPushButton(QStringLiteral("打开项目"), leftCard); buttonsRow->addWidget(btnCreate); buttonsRow->addWidget(btnOpen); buttonsRow->addStretch(1); - welcomeLeft->addLayout(buttonsRow); - welcomeLeft->addStretch(1); + leftOuter->addLayout(buttonsRow); + leftOuter->addStretch(1); connect(btnCreate, &QPushButton::clicked, this, &MainWindow::onNewProject); connect(btnOpen, &QPushButton::clicked, this, &MainWindow::onOpenProject); auto* recentFrame = new QFrame(m_pageWelcome); - recentFrame->setFrameShape(QFrame::StyledPanel); + recentFrame->setObjectName(QStringLiteral("WelcomeCard")); + recentFrame->setFrameShape(QFrame::NoFrame); auto* recentOuter = new QVBoxLayout(recentFrame); - recentOuter->setContentsMargins(16, 16, 16, 16); - recentOuter->setSpacing(8); + recentOuter->setContentsMargins(18, 18, 18, 18); + recentOuter->setSpacing(10); auto* recentTitle = new QLabel(QStringLiteral("最近的项目"), recentFrame); - QFont rf = recentTitle->font(); - rf.setBold(true); - recentTitle->setFont(rf); + recentTitle->setObjectName(QStringLiteral("WelcomeSectionTitle")); recentOuter->addWidget(recentTitle); m_welcomeRecentEmptyLabel = new QLabel(QStringLiteral("暂无最近打开的项目"), recentFrame); - m_welcomeRecentEmptyLabel->setStyleSheet(QStringLiteral("QLabel { color: palette(mid); }")); + m_welcomeRecentEmptyLabel->setObjectName(QStringLiteral("WelcomeHint")); m_welcomeRecentEmptyLabel->setWordWrap(true); recentOuter->addWidget(m_welcomeRecentEmptyLabel); @@ -2761,7 +3347,7 @@ void MainWindow::rebuildCentralPages() { } }); - welcomeRoot->addLayout(welcomeLeft, 1); + welcomeRoot->addWidget(leftCard, 0); welcomeRoot->addWidget(recentFrame, 1); // 工作区:全屏画布 + 左上角浮动模式切换 + 左侧浮动工具栏 @@ -2774,49 +3360,107 @@ void MainWindow::rebuildCentralPages() { m_entityIntroPopup = new gui::EntityIntroPopup(this); + m_previewArrowLeft = new QToolButton(canvasHost); + m_previewArrowLeft->setText(QStringLiteral("◀")); + m_previewArrowLeft->setToolTip({}); + polishCompactToolButton(m_previewArrowLeft, 44); + m_previewArrowLeft->setStyleSheet( + QStringLiteral("QToolButton { background-color: rgba(40,40,40,168); border-radius: 8px; color: palette(text); " + "padding: 4px 8px; } QToolButton:hover { background-color: rgba(60,60,60,208); }")); + m_previewArrowLeft->hide(); + connect(m_previewArrowLeft, &QToolButton::clicked, this, + [this]() { switchPreviewSchemeAdjacent(-1); }); + + m_previewArrowRight = new QToolButton(canvasHost); + m_previewArrowRight->setText(QStringLiteral("▶")); + m_previewArrowRight->setToolTip({}); + polishCompactToolButton(m_previewArrowRight, 44); + m_previewArrowRight->setStyleSheet( + QStringLiteral("QToolButton { background-color: rgba(40,40,40,168); border-radius: 8px; color: palette(text); " + "padding: 4px 8px; } QToolButton:hover { background-color: rgba(60,60,60,208); }")); + m_previewArrowRight->hide(); + connect(m_previewArrowRight, &QToolButton::clicked, this, + [this]() { switchPreviewSchemeAdjacent(1); }); + + canvasHost->previewArrowLeft = m_previewArrowLeft; + canvasHost->previewArrowRight = m_previewArrowRight; + m_previewPlaybackBar = new QFrame(canvasHost); m_previewPlaybackBar->setObjectName(QStringLiteral("PreviewPlaybackBar")); m_previewPlaybackBar->setStyleSheet(QString::fromUtf8(kTimelineBarQss)); auto* pbl = new QHBoxLayout(m_previewPlaybackBar); pbl->setContentsMargins(10, 6, 10, 6); pbl->setSpacing(8); + m_previewSchemeCombo = new QComboBox(m_previewPlaybackBar); + m_previewSchemeCombo->setMinimumWidth(160); + pbl->addWidget(m_previewSchemeCombo); m_previewBtnPlay = new QToolButton(m_previewPlaybackBar); + m_previewBtnPlay->setCheckable(false); m_previewBtnPlay->setText(QStringLiteral("播放")); - m_previewBtnPlay->setToolTip(QStringLiteral("播放时间轴")); - m_previewBtnPause = new QToolButton(m_previewPlaybackBar); - m_previewBtnPause->setText(QStringLiteral("暂停")); - m_previewBtnPause->setToolTip(QStringLiteral("暂停时间轴")); + m_previewBtnPlay->setToolTip({}); polishCompactToolButton(m_previewBtnPlay, 36); - polishCompactToolButton(m_previewBtnPause, 36); pbl->addWidget(m_previewBtnPlay); - pbl->addWidget(m_previewBtnPause); m_previewPlaybackBar->setParent(canvasHost); - m_previewPlaybackBar->hide(); - canvasHost->previewPlaybackBar = m_previewPlaybackBar; - - m_floatingModeDock = new QFrame(canvasHost); - m_floatingModeDock->setObjectName(QStringLiteral("FloatingModeDock")); - m_floatingModeDock->setFrameShape(QFrame::NoFrame); - m_floatingModeDock->setStyleSheet(QString::fromUtf8(kFloatingModeDockQss)); - auto* modeDockLayout = new QHBoxLayout(m_floatingModeDock); - modeDockLayout->setContentsMargins(8, 4, 10, 4); - modeDockLayout->setSpacing(0); - m_modeSelector = new QComboBox(m_floatingModeDock); - m_modeSelector->addItem(QStringLiteral("编辑")); - m_modeSelector->addItem(QStringLiteral("预览")); - { - QFontMetrics fm(m_modeSelector->font()); - int textW = 0; - for (int i = 0; i < m_modeSelector->count(); ++i) { - textW = std::max(textW, fm.horizontalAdvance(m_modeSelector->itemText(i))); +cod setPreviewRequested(false); + if (m_workspace.isOpen() && + m_workspace.project().activeAnimationId() != QStringLiteral("none")) { + m_workspace.project().setActiveAnimationId(QStringLiteral("none")); + m_workspace.save(); + } + if (m_editorCanvas) { + m_editorCanvas->setTool(EditorCanvas::Tool::Move); + m_editorCanvas->clearCameraSelection(); + } + if (m_railBtnMove) { + m_railBtnMove->setChecked(true); + } + } else if (index == 1) { + m_animationRequested = true; + m_hotspotRequested = false; + setPreviewRequested(false); + if (m_workspace.isOpen() && + m_workspace.project().activeAnimationId() == QStringLiteral("none")) { + const QString firstAnim = firstAnimationSchemeIdNonNoneWs(m_workspace.project()); + if (!firstAnim.isEmpty()) { + m_workspace.project().setActiveAnimationId(firstAnim); + m_workspace.save(); + } + } + if (m_editorCanvas) { + m_editorCanvas->setTool(EditorCanvas::Tool::Move); + } + if (m_railBtnMove) { + m_railBtnMove->setChecked(true); + } + } else if (index == 2) { + m_animationRequested = false; + m_hotspotRequested = true; + setPreviewRequested(false); + if (m_workspace.isOpen() && + m_workspace.project().activeAnimationId() != QStringLiteral("none")) { + m_workspace.project().setActiveAnimationId(QStringLiteral("none")); + m_workspace.save(); + } + syncHotspotEnterPreviewCombo(); + if (m_editorCanvas) { + m_editorCanvas->clearPresentationEntityFocus(); + m_editorCanvas->setTool(EditorCanvas::Tool::Move); + m_editorCanvas->clearCameraSelection(); + } + if (m_railBtnMove) { + m_railBtnMove->setChecked(true); + } + } else { + m_animationRequested = false; + m_hotspotRequested = false; + m_previewAnimationId = QStringLiteral("none"); + m_previewFrame = 0; + setPreviewRequested(true); } - const int indicator = - m_modeSelector->style()->pixelMetric(QStyle::PM_ScrollBarExtent, nullptr, m_modeSelector); - // 文本 + 下拉箭头 + QSS 水平 padding(约 6+6)与边框余量 - m_modeSelector->setFixedWidth(textW + indicator + 14); - } - connect(m_modeSelector, &QComboBox::currentIndexChanged, this, [this](int index) { - setPreviewRequested(index == 1); + syncEditorRailForMode(); + updateUiEnabledState(); + refreshEditorPage(); + refreshProjectTree(); }); modeDockLayout->addWidget(m_modeSelector); canvasHost->modeDock = m_floatingModeDock; @@ -2825,36 +3469,36 @@ void MainWindow::rebuildCentralPages() { m_floatingToolDock->setObjectName(QStringLiteral("EditorToolRail")); m_floatingToolDock->setFrameShape(QFrame::NoFrame); m_floatingToolDock->setStyleSheet(QString::fromUtf8(kEditorToolRailQss)); - m_floatingToolDock->setFixedWidth(52); + m_floatingToolDock->setFixedWidth(40); auto* toolLayout = new QVBoxLayout(m_floatingToolDock); - toolLayout->setContentsMargins(6, 8, 6, 8); - toolLayout->setSpacing(6); + toolLayout->setContentsMargins(1, 2, 1, 2); + toolLayout->setSpacing(2); auto* group = new QButtonGroup(m_floatingToolDock); group->setExclusive(true); - auto* btnMove = new QToolButton(m_floatingToolDock); - btnMove->setCheckable(true); - btnMove->setChecked(true); - setToolButtonIconOrText(btnMove, QStringLiteral("transform-move"), QStringLiteral("移")); - btnMove->setToolTip(QStringLiteral("移动")); - polishCompactToolButton(btnMove, 40); - toolLayout->addWidget(btnMove, 0, Qt::AlignHCenter); - group->addButton(btnMove, static_cast(EditorCanvas::Tool::Move)); + m_railBtnMove = new QToolButton(m_floatingToolDock); + m_railBtnMove->setCheckable(true); + m_railBtnMove->setChecked(true); + setToolButtonIconOrText(m_railBtnMove, QStringLiteral("transform-move"), QStringLiteral("移")); + m_railBtnMove->setToolTip({}); + polishCompactToolButton(m_railBtnMove, 30); + toolLayout->addWidget(m_railBtnMove, 0, Qt::AlignHCenter); + group->addButton(m_railBtnMove, static_cast(EditorCanvas::Tool::Move)); - auto* btnZoom = new QToolButton(m_floatingToolDock); - btnZoom->setCheckable(true); - setToolButtonIconOrText(btnZoom, QStringLiteral("zoom-in"), QStringLiteral("放")); - btnZoom->setToolTip(QStringLiteral("缩放")); - polishCompactToolButton(btnZoom, 40); - toolLayout->addWidget(btnZoom, 0, Qt::AlignHCenter); - group->addButton(btnZoom, static_cast(EditorCanvas::Tool::Zoom)); + m_railBtnZoom = new QToolButton(m_floatingToolDock); + m_railBtnZoom->setCheckable(true); + setToolButtonIconOrText(m_railBtnZoom, QStringLiteral("zoom-in"), QStringLiteral("放")); + m_railBtnZoom->setToolTip({}); + polishCompactToolButton(m_railBtnZoom, 30); + toolLayout->addWidget(m_railBtnZoom, 0, Qt::AlignHCenter); + group->addButton(m_railBtnZoom, static_cast(EditorCanvas::Tool::Zoom)); m_btnCreateEntity = new QToolButton(m_floatingToolDock); m_btnCreateEntity->setCheckable(true); setToolButtonIconOrText(m_btnCreateEntity, QStringLiteral("draw-brush"), QStringLiteral("创")); - m_btnCreateEntity->setToolTip(QStringLiteral("创建实体")); - polishCompactToolButton(m_btnCreateEntity, 40); + m_btnCreateEntity->setToolTip({}); + polishCompactToolButton(m_btnCreateEntity, 30); toolLayout->addWidget(m_btnCreateEntity, 0, Qt::AlignHCenter); group->addButton(m_btnCreateEntity, static_cast(EditorCanvas::Tool::CreateEntity)); if (!m_createEntityPopup) { @@ -2868,7 +3512,6 @@ void MainWindow::rebuildCentralPages() { if (!m_editorCanvas) return; m_editorCanvas->setEntityCreateSegmentMode(static_cast(id)); syncCreateEntityToolButtonTooltip(); - statusBar()->showMessage(QStringLiteral("已切换分割方式")); }); } connect(m_btnCreateEntity, &QToolButton::clicked, this, [this]() { @@ -2886,21 +3529,48 @@ void MainWindow::rebuildCentralPages() { m_btnToggleDepthOverlay->setCheckable(true); m_btnToggleDepthOverlay->setChecked(false); setToolButtonIconOrText(m_btnToggleDepthOverlay, QStringLiteral("color-profile"), QStringLiteral("深")); - m_btnToggleDepthOverlay->setToolTip(QStringLiteral("深度叠加")); - polishCompactToolButton(m_btnToggleDepthOverlay, 40); + m_btnToggleDepthOverlay->setToolTip({}); + polishCompactToolButton(m_btnToggleDepthOverlay, 30); toolLayout->addWidget(m_btnToggleDepthOverlay, 0, Qt::AlignHCenter); - auto* btnFit = new QToolButton(m_floatingToolDock); - setToolButtonIconOrText(btnFit, QStringLiteral("zoom-fit-best"), QStringLiteral("框")); - btnFit->setToolTip(QStringLiteral("适配视口")); - polishCompactToolButton(btnFit, 40); - toolLayout->addWidget(btnFit, 0, Qt::AlignHCenter); + m_railBtnFit = new QToolButton(m_floatingToolDock); + setToolButtonIconOrText(m_railBtnFit, QStringLiteral("zoom-fit-best"), QStringLiteral("框")); + m_railBtnFit->setToolTip({}); + polishCompactToolButton(m_railBtnFit, 30); + toolLayout->addWidget(m_railBtnFit, 0, Qt::AlignHCenter); + + m_btnRailAddHotspot = new QToolButton(m_floatingToolDock); + m_btnRailAddHotspot->setCheckable(true); + setToolButtonIconOrText(m_btnRailAddHotspot, QStringLiteral("list-add"), QStringLiteral("+")); + m_btnRailAddHotspot->setToolTip({}); + polishCompactToolButton(m_btnRailAddHotspot, 30); + toolLayout->addWidget(m_btnRailAddHotspot, 0, Qt::AlignHCenter); + group->addButton(m_btnRailAddHotspot, static_cast(EditorCanvas::Tool::AddHotspot)); + + m_btnRailMoveHotspot = new QToolButton(m_floatingToolDock); + m_btnRailMoveHotspot->setCheckable(true); + setToolButtonIconOrText(m_btnRailMoveHotspot, QStringLiteral("transform-move"), QStringLiteral("点")); + m_btnRailMoveHotspot->setToolTip({}); + polishCompactToolButton(m_btnRailMoveHotspot, 30); + toolLayout->addWidget(m_btnRailMoveHotspot, 0, Qt::AlignHCenter); + group->addButton(m_btnRailMoveHotspot, static_cast(EditorCanvas::Tool::MoveHotspot)); + + m_btnHotspotEnterPreview = new QToolButton(m_floatingToolDock); + m_btnHotspotEnterPreview->setText(QStringLiteral("预览")); + m_btnHotspotEnterPreview->setVisible(false); + m_btnHotspotEnterPreview->setToolTip({}); + polishCompactToolButton(m_btnHotspotEnterPreview, 30); + toolLayout->addWidget(m_btnHotspotEnterPreview, 0, Qt::AlignHCenter); toolLayout->addStretch(1); canvasHost->toolDock = m_floatingToolDock; m_floatingModeDock->setParent(canvasHost); m_floatingToolDock->setParent(canvasHost); + m_editorCanvas->setMouseTracking(true); + m_canvasHost->setMouseTracking(true); + m_editorCanvas->installEventFilter(this); + m_editorCanvas->setParent(canvasHost); m_floatingToolDock->raise(); m_floatingModeDock->raise(); @@ -2915,6 +3585,12 @@ void MainWindow::rebuildCentralPages() { updateStatusBarText(); }); connect(m_editorCanvas, &EditorCanvas::selectedEntityChanged, this, [this](bool hasSel, const QString& id, int depth, const QPointF& origin) { + if (hasSel && !id.isEmpty()) { + m_selectedHotspotId.clear(); + if (m_editorCanvas) { + m_editorCanvas->clearPresentationHotspotSelection(); + } + } m_hasSelectedEntity = hasSel; m_selectedEntityId = id; m_selectedBlackholeEntityId.clear(); @@ -2942,6 +3618,12 @@ void MainWindow::rebuildCentralPages() { connect(m_editorCanvas, &EditorCanvas::selectedToolChanged, this, [this](bool hasSel, const QString& id, const QPointF& origin) { Q_UNUSED(origin); + if (hasSel && !id.isEmpty()) { + m_selectedHotspotId.clear(); + if (m_editorCanvas) { + m_editorCanvas->clearPresentationHotspotSelection(); + } + } m_hasSelectedTool = hasSel; m_selectedToolId = id; m_selectedBlackholeEntityId.clear(); @@ -3104,7 +3786,6 @@ void MainWindow::rebuildCentralPages() { if (netErr != QNetworkReply::NoError) { if (netErrStr.contains(QStringLiteral("canceled"), Qt::CaseInsensitive) || netErr == QNetworkReply::OperationCanceledError) { - statusBar()->showMessage(QStringLiteral("已取消分割")); return; } QMessageBox::warning(this, QStringLiteral("SAM 分割"), @@ -3177,7 +3858,8 @@ void MainWindow::rebuildCentralPages() { if (m_workspace.hasDepth()) { const QString dpath = m_workspace.depthAbsolutePath(); if (!dpath.isEmpty() && QFileInfo::exists(dpath)) { - const QImage dimg(dpath); + const QImage dimg = + core::image_file::loadImageLimited(dpath, core::image_decode::kWorkspaceMaxPixels); if (!dimg.isNull()) { depth8 = dimg.convertToFormat(QImage::Format_Grayscale8); } @@ -3199,7 +3881,8 @@ void MainWindow::rebuildCentralPages() { } const QString bgAbs = m_workspace.backgroundAbsolutePath(); - QImage bg(bgAbs); + QImage bg = + core::image_file::loadImageLimited(bgAbs, core::image_decode::kWorkspaceMaxPixels); if (!bg.isNull() && bg.format() != QImage::Format_ARGB32_Premultiplied) { bg = bg.convertToFormat(QImage::Format_ARGB32_Premultiplied); } @@ -3225,7 +3908,6 @@ void MainWindow::rebuildCentralPages() { // 不直接落盘:进入待确认(可微调) m_editorCanvas->setPendingEntityPolygonWorld(polyWorld); - statusBar()->showMessage(QStringLiteral("分割完成:可拖动顶点微调,回车/点击空白确认")); }); dlg->show(); @@ -3243,7 +3925,8 @@ void MainWindow::rebuildCentralPages() { int z = 0; if (m_workspace.hasDepth()) { const QString dpath = m_workspace.depthAbsolutePath(); - QImage depth8(dpath); + QImage depth8 = + core::image_file::loadImageLimited(dpath, core::image_decode::kWorkspaceMaxPixels); if (!depth8.isNull()) { depth8 = depth8.convertToFormat(QImage::Format_Grayscale8); const QPointF c = entity_cutout::polygonCentroid(polyWorld); @@ -3308,15 +3991,39 @@ void MainWindow::rebuildCentralPages() { } ent.blackholeResolvedBy = QStringLiteral("pending"); - QImage bg(m_workspace.backgroundAbsolutePath()); - if (!bg.isNull() && bg.format() != QImage::Format_ARGB32_Premultiplied) { - bg = bg.convertToFormat(QImage::Format_ARGB32_Premultiplied); - } + // 大图必须按 1:1 region 解码,否则 polyWorld(逻辑像素)与 bg 像素空间不一致会产生严重错位。 QImage cutout; - if (!bg.isNull()) { - QPointF topLeft; - cutout = entity_cutout::extractEntityImage(bg, ent.cutoutPolygonWorld, topLeft); - ent.imageTopLeftWorld = topLeft; + { + const QRectF bb = entity_cutout::pathFromWorldPolygon(ent.cutoutPolygonWorld).boundingRect(); + const int left = int(std::floor(bb.left())); + const int top = int(std::floor(bb.top())); + const int right = int(std::ceil(bb.right())); + const int bottom = int(std::ceil(bb.bottom())); + QSize bgLogical; + if (!core::image_file::probeImagePixelSize(m_workspace.backgroundAbsolutePath(), &bgLogical)) { + bgLogical = QSize(); + } + if (bgLogical.isValid()) { + const QRect region = entity_cutout::clampRectToImage( + QRect(QPoint(left, top), QPoint(right, bottom)), + bgLogical); + QImage bgRegion; + if (!region.isEmpty() && + core::image_file::loadRegionToQImageExact(m_workspace.backgroundAbsolutePath(), region, &bgRegion) && + !bgRegion.isNull()) { + if (bgRegion.format() != QImage::Format_ARGB32_Premultiplied) { + bgRegion = bgRegion.convertToFormat(QImage::Format_ARGB32_Premultiplied); + } + QVector localPoly; + localPoly.reserve(ent.cutoutPolygonWorld.size()); + for (const auto& p : ent.cutoutPolygonWorld) { + localPoly.push_back(p - region.topLeft()); + } + QPointF localTopLeft; + cutout = entity_cutout::extractEntityImage(bgRegion, localPoly, localTopLeft); + ent.imageTopLeftWorld = region.topLeft() + localTopLeft; + } + } } if (!m_workspace.addEntity(ent, cutout)) { @@ -3326,7 +4033,6 @@ void MainWindow::rebuildCentralPages() { if (m_editorCanvas) { m_editorCanvas->clearPendingEntityPolygon(); } - statusBar()->showMessage(QStringLiteral("实体已创建")); refreshEditorPage(); refreshProjectTree(); updateUiEnabledState(); @@ -3334,7 +4040,7 @@ void MainWindow::rebuildCentralPages() { connect(m_editorCanvas, &EditorCanvas::requestMoveEntity, this, [this](const QString& id, const QPointF& delta) { // 动画编辑:拖动即写入当前位置关键帧,避免被既有关键帧插值“拉回去” const bool autoKey = true; - if (!m_workspace.moveEntityBy(id, delta, m_currentFrame % core::Project::kClipFixedFrames, autoKey)) { + if (!m_workspace.moveEntityBy(id, delta, std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1), autoKey)) { return; } refreshEditorPage(); @@ -3344,7 +4050,7 @@ void MainWindow::rebuildCentralPages() { connect(m_editorCanvas, &EditorCanvas::requestMoveTool, this, [this](const QString& id, const QPointF& delta) { const bool autoKey = true; - if (!m_workspace.moveToolBy(id, delta, m_currentFrame % core::Project::kClipFixedFrames, autoKey)) { + if (!m_workspace.moveToolBy(id, delta, std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1), autoKey)) { return; } refreshEditorPage(); @@ -3353,7 +4059,7 @@ void MainWindow::rebuildCentralPages() { }); connect(m_editorCanvas, &EditorCanvas::requestMoveCamera, this, [this](const QString& id, const QPointF& delta) { const bool autoKey = true; - if (!m_workspace.moveCameraBy(id, delta, m_currentFrame % core::Project::kClipFixedFrames, autoKey)) { + if (!m_workspace.moveCameraBy(id, delta, std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1), autoKey)) { return; } refreshEditorPage(); @@ -3367,17 +4073,104 @@ void MainWindow::rebuildCentralPages() { for (const auto& rc : resFrame.cameras) { if (rc.camera.id != id) continue; const double ns = std::clamp(rc.camera.viewScale * factor, 1e-6, 1e3); - const int kf = std::clamp(m_currentFrame, 0, core::Project::kClipFixedFrames - 1); + const int kf = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); if (!m_workspace.setCameraViewScaleValue(id, ns, kf)) return; refreshEditorPage(); refreshDopeSheet(); return; } }); + + connect(m_editorCanvas, &EditorCanvas::requestAddPresentationHotspot, this, [this](const QPointF& w) { + QVector hs = m_workspace.project().presentationHotspots(); + core::Project::PresentationHotspot nh; + nh.id = nextPresentationHotspotIdWs(hs); + nh.centerWorld = w; + nh.targetAnimationId.clear(); + const QString newId = nh.id; + hs.push_back(std::move(nh)); + m_workspace.applyPresentationHotspots(hs, true, QStringLiteral("热点")); + m_selectedHotspotId = newId; + if (m_editorCanvas) { + m_editorCanvas->setSelectedPresentationHotspotId(m_selectedHotspotId); + } + refreshEditorPage(); + }); + connect(m_editorCanvas, &EditorCanvas::requestAddPresentationHotspotForAnimation, this, + [this](const QPointF& w, const QString& animationId) { + if (animationId.isEmpty()) { + return; + } + QVector hs = m_workspace.project().presentationHotspots(); + core::Project::PresentationHotspot nh; + nh.id = nextPresentationHotspotIdWs(hs); + nh.centerWorld = w; + nh.targetAnimationId = animationId; + const QString newId = nh.id; + hs.push_back(std::move(nh)); + m_workspace.applyPresentationHotspots(hs, true, QStringLiteral("热点")); + m_selectedHotspotId = newId; + if (m_editorCanvas) { + m_editorCanvas->setSelectedPresentationHotspotId(m_selectedHotspotId); + } + refreshEditorPage(); + }); + connect(m_editorCanvas, &EditorCanvas::presentationHotspotMoved, this, + [this](const QString& hotspotId, const QPointF& c) { + QVector hs = m_workspace.project().presentationHotspots(); + for (auto& h : hs) { + if (h.id == hotspotId) { + h.centerWorld = c; + break; + } + } + m_workspace.applyPresentationHotspots(hs, true, QStringLiteral("热点")); + refreshEditorPage(); + }); + connect(m_editorCanvas, &EditorCanvas::selectedPresentationHotspotChanged, this, + [this](const QString& hotspotId) { + m_selectedHotspotId = hotspotId; + if (!hotspotId.isEmpty()) { + m_hasSelectedEntity = false; + m_selectedEntityId.clear(); + m_selectedEntityDisplayNameCache.clear(); + m_hasSelectedTool = false; + m_selectedToolId.clear(); + m_hasSelectedCamera = false; + m_selectedCameraId.clear(); + m_selectedBlackholeEntityId.clear(); + if (m_editorCanvas) { + m_editorCanvas->clearEntitySelection(); + m_editorCanvas->clearCameraSelection(); + } + } + if (!m_timelineScrubbing) { + refreshPropertyPanel(); + syncProjectTreeFromCanvasSelection(); + } + }); + connect(m_editorCanvas, &EditorCanvas::hotspotPlayRequested, this, [this](const QString& hid) { + for (const auto& h : m_workspace.project().presentationHotspots()) { + if (h.id == hid) { + const QString tid = h.targetAnimationId; + if (!tid.isEmpty() && tid != QStringLiteral("none")) { + requestPreviewSchemeSwitchTo(tid, true); + } + return; + } + } + }); + connect(m_editorCanvas, &EditorCanvas::selectedCameraChanged, this, [this](bool hasSel, const QString& id, const QPointF& centerWorld, double viewScale) { Q_UNUSED(centerWorld); Q_UNUSED(viewScale); + if (hasSel && !id.isEmpty()) { + m_selectedHotspotId.clear(); + if (m_editorCanvas) { + m_editorCanvas->clearPresentationHotspotSelection(); + } + } m_hasSelectedCamera = hasSel; m_selectedCameraId = id; if (hasSel) { @@ -3404,11 +4197,10 @@ void MainWindow::rebuildCentralPages() { QStringLiteral("复制背景区域失败。请重新拖动取样框,确保采样区域在背景范围内。")); return; } - statusBar()->showMessage(QStringLiteral("黑洞已通过背景复制修复")); refreshProjectTree(); updateUiEnabledState(); if (m_editorCanvas) { - m_editorCanvas->notifyBackgroundContentChanged(); + m_editorCanvas->notifyBlackholeOverlaysChanged(); } refreshEditorPage(); if (m_previewRequested) { @@ -3461,16 +4253,36 @@ void MainWindow::rebuildCentralPages() { m_bgPropertySection->syncDepthOverlayChecked(on); } }); - connect(btnFit, &QToolButton::clicked, this, [this]() { + connect(m_railBtnFit, &QToolButton::clicked, this, [this]() { if (m_editorCanvas) { m_editorCanvas->zoomToFit(); } }); + connect(m_btnHotspotEnterPreview, &QToolButton::clicked, this, [this]() { + const QString aid = firstAnimationSchemeIdNonNoneWs(m_workspace.project()); + if (aid.isEmpty()) { + return; + } + m_animationRequested = false; + m_hotspotRequested = false; + m_previewAnimationId = aid; + m_previewFrame = 0; + setPreviewRequested(true); + if (m_modeSelector) { + m_modeSelector->blockSignals(true); + m_modeSelector->setCurrentIndex(3); + m_modeSelector->blockSignals(false); + } + requestPreviewSchemeSwitchTo(aid, true); + }); m_centerStack->addWidget(m_pageWelcome); m_centerStack->addWidget(m_pageEditor); setCentralWidget(centerShell); + syncHotspotEnterPreviewCombo(); + syncEditorRailForMode(); + showWelcomePage(); } @@ -3498,8 +4310,8 @@ void MainWindow::refreshWelcomeRecentList() { auto* item = new QTreeWidgetItem(m_welcomeRecentTree); item->setText(0, QFileInfo(path).fileName()); item->setText(1, fm.elidedText(path, Qt::ElideMiddle, elideW)); - item->setToolTip(0, path); - item->setToolTip(1, path); + item->setToolTip(0, {}); + item->setToolTip(1, {}); item->setData(0, Qt::UserRole, path); } } @@ -3519,9 +4331,43 @@ void MainWindow::openProjectFromPath(const QString& dir) { } m_recentHistory.addAndSave(m_workspace.projectDir()); refreshWelcomeRecentList(); - statusBar()->showMessage(QStringLiteral("项目已打开:%1").arg(m_workspace.projectDir())); + presentWorkspaceEditorWithBlockingInitialLoad(); +} + +void MainWindow::presentWorkspaceEditorWithBlockingInitialLoad() { + m_animationRequested = m_workspace.isOpen() && + m_workspace.project().activeAnimationId() != QStringLiteral("none"); + m_hotspotRequested = false; + m_selectedHotspotId.clear(); + QProgressDialog progress(QStringLiteral("加载中"), QString(), 0, 0, this); + progress.setWindowModality(Qt::ApplicationModal); + progress.setMinimumDuration(0); + progress.setCancelButton(nullptr); + progress.setRange(0, 0); + progress.show(); + QCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents); + + // 注意:这里不要自动“对齐实体中心/迁移枢轴”,否则会覆盖用户手动设置的「相对位置(枢轴相对形心)」。 + // 若需要旧工程一次性迁移,应放到显式菜单操作或带版本标记的单次迁移逻辑中。 + + showEditorPage(); refreshEditorPage(); refreshProjectTree(); + + if (m_editorCanvas) { + m_editorCanvas->update(); + } + QCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents); + + if (m_editorCanvas && m_workspace.hasBackground()) { + QEventLoop loop; + QMetaObject::Connection conn = QObject::connect(m_editorCanvas, &EditorCanvas::initialBackgroundLoadFinished, + &loop, &QEventLoop::quit); + QTimer::singleShot(120000, &loop, &QEventLoop::quit); + loop.exec(); + QObject::disconnect(conn); + } + updateUiEnabledState(); refreshPreviewPage(); } @@ -3549,6 +4395,18 @@ void MainWindow::setPreviewRequested(bool preview) { if (preview && (!m_workspace.isOpen() || !m_workspace.hasBackground())) { return; } + if (preview) { + stopEditorPlayback(); + if (m_previewAnimationId.isEmpty()) { + m_previewAnimationId = QStringLiteral("none"); + } + const QString prevId = effectivePreviewAnimationId(); + const int plen = m_workspace.animationLengthFramesForId(prevId); + m_previewFrame = std::clamp(m_previewFrame, 0, std::max(0, plen - 1)); + } else { + stopPreviewPlayback(); + } + m_previewRequested = preview; if (!preview && m_editorCanvas) { m_editorCanvas->clearPresentationEntityFocus(); @@ -3585,13 +4443,13 @@ void MainWindow::refreshEditorPage() { if (m_schemeSelector) { m_schemeSelector->blockSignals(true); m_schemeSelector->clear(); - const auto& schemes = m_workspace.project().animationSchemes(); - for (const auto& s : schemes) { - const QString label = s.name.isEmpty() ? s.id : s.name; - m_schemeSelector->addItem(label, s.id); + const auto& animations = m_workspace.project().animations(); + for (const auto& a : animations) { + const QString label = a.name.isEmpty() ? a.id : a.name; + m_schemeSelector->addItem(label, a.id); } - m_schemeSelector->addItem(QStringLiteral("+ 新建方案…"), QStringLiteral("__create__")); - const QString activeId = m_workspace.project().activeSchemeId(); + m_schemeSelector->addItem(QStringLiteral("+ 新建动画…"), QStringLiteral("__create__")); + const QString activeId = m_workspace.project().activeAnimationId(); int idx = -1; for (int i = 0; i < m_schemeSelector->count(); ++i) { if (m_schemeSelector->itemData(i).toString() == activeId) { @@ -3605,8 +4463,19 @@ void MainWindow::refreshEditorPage() { } applyTimelineFromProject(); - const core::eval::ResolvedProjectFrame rf = - core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); + core::eval::ResolvedProjectFrame rf; + const bool presentationPreview = presentation; + if (presentationPreview && !m_previewPlaying) { + refreshPreviewSchemeCombo(); + } + if (presentationPreview) { + const QString pvId = effectivePreviewAnimationId(); + const int pvLen = m_workspace.animationLengthFramesForId(pvId); + m_previewFrame = std::clamp(m_previewFrame, 0, std::max(0, pvLen - 1)); + rf = core::eval::evaluateAtFrame(m_workspace.project(), m_previewFrame, 10, pvId); + } else { + rf = core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); + } QVector ents; ents.reserve(rf.entities.size()); QVector entOps; @@ -3630,22 +4499,44 @@ void MainWindow::refreshEditorPage() { for (const auto& rc : rf.cameras) { cams.push_back(rc.camera); } - m_editorCanvas->setCameraOverlays(cams, m_selectedCameraId, m_tempHiddenCameraIds); - m_editorCanvas->setCurrentFrame(m_currentFrame); - m_editorCanvas->setTempHiddenIds(m_tempHiddenEntityIds, m_tempHiddenToolIds); - m_editorCanvas->setPreviewCameraViewLocked(false); - if (presentation) { - const QString acid = m_workspace.project().activeCameraId(); - if (!acid.isEmpty()) { - for (const auto& rc : rf.cameras) { - if (rc.camera.id == acid) { - m_editorCanvas->setPreviewCameraViewLocked(true); - m_editorCanvas->applyCameraViewport(rc.camera.centerWorld, rc.camera.viewScale); - break; + const QString pvIdForCam = presentationPreview ? effectivePreviewAnimationId() : QString(); + const bool animationEditor = currentUiMode() == UiMode::AnimationEditor; + const bool allowCameraUi = (presentationPreview && pvIdForCam != QStringLiteral("none")) || + (!presentationPreview && animationEditor && + m_workspace.project().activeAnimationId() != QStringLiteral("none")); + + if (!allowCameraUi) { + m_hasSelectedCamera = false; + m_selectedCameraId.clear(); + m_editorCanvas->setCameraOverlays({}, QString(), {}); + m_editorCanvas->setPreviewCameraViewLocked(false); + m_editorCanvas->clearCameraSelection(); + } else { + m_editorCanvas->setCameraOverlays(cams, m_selectedCameraId, m_tempHiddenCameraIds); + m_editorCanvas->setPreviewCameraViewLocked(false); + if (presentation) { + const QString acid = m_workspace.project().activeCameraId(); + if (!acid.isEmpty() && pvIdForCam != QStringLiteral("none")) { + for (const auto& rc : rf.cameras) { + if (rc.camera.id == acid) { + m_editorCanvas->setPreviewCameraViewLocked(true); + m_editorCanvas->applyCameraViewport(rc.camera.centerWorld, rc.camera.viewScale); + break; + } } } } } + m_editorCanvas->setCurrentFrame(presentationPreview ? m_previewFrame : m_currentFrame); + m_editorCanvas->setTempHiddenIds(m_tempHiddenEntityIds, m_tempHiddenToolIds); + + m_editorCanvas->setPresentationHotspots(m_workspace.project().presentationHotspots()); + m_editorCanvas->setPresentationHotspotEditorActive(m_hotspotRequested && !m_previewRequested); + m_editorCanvas->setPresentationHotspotPlaybackTargetEnabled(presentationPreview && + effectivePreviewAnimationId() == + QStringLiteral("none")); + m_editorCanvas->setSelectedPresentationHotspotId(m_selectedHotspotId); + updateTimelineTracks(); } else { @@ -3685,79 +4576,62 @@ void MainWindow::updateTimelineTracks() { return; } - // 选择当前 clip(与 workspace 写入规则一致) - const core::Project::AnimationClip* clip = nullptr; - const auto& allClips = m_workspace.project().animationClips(); - const auto* scheme = m_workspace.project().activeSchemeOrNull(); - if (scheme) { - const QString stripId = m_workspace.project().selectedStripId(); - const core::Project::NlaStrip* chosenStrip = nullptr; - if (!stripId.isEmpty()) { - for (const auto& tr : scheme->tracks) { - for (const auto& st : tr.strips) { - if (st.id == stripId) { - chosenStrip = &st; - break; - } - } - if (chosenStrip) break; - } - } - if (!chosenStrip) { - for (const auto& tr : scheme->tracks) { - for (const auto& st : tr.strips) { - if (st.enabled && !st.muted) { - chosenStrip = &st; - break; - } - } - if (chosenStrip) break; - } - } - if (chosenStrip) { - clip = m_workspace.project().findClipById(chosenStrip->clipId); + const core::Project::Animation* anim = m_workspace.project().activeAnimationOrNull(); + if (!anim) { + m_timeline->setKeyframeTracks({}, {}, {}, {}); + // 与旧注释一致:不选中工具时不要强行清空 tool tracks + if (wantTool) { + m_timeline->setToolKeyframeTracks({}, {}); } + return; } - if (!clip && !allClips.isEmpty()) { - clip = &allClips.front(); - } - if (!clip) return; - auto framesOfVec2 = [](const QVector& keys) { + auto framesOfTrack = [&](core::Project::AnimationTargetKind kind, + const QString& targetId, + core::Project::AnimationProperty prop) -> QVector { QVector out; - out.reserve(keys.size()); - for (const auto& k : keys) out.push_back(k.frame); - return out; - }; - auto framesOfDouble = [](const QVector& keys) { - QVector out; - out.reserve(keys.size()); - for (const auto& k : keys) out.push_back(k.frame); + if (targetId.isEmpty()) return out; + for (const auto& t : anim->tracks) { + if (t.targetKind != kind || t.targetId != targetId || t.property != prop) { + continue; + } + out.reserve(out.size() + t.keys.size()); + for (const auto& k : t.keys) { + out.push_back(k.frame); + } + } + std::sort(out.begin(), out.end()); + out.erase(std::unique(out.begin(), out.end()), out.end()); return out; }; + auto framesOfImage = [](const QVector& keys) { QVector out; out.reserve(keys.size()); for (const auto& k : keys) out.push_back(k.frame); return out; }; - auto framesOfBool = [](const QVector& keys) { - QVector out; - out.reserve(keys.size()); - for (const auto& k : keys) out.push_back(k.frame); - return out; - }; if (wantEntity) { - const auto loc = clip->entityLocationKeys.value(m_selectedEntityId); - const auto sc = clip->entityUserScaleKeys.value(m_selectedEntityId); - const auto im = clip->entityImageFrames.value(m_selectedEntityId); - const auto vis = clip->entityVisibilityKeys.value(m_selectedEntityId); - m_timeline->setKeyframeTracks(framesOfVec2(loc), framesOfDouble(sc), framesOfImage(im), framesOfBool(vis)); + const auto loc = framesOfTrack(core::Project::AnimationTargetKind::Entity, + m_selectedEntityId, + core::Project::AnimationProperty::Position); + const auto sc = framesOfTrack(core::Project::AnimationTargetKind::Entity, + m_selectedEntityId, + core::Project::AnimationProperty::UserScale); + const auto im = m_workspace.entitySpriteFrames(m_selectedEntityId); + const auto vis = framesOfTrack(core::Project::AnimationTargetKind::Entity, + m_selectedEntityId, + core::Project::AnimationProperty::Visibility); + m_timeline->setKeyframeTracks(loc, sc, framesOfImage(im), vis); } else if (wantCamera) { - const auto loc = clip->cameraLocationKeys.value(m_selectedCameraId); - const auto sc = clip->cameraScaleKeys.value(m_selectedCameraId); - m_timeline->setKeyframeTracks(framesOfVec2(loc), framesOfDouble(sc), {}, {}); + const auto loc = framesOfTrack(core::Project::AnimationTargetKind::Camera, + m_selectedCameraId, + core::Project::AnimationProperty::Position); + const auto sc = framesOfTrack(core::Project::AnimationTargetKind::Camera, + m_selectedCameraId, + core::Project::AnimationProperty::CameraScale); + m_timeline->setKeyframeTracks(loc, sc, {}, {}); } else { m_timeline->setKeyframeTracks({}, {}, {}, {}); } @@ -3765,9 +4639,13 @@ void MainWindow::updateTimelineTracks() { // 注意:未选中工具时不能调用 setToolKeyframeTracks({}, {}),其实现会清空 m_locFrames/m_scaleFrames, // 从而冲掉上面已为实体/摄像机写入的轨道数据。 if (wantTool) { - const auto loc = clip->toolLocationKeys.value(m_selectedToolId); - const auto vis = clip->toolVisibilityKeys.value(m_selectedToolId); - m_timeline->setToolKeyframeTracks(framesOfVec2(loc), framesOfBool(vis)); + const auto loc = framesOfTrack(core::Project::AnimationTargetKind::Tool, + m_selectedToolId, + core::Project::AnimationProperty::Position); + const auto vis = framesOfTrack(core::Project::AnimationTargetKind::Tool, + m_selectedToolId, + core::Project::AnimationProperty::Visibility); + m_timeline->setToolKeyframeTracks(loc, vis); } } @@ -3776,8 +4654,8 @@ void MainWindow::applyTimelineFromProject() { return; } const int g = std::max(0, m_currentFrame); - const int local = std::clamp(g, 0, core::Project::kClipFixedFrames - 1); - m_timeline->setFrameRange(0, core::Project::kClipFixedFrames); + const int local = std::clamp(g, 0, m_workspace.activeAnimationLengthFrames() - 1); + m_timeline->setFrameRange(0, m_workspace.activeAnimationLengthFrames()); m_timeline->setCurrentFrameProgrammatic(local); if (m_editorCanvas) m_editorCanvas->setCurrentFrame(g); } @@ -3791,6 +4669,20 @@ void MainWindow::refreshDopeSheet() { return; } const int f = m_currentFrame; + const core::Project::Animation* anim = m_workspace.project().activeAnimationOrNull(); + + auto hasKeyAtFrame = [&](core::Project::AnimationTargetKind kind, + const QString& targetId, + core::Project::AnimationProperty prop) -> bool { + if (!anim || targetId.isEmpty()) return false; + for (const auto& t : anim->tracks) { + if (t.targetKind != kind || t.targetId != targetId || t.property != prop) continue; + for (const auto& k : t.keys) { + if (k.frame == f) return true; + } + } + return false; + }; const auto& ents = m_workspace.entities(); for (const auto& e : ents) { auto* parent = new QTreeWidgetItem(m_dopeTree); @@ -3807,22 +4699,14 @@ void MainWindow::refreshDopeSheet() { ch->setText(1, hasKey ? QStringLiteral("●") : QStringLiteral("—")); }; - bool hasLoc = false; - for (const auto& k : e.locationKeys) { - if (k.frame == f) { - hasLoc = true; - break; - } - } - bool hasSc = false; - for (const auto& k : e.depthScaleKeys) { - if (k.frame == f) { - hasSc = true; - break; - } - } + const bool hasLoc = hasKeyAtFrame(core::Project::AnimationTargetKind::Entity, + e.id, + core::Project::AnimationProperty::Position); + const bool hasSc = hasKeyAtFrame(core::Project::AnimationTargetKind::Entity, + e.id, + core::Project::AnimationProperty::DepthScale); bool hasIm = false; - for (const auto& k : e.imageFrames) { + for (const auto& k : m_workspace.entitySpriteFrames(e.id)) { if (k.frame == f) { hasIm = true; break; @@ -3900,7 +4784,6 @@ void MainWindow::showBlackholeContextMenu(const QPoint& globalPos, const QString QStringLiteral("无法进入画布拖动模式,请确认黑洞与背景数据有效。")); return; } - statusBar()->showMessage(QStringLiteral("拖动画布中的青色取样框,松开鼠标即应用;Esc 取消")); return; } else if (dlg.selectedAlgorithm() == BlackholeResolveDialog::Algorithm::UseOriginalBackground) { ok = m_workspace.resolveBlackholeByUseOriginalBackground(entityId); @@ -3908,13 +4791,13 @@ void MainWindow::showBlackholeContextMenu(const QPoint& globalPos, const QString QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("应用“使用原始背景”失败。")); } } else { - // 模型补全(SDXL Inpaint):裁剪黑洞区域 -> 生成 mask -> 请求后端补全 -> 预览 -> 接受后写回背景 + // 模型补全:裁剪 -> mask -> 请求后端 -> 预览 -> 接受后保存独立覆盖层(不修改 background 原图) const QString bgAbs = m_workspace.backgroundAbsolutePath(); if (bgAbs.isEmpty() || !QFileInfo::exists(bgAbs)) { QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("背景文件无效。")); return; } - QImage bg(bgAbs); + QImage bg = core::image_file::loadImageLimited(bgAbs, core::image_decode::kWorkspaceMaxPixels); if (bg.isNull()) { QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("读取背景失败。")); return; @@ -3939,10 +4822,14 @@ void MainWindow::showBlackholeContextMenu(const QPoint& globalPos, const QString holePath.addPolygon(QPolygonF(holePolyWorld)); holePath.closeSubpath(); - // 给模型更多上下文,避免“只看见一小块洞”导致输出色块/噪声 - constexpr int kMargin = 160; + // 给模型更多上下文,避免“只看见一小块洞”导致输出色块/噪声。 + // 经验:将黑洞外接矩形在面积上放大约 4 倍(线性约 2 倍)会明显提升纹理连续性。 + constexpr double kExpandLinear = 2.0; // 线性放大倍数:2x -> 面积约 4x + constexpr int kMinMargin = 160; // 兜底最小边距(避免洞很小时上下文不足) const QRect holeRect = holePath.boundingRect().toAlignedRect(); - QRect cropRect = holeRect.adjusted(-kMargin, -kMargin, kMargin, kMargin); + const int marginX = std::max(kMinMargin, int(std::round((holeRect.width() * (kExpandLinear - 1.0)) / 2.0))); + const int marginY = std::max(kMinMargin, int(std::round((holeRect.height() * (kExpandLinear - 1.0)) / 2.0))); + QRect cropRect = holeRect.adjusted(-marginX, -marginY, marginX, marginY); cropRect = cropRect.intersected(QRect(QPoint(0, 0), bg.size())); if (!cropRect.isValid() || cropRect.width() <= 1 || cropRect.height() <= 1) { QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("裁剪区域无效。")); @@ -3960,54 +4847,155 @@ void MainWindow::showBlackholeContextMenu(const QPoint& globalPos, const QString pm.end(); } + // 重要:给后端 inpaint 的 mask 应尽量是“硬二值”,否则灰度边缘会导致半重绘, + // 进而留下人物的模糊残影。这里做阈值化 + 轻微膨胀覆盖边缘残留。 + { + // 阈值化 + for (int y = 0; y < mask.height(); ++y) { + uchar* row = mask.scanLine(y); + for (int x = 0; x < mask.width(); ++x) { + row[x] = (row[x] >= 128) ? 255 : 0; + } + } + // 二值膨胀(半径 2px),更彻底地“剔除人物边缘” + constexpr int kDilateRadius = 2; + for (int it = 0; it < kDilateRadius; ++it) { + QImage dil(mask.size(), QImage::Format_Grayscale8); + dil.fill(0); + for (int y = 0; y < mask.height(); ++y) { + for (int x = 0; x < mask.width(); ++x) { + int best = 0; + for (int dy = -1; dy <= 1 && best == 0; ++dy) { + const int yy = y + dy; + if (yy < 0 || yy >= mask.height()) continue; + const uchar* r = mask.constScanLine(yy); + for (int dx = -1; dx <= 1; ++dx) { + const int xx = x + dx; + if (xx < 0 || xx >= mask.width()) continue; + if (r[xx] != 0) { + best = 255; + break; + } + } + } + dil.scanLine(y)[x] = uchar(best); + } + } + mask = dil; + } + } + // 关键:黑洞区域在原始背景里通常是“被抠走的前景(人)像素”,但我们目标是补背景。 - // 为避免模型受前景像素干扰,这里先把 mask 内像素用周围背景的平均色“抹掉”,再发往后端 inpaint。 + // 直接用“平均色”抹掉会让模型更容易输出大块色块,纹理连续性变差。 + // 改为:先做一次较强的模糊,再把 mask 内像素替换成模糊后的背景(保留整体明暗/色彩走向)。 QImage cropForInpaint = cropRgb.convertToFormat(QImage::Format_RGB888); { const QImage m8 = mask.convertToFormat(QImage::Format_Grayscale8); - // 取一圈“mask 外的邻域”估计背景平均色 - long long sr = 0, sg = 0, sb = 0; - int sc = 0; - for (int y = 0; y < cropForInpaint.height(); ++y) { - const uchar* mr = m8.constScanLine(y); - const uchar* pr = cropForInpaint.constScanLine(y); - for (int x = 0; x < cropForInpaint.width(); ++x) { - if (mr[x] != 0) continue; - bool nearHole = false; - for (int dy = -2; dy <= 2 && !nearHole; ++dy) { - const int yy = y + dy; - if (yy < 0 || yy >= m8.height()) continue; - const uchar* r2 = m8.constScanLine(yy); - for (int dx = -2; dx <= 2; ++dx) { - const int xx = x + dx; - if (xx < 0 || xx >= m8.width()) continue; - if (r2[xx] != 0) { - nearHole = true; - break; + // 对整块做较强 box blur(多次 7x7 近似高斯),再用它覆盖洞内区域 + QImage blurred = cropForInpaint; + auto boxBlurOnce7 = [](const QImage& src) -> QImage { + QImage out(src.size(), QImage::Format_RGB888); + const int w = src.width(); + const int h = src.height(); + for (int y = 0; y < h; ++y) { + uchar* dstRow = out.scanLine(y); + for (int x = 0; x < w; ++x) { + int sr = 0, sg = 0, sb = 0, cnt = 0; + for (int dy = -3; dy <= 3; ++dy) { + const int yy = y + dy; + if (yy < 0 || yy >= h) continue; + const uchar* srcRow = src.constScanLine(yy); + for (int dx = -3; dx <= 3; ++dx) { + const int xx = x + dx; + if (xx < 0 || xx >= w) continue; + const int idx = xx * 3; + sr += int(srcRow[idx + 0]); + sg += int(srcRow[idx + 1]); + sb += int(srcRow[idx + 2]); + cnt += 1; } } + const int di = x * 3; + dstRow[di + 0] = uchar(std::clamp((sr + cnt / 2) / std::max(1, cnt), 0, 255)); + dstRow[di + 1] = uchar(std::clamp((sg + cnt / 2) / std::max(1, cnt), 0, 255)); + dstRow[di + 2] = uchar(std::clamp((sb + cnt / 2) / std::max(1, cnt), 0, 255)); + } + } + return out; + }; + // 多次迭代:更强的平滑,避免人物纹理残留影响模型 + for (int i = 0; i < 2; ++i) { + blurred = boxBlurOnce7(blurred); + } + + // 对 LaMa 额外处理:洞外一圈“抑制环带”也做弱化,减少邻近人物对补洞的强干扰。 + // 思路:ring = dilate(mask, R) - mask,把 ring 内像素往 blurred 拉近。 + constexpr int kLamaContextSuppressRadius = 10; // 8~16 常见有效 + QImage ringMask(mask.size(), QImage::Format_Grayscale8); + ringMask.fill(0); + { + // 先构造 dilated(mask, R) + QImage dil = m8; + for (int it = 0; it < kLamaContextSuppressRadius; ++it) { + QImage d2(dil.size(), QImage::Format_Grayscale8); + d2.fill(0); + for (int y = 0; y < dil.height(); ++y) { + for (int x = 0; x < dil.width(); ++x) { + int best = 0; + for (int dy = -1; dy <= 1 && best == 0; ++dy) { + const int yy = y + dy; + if (yy < 0 || yy >= dil.height()) continue; + const uchar* row = dil.constScanLine(yy); + for (int dx = -1; dx <= 1; ++dx) { + const int xx = x + dx; + if (xx < 0 || xx >= dil.width()) continue; + if (row[xx] != 0) { + best = 255; + break; + } + } + } + d2.scanLine(y)[x] = uchar(best); + } + } + dil = d2; + } + // ring = dil - m8 + for (int y = 0; y < ringMask.height(); ++y) { + const uchar* drow = dil.constScanLine(y); + const uchar* mrow = m8.constScanLine(y); + uchar* rrow = ringMask.scanLine(y); + for (int x = 0; x < ringMask.width(); ++x) { + rrow[x] = (drow[x] != 0 && mrow[x] == 0) ? 255 : 0; } - if (!nearHole) continue; - const int idx = x * 3; - sr += pr[idx + 0]; - sg += pr[idx + 1]; - sb += pr[idx + 2]; - sc += 1; } } - const int fillR = (sc > 0) ? int(sr / sc) : 128; - const int fillG = (sc > 0) ? int(sg / sc) : 128; - const int fillB = (sc > 0) ? int(sb / sc) : 128; for (int y = 0; y < cropForInpaint.height(); ++y) { const uchar* mr = m8.constScanLine(y); uchar* pr = cropForInpaint.scanLine(y); + const uchar* br = blurred.constScanLine(y); for (int x = 0; x < cropForInpaint.width(); ++x) { if (mr[x] == 0) continue; const int idx = x * 3; - pr[idx + 0] = uchar(std::clamp(fillR, 0, 255)); - pr[idx + 1] = uchar(std::clamp(fillG, 0, 255)); - pr[idx + 2] = uchar(std::clamp(fillB, 0, 255)); + pr[idx + 0] = br[idx + 0]; + pr[idx + 1] = br[idx + 1]; + pr[idx + 2] = br[idx + 2]; + } + } + + // ring 弱化(对洞外邻域的人物/实体“去结构”) + for (int y = 0; y < cropForInpaint.height(); ++y) { + const uchar* rr = ringMask.constScanLine(y); + uchar* pr = cropForInpaint.scanLine(y); + const uchar* br = blurred.constScanLine(y); + for (int x = 0; x < cropForInpaint.width(); ++x) { + if (rr[x] == 0) continue; + const int idx = x * 3; + // mix: 40% 原始 + 60% blurred(保留大色块但削弱结构) + pr[idx + 0] = uchar((int(pr[idx + 0]) * 40 + int(br[idx + 0]) * 60 + 50) / 100); + pr[idx + 1] = uchar((int(pr[idx + 1]) * 40 + int(br[idx + 1]) * 60 + 50) / 100); + pr[idx + 2] = uchar((int(pr[idx + 2]) * 40 + int(br[idx + 2]) * 60 + 50) / 100); } } } @@ -4041,10 +5029,13 @@ void MainWindow::showBlackholeContextMenu(const QPoint& globalPos, const QString QNetworkReply* reply = client->inpaintAsync( cropPng, maskPng, + // LaMa(物体移除 / 擦除) + QStringLiteral("lama"), prompt, - QString(), - // 经验值:强度过高容易把整个裁剪块“重绘成色块”,先降低更稳 - 0.55, + // 目标是“去掉人物/实体换成背景”,给一个默认 negative prompt 抑制人物结构与杂项 + QStringLiteral("person, human, face, body, hands, text, watermark, signature, logo"), + // 去人物场景:strength 太低容易“保留人物残影”;适当提高更干净 + 0.72, 1024, &immediateErr); if (!reply) { @@ -4065,7 +5056,7 @@ void MainWindow::showBlackholeContextMenu(const QPoint& globalPos, const QString }); connect(reply, &QNetworkReply::finished, this, - [this, reply, task, client, entityId, bg, cropRect, cropRgb, mask]() mutable { + [this, reply, task, client, entityId, bg, cropRect, cropRgb, mask, cropForInpaint]() mutable { const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); const QByteArray raw = reply->readAll(); const auto netErr = reply->error(); @@ -4192,30 +5183,40 @@ void MainWindow::showBlackholeContextMenu(const QPoint& globalPos, const QString } } - InpaintPreviewDialog preview(QStringLiteral("模型补全预览"), this); - preview.setImages(cropRgb, after); - if (preview.exec() != QDialog::Accepted) { - statusBar()->showMessage(QStringLiteral("已取消模型补全")); - return; - } - - QImage patchedBg = bg; + // 预览左图:按你的需求,直接把洞区域挖空成纯黑(更直观) + QImage before = cropForInpaint.convertToFormat(QImage::Format_RGB888); { - QPainter p(&patchedBg); - p.drawImage(cropRect.topLeft(), after); - p.end(); + const QImage m8 = mask.convertToFormat(QImage::Format_Grayscale8); + for (int y = 0; y < before.height(); ++y) { + const uchar* mr = m8.constScanLine(y); + uchar* br = before.scanLine(y); + for (int x = 0; x < before.width(); ++x) { + if (mr[x] == 0) continue; + const int idx = x * 3; + br[idx + 0] = 0; + br[idx + 1] = 0; + br[idx + 2] = 0; + } + } } - const bool ok2 = m_workspace.resolveBlackholeByModelInpaint(entityId, patchedBg, true); - if (!ok2) { - QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("写回背景失败。")); + + InpaintPreviewDialog preview(QStringLiteral("预览"), this); + preview.setImages(before, after); + if (preview.exec() != QDialog::Accepted) { + return; + } + + const bool ok2 = + m_workspace.resolveBlackholeByModelInpaint(entityId, after, cropRect, true); + if (!ok2) { + QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("保存修复覆盖层失败。")); return; } - statusBar()->showMessage(QStringLiteral("黑洞已通过模型补全修复")); refreshProjectTree(); updateUiEnabledState(); if (m_editorCanvas) { - m_editorCanvas->notifyBackgroundContentChanged(); + m_editorCanvas->notifyBlackholeOverlaysChanged(); } refreshEditorPage(); if (m_previewRequested) { @@ -4228,7 +5229,6 @@ void MainWindow::showBlackholeContextMenu(const QPoint& globalPos, const QString } if (ok) { - statusBar()->showMessage(QStringLiteral("黑洞修复已应用")); refreshProjectTree(); updateUiEnabledState(); refreshEditorPage(); @@ -4289,11 +5289,7 @@ void MainWindow::onNewProject() { m_recentHistory.addAndSave(m_workspace.projectDir()); refreshWelcomeRecentList(); - statusBar()->showMessage(QStringLiteral("项目已创建:%1").arg(m_workspace.projectDir())); - refreshEditorPage(); - refreshProjectTree(); - updateUiEnabledState(); - refreshPreviewPage(); + presentWorkspaceEditorWithBlockingInitialLoad(); } void MainWindow::onOpenProject() { @@ -4315,7 +5311,13 @@ void MainWindow::onCloseProject() { if (!m_workspace.isOpen()) { return; } + m_animationRequested = false; + m_hotspotRequested = false; + m_selectedHotspotId.clear(); m_previewRequested = false; + stopPreviewPlayback(); + m_previewAnimationId.clear(); + m_previewFrame = 0; m_playing = false; if (m_playTimer) { m_playTimer->stop(); @@ -4337,16 +5339,15 @@ void MainWindow::onCloseProject() { m_tempHiddenCameraIds.clear(); m_currentFrame = 0; - statusBar()->showMessage(QStringLiteral("工程已关闭")); refreshEditorPage(); refreshProjectTree(); updateUiEnabledState(); refreshPreviewPage(); + QTimer::singleShot(0, this, [this]() { updateRightDockColumnMinimumWidthFromContent(); }); } void MainWindow::onUndo() { if (!m_workspace.undo()) { - statusBar()->showMessage(QStringLiteral("无法撤销")); return; } refreshEditorPage(); @@ -4357,7 +5358,6 @@ void MainWindow::onUndo() { void MainWindow::onRedo() { if (!m_workspace.redo()) { - statusBar()->showMessage(QStringLiteral("无法重做")); return; } refreshEditorPage(); @@ -4379,7 +5379,50 @@ void MainWindow::onAbout() { aboutDialog->exec(); } +void MainWindow::updatePreviewEdgeArrowsHover(const QPointF& canvasHostPos) { + if (!m_previewArrowLeft || !m_previewArrowRight || !m_canvasHost || !m_previewRequested) { + return; + } + if (previewAnimationSchemeIdsNonNone().size() <= 1) { + m_previewArrowLeft->hide(); + m_previewArrowRight->hide(); + return; + } + constexpr qreal kZone = 96.0; + const qreal w = m_canvasHost->width(); + const bool nearLeft = canvasHostPos.x() < kZone; + const bool nearRight = canvasHostPos.x() > w - kZone; + m_previewArrowLeft->setVisible(m_previewRequested && nearLeft); + m_previewArrowRight->setVisible(m_previewRequested && nearRight); + if (auto* ch = dynamic_cast(m_canvasHost)) { + ch->relayoutFloaters(); + } +} + bool MainWindow::eventFilter(QObject* watched, QEvent* event) { + if (watched == m_editorCanvas && m_previewRequested && m_editorCanvas && m_canvasHost) { + if (event->type() == QEvent::MouseMove) { + const auto me = static_cast(event); +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + const QPoint pe = me->position().toPoint(); +#else + const QPoint pe = me->pos(); +#endif + const QPoint inHost = m_editorCanvas->mapTo(m_canvasHost, pe); + updatePreviewEdgeArrowsHover(QPointF(inHost)); + } else if (event->type() == QEvent::Leave) { + if (m_previewArrowLeft) { + m_previewArrowLeft->hide(); + } + if (m_previewArrowRight) { + m_previewArrowRight->hide(); + } + if (auto* ch = dynamic_cast(m_canvasHost)) { + ch->relayoutFloaters(); + } + } + } + if (event->type() == QEvent::Resize && watched == m_dockProjectTree) { if (m_dockProjectTree && m_workspace.isOpen() && !m_previewRequested && !m_dockProjectTree->isFloating()) { const int w = m_dockProjectTree->width(); diff --git a/client/gui/main_window/MainWindow.h b/client/gui/main_window/MainWindow.h index 459728c..88a8f41 100644 --- a/client/gui/main_window/MainWindow.h +++ b/client/gui/main_window/MainWindow.h @@ -24,6 +24,7 @@ class QMenu; class QFrame; class QIcon; class QPushButton; +class QListWidget; class QSlider; class QStackedWidget; class QToolButton; @@ -32,12 +33,14 @@ class QTreeWidgetItem; class QWidget; class EditorCanvas; class TimelineWidget; +class TimelineEditorModel; namespace gui { class BackgroundPropertySection; class BlackholePropertySection; class EntityPropertySection; class ToolPropertySection; class CameraPropertySection; +class HotspotPropertySection; class EntityIntroPopup; class ResourceLibraryDock; } @@ -81,15 +84,18 @@ private: void computeDepthAsync(); // UI 状态分三种: // - Welcome:未打开项目。只显示欢迎页,其它 dock 一律隐藏,视图开关禁用。 - // - Editor:已打开项目。显示编辑页,按默认规则显示 dock,同时允许用户通过“视图”菜单控制。 + // - EntityEditor:静态实体编辑(activeAnimationId=none,不显示摄像机)。 + // - AnimationEditor:动画编辑(真实动画方案,可编辑摄像机/时间轴)。 + // - HotspotEditor:展示热点布点(要求:项目已打开且背景不为空)。 // - Preview:预览展示。用于全流程完成后的展示(要求:项目已打开且背景不为空)。 - enum class UiMode { Welcome, Editor, Preview }; + enum class UiMode { Welcome, EntityEditor, AnimationEditor, HotspotEditor, Preview }; void createMenus(); // 菜单和工具栏 void createFileMenu(); // 文件菜单 void createEditMenu(); // 编辑菜单 void createHelpMenu(); // 帮助菜单 void createViewMenu(); // 视图菜单 + void createAnimationMenu(); void createWindowMenu(); // 窗口菜单(资源库等) void createProjectTreeDock(); void createTimelineDock(); @@ -109,6 +115,8 @@ private: void showPreviewPage(); void refreshWelcomeRecentList(); void openProjectFromPath(const QString& dir); + /// 打开/新建工程后:模态「加载中」直至首张背景可显示(无背景则立即继续) + void presentWorkspaceEditorWithBlockingInitialLoad(); void refreshPreviewPage(); void refreshEditorPage(); void applyTimelineFromProject(); @@ -116,6 +124,20 @@ private: void refreshDopeSheet(); void setPreviewRequested(bool preview); void syncPreviewPlaybackBar(); + void stopEditorPlayback(); + void stopPreviewPlayback(); + void togglePreviewPlayback(); + void startPreviewPlayback(); + QString effectivePreviewAnimationId() const; + void refreshPreviewSchemeCombo(); + void onPreviewSchemeComboChanged(int idx); + void switchPreviewSchemeAdjacent(int delta); + void requestPreviewSchemeSwitchTo(const QString& id, bool warmupDialog); + void updatePreviewEdgeArrowsHover(const QPointF& posInCanvasHost); + + QVector previewAnimationSchemeIdsNonNone() const; + void syncHotspotEnterPreviewCombo(); + void syncEditorRailForMode(); QStackedWidget* m_centerStack = nullptr; QWidget* m_pageWelcome = nullptr; @@ -132,6 +154,7 @@ private: gui::EntityPropertySection* m_entityPropertySection = nullptr; gui::ToolPropertySection* m_toolPropertySection = nullptr; gui::CameraPropertySection* m_cameraPropertySection = nullptr; + gui::HotspotPropertySection* m_hotspotPropertySection = nullptr; QToolButton* m_btnCreateEntity = nullptr; ToolOptionPopup* m_createEntityPopup = nullptr; QToolButton* m_btnToggleDepthOverlay = nullptr; @@ -139,6 +162,8 @@ private: EditorCanvas* m_editorCanvas = nullptr; QTreeWidget* m_projectTree = nullptr; + QStackedWidget* m_rightTopDockStack = nullptr; + QListWidget* m_hotspotAnimationList = nullptr; QDockWidget* m_dockProjectTree = nullptr; QDockWidget* m_dockProperties = nullptr; QDockWidget* m_dockTimeline = nullptr; @@ -155,6 +180,7 @@ private: QAction* m_actionToggleResourceLibrary = nullptr; QAction* m_actionEnterPreview = nullptr; QAction* m_actionBackToEditor = nullptr; + QAction* m_actionAnimationSettings = nullptr; QAction* m_actionCanvasWorldAxes = nullptr; QAction* m_actionCanvasAxisValues = nullptr; QAction* m_actionCanvasGrid = nullptr; @@ -192,6 +218,8 @@ private: void refreshPropertyPanel(); void refreshEntityPropertyPanelFast(); void syncProjectTreeFromCanvasSelection(); + /// 右侧项目树+属性列最小宽度:随当前属性页与树内容 minimumSizeHint 抬高,窄于内容时不再缩小。 + void updateRightDockColumnMinimumWidthFromContent(); bool m_timelineScrubbing = false; bool m_entityDragging = false; @@ -200,7 +228,24 @@ private: int m_currentFrame = 0; bool m_playing = false; QTimer* m_playTimer = nullptr; + QString m_previewAnimationId; + + bool m_animationRequested = false; + bool m_hotspotRequested = false; + QString m_selectedHotspotId; + + QToolButton* m_railBtnMove = nullptr; + QToolButton* m_railBtnZoom = nullptr; + QToolButton* m_railBtnFit = nullptr; + QToolButton* m_btnRailAddHotspot = nullptr; + QToolButton* m_btnRailMoveHotspot = nullptr; + QToolButton* m_btnHotspotEnterPreview = nullptr; + QToolButton* m_previewBackToCanvasBtn = nullptr; + int m_previewFrame = 0; + bool m_previewPlaying = false; + QTimer* m_previewPlayTimer = nullptr; TimelineWidget* m_timeline = nullptr; + TimelineEditorModel* m_timelineModel = nullptr; QToolButton* m_btnPlay = nullptr; QComboBox* m_schemeSelector = nullptr; // 时间轴区间选择(用于逐帧贴图动画) @@ -214,7 +259,9 @@ private: gui::EntityIntroPopup* m_entityIntroPopup = nullptr; QFrame* m_previewPlaybackBar = nullptr; QToolButton* m_previewBtnPlay = nullptr; - QToolButton* m_previewBtnPause = nullptr; + QComboBox* m_previewSchemeCombo = nullptr; + QToolButton* m_previewArrowLeft = nullptr; + QToolButton* m_previewArrowRight = nullptr; gui::ResourceLibraryDock* m_resourceLibraryDockWidget = nullptr; core::library::ResourceLibraryProvider* m_resourceLibraryProvider = nullptr; diff --git a/client/gui/params/ParamControls.cpp b/client/gui/params/ParamControls.cpp index 0b66c21..1c3df79 100644 --- a/client/gui/params/ParamControls.cpp +++ b/client/gui/params/ParamControls.cpp @@ -1,10 +1,18 @@ #include "params/ParamControls.h" +#include "widgets/CompactNumericSpinBox.h" + #include #include + +#include +#include #include #include +#include #include +#include +#include namespace gui { @@ -12,7 +20,7 @@ Float01ParamControl::Float01ParamControl(QWidget* parent) : QWidget(parent) { auto* row = new QHBoxLayout(this); row->setContentsMargins(0, 0, 0, 0); - row->setSpacing(8); + row->setSpacing(4); m_slider = new QSlider(Qt::Horizontal, this); m_slider->setRange(0, 1000); @@ -20,12 +28,11 @@ Float01ParamControl::Float01ParamControl(QWidget* parent) m_slider->setPageStep(10); row->addWidget(m_slider, 1); - m_spin = new QDoubleSpinBox(this); + m_spin = new CompactDoubleSpinBox(this, 52); m_spin->setRange(0.0, 1.0); m_spin->setDecimals(3); m_spin->setSingleStep(0.01); - m_spin->setMinimumWidth(72); - row->addWidget(m_spin); + row->addWidget(m_spin, 0); connect(m_slider, &QSlider::valueChanged, this, [this]() { syncFromSlider(); }); connect(m_spin, qOverload(&QDoubleSpinBox::valueChanged), this, [this]() { syncFromSpin(); }); @@ -73,59 +80,142 @@ Vec2ParamControl::Vec2ParamControl(QWidget* parent) : QWidget(parent) { auto* row = new QHBoxLayout(this); row->setContentsMargins(0, 0, 0, 0); - row->setSpacing(8); + row->setSpacing(3); - m_x = new QDoubleSpinBox(this); - m_x->setRange(-1e9, 1e9); - m_x->setDecimals(2); - m_x->setSingleStep(1.0); - m_x->setMinimumWidth(72); - row->addWidget(m_x, 1); + constexpr int kMaxW = 72; + m_sx = new CompactIntSpinBox(this, kMaxW); + m_sx->setRange(-10'000'000, 10'000'000); + m_sx->setSingleStep(1); + m_sx->setAccelerated(true); + row->addWidget(m_sx, 0); - m_y = new QDoubleSpinBox(this); - m_y->setRange(-1e9, 1e9); - m_y->setDecimals(2); - m_y->setSingleStep(1.0); - m_y->setMinimumWidth(72); - row->addWidget(m_y, 1); + m_sy = new CompactIntSpinBox(this, kMaxW); + m_sy->setRange(-10'000'000, 10'000'000); + m_sy->setSingleStep(1); + m_sy->setAccelerated(true); + row->addWidget(m_sy, 0); + row->addStretch(1); - connect(m_x, qOverload(&QDoubleSpinBox::valueChanged), this, [this]() { emitIfChanged(); }); - connect(m_y, qOverload(&QDoubleSpinBox::valueChanged), this, [this]() { emitIfChanged(); }); + connect(m_sx, qOverload(&QSpinBox::valueChanged), this, [this]() { emitIfChanged(); }); + connect(m_sy, qOverload(&QSpinBox::valueChanged), this, [this]() { emitIfChanged(); }); - setValue(0.0, 0.0); + setValue(0, 0); } void Vec2ParamControl::setEnabled(bool on) { QWidget::setEnabled(on); - if (m_x) m_x->setEnabled(on); - if (m_y) m_y->setEnabled(on); + if (m_sx) m_sx->setEnabled(on); + if (m_sy) m_sy->setEnabled(on); } -void Vec2ParamControl::setValue(double x, double y) { +void Vec2ParamControl::setValue(int x, int y) { m_block = true; - if (m_x) m_x->setValue(x); - if (m_y) m_y->setValue(y); + if (m_sx) m_sx->setValue(x); + if (m_sy) m_sy->setValue(y); m_lastX = x; m_lastY = y; m_block = false; } -double Vec2ParamControl::x() const { return m_x ? m_x->value() : 0.0; } -double Vec2ParamControl::y() const { return m_y ? m_y->value() : 0.0; } +int Vec2ParamControl::x() const { return m_sx ? m_sx->value() : 0; } +int Vec2ParamControl::y() const { return m_sy ? m_sy->value() : 0; } + +bool Vec2ParamControl::isActivelyEditing() const { + QWidget* fw = QApplication::focusWidget(); + for (QWidget* w = fw; w; w = w->parentWidget()) { + if (w == this) { + return true; + } + } + return false; +} void Vec2ParamControl::emitIfChanged() { - if (m_block || !m_x || !m_y) return; - const double nx = m_x->value(); - const double ny = m_y->value(); - auto nearEq = [](double a, double b) { - const double scale = std::max({1.0, std::abs(a), std::abs(b)}); - return std::abs(a - b) <= 1e-4 * scale; + if (m_block || !m_sx || !m_sy) return; + const int nx = m_sx->value(); + const int ny = m_sy->value(); + if (nx == m_lastX && ny == m_lastY) return; + m_lastX = nx; + m_lastY = ny; + emit valueChanged(nx, ny); +} + +Vec2DoubleParamControl::Vec2DoubleParamControl(QWidget* parent) + : QWidget(parent) { + auto* row = new QHBoxLayout(this); + row->setContentsMargins(0, 0, 0, 0); + row->setSpacing(4); + + // 属性栏里的数值输入框不要太宽,否则会挤占面板空间。 + // 坐标/中心等字段通常是整数显示,因此固定更紧凑的宽度即可(仍允许键盘输入)。 + constexpr int kMaxW = 78; + auto makeSpin = [kMaxW](QDoubleSpinBox* box) { + box->setObjectName(QStringLiteral("CompactNumericSpin")); + box->setButtonSymbols(QAbstractSpinBox::UpDownArrows); + box->setFocusPolicy(Qt::StrongFocus); + box->setKeyboardTracking(false); + // 坐标轴/位置统一用整数(仍可直接键盘输入) + box->setDecimals(0); + box->setRange(-1.0e7, 1.0e7); + box->setSingleStep(1.0); + box->setAccelerated(true); + box->setMinimumWidth(60); + box->setMaximumWidth(kMaxW); + box->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); }; - if (nearEq(nx, m_lastX) && nearEq(ny, m_lastY)) return; + + m_sx = new QDoubleSpinBox(this); + makeSpin(m_sx); + row->addWidget(m_sx, 0); + + m_sy = new QDoubleSpinBox(this); + makeSpin(m_sy); + row->addWidget(m_sy, 0); + + connect(m_sx, qOverload(&QDoubleSpinBox::valueChanged), this, [this]() { emitIfChanged(); }); + connect(m_sy, qOverload(&QDoubleSpinBox::valueChanged), this, [this]() { emitIfChanged(); }); + + setValue(0.0, 0.0); +} + +void Vec2DoubleParamControl::setEnabled(bool on) { + QWidget::setEnabled(on); + if (m_sx) m_sx->setEnabled(on); + if (m_sy) m_sy->setEnabled(on); +} + +void Vec2DoubleParamControl::setValue(double x, double y) { + m_block = true; + const double rx = std::round(x); + const double ry = std::round(y); + if (m_sx) m_sx->setValue(rx); + if (m_sy) m_sy->setValue(ry); + m_lastX = rx; + m_lastY = ry; + m_block = false; +} + +double Vec2DoubleParamControl::x() const { return m_sx ? m_sx->value() : 0.0; } +double Vec2DoubleParamControl::y() const { return m_sy ? m_sy->value() : 0.0; } + +bool Vec2DoubleParamControl::isActivelyEditing() const { + QWidget* fw = QApplication::focusWidget(); + for (QWidget* w = fw; w; w = w->parentWidget()) { + if (w == this) { + return true; + } + } + return false; +} + +void Vec2DoubleParamControl::emitIfChanged() { + if (m_block || !m_sx || !m_sy) return; + const double nx = m_sx->value(); + const double ny = m_sy->value(); + if (qFuzzyCompare(nx + 1.0, m_lastX + 1.0) && qFuzzyCompare(ny + 1.0, m_lastY + 1.0)) return; m_lastX = nx; m_lastY = ny; emit valueChanged(nx, ny); } } // namespace gui - diff --git a/client/gui/params/ParamControls.h b/client/gui/params/ParamControls.h index a69f8da..9c93023 100644 --- a/client/gui/params/ParamControls.h +++ b/client/gui/params/ParamControls.h @@ -4,11 +4,10 @@ class QDoubleSpinBox; class QSlider; -class QLabel; +class QSpinBox; namespace gui { -// 0..1 浮点参数:Slider + DoubleSpinBox(可复用) class Float01ParamControl final : public QWidget { Q_OBJECT public: @@ -31,15 +30,43 @@ private: bool m_block = false; }; -// Vec2 参数:两个 DoubleSpinBox(可复用) class Vec2ParamControl final : public QWidget { Q_OBJECT public: explicit Vec2ParamControl(QWidget* parent = nullptr); + void setValue(int x, int y); + [[nodiscard]] int x() const; + [[nodiscard]] int y() const; + + [[nodiscard]] bool isActivelyEditing() const; + + void setEnabled(bool on); + +signals: + void valueChanged(int x, int y); + +private: + void emitIfChanged(); + + QSpinBox* m_sx = nullptr; + QSpinBox* m_sy = nullptr; + bool m_block = false; + int m_lastX = 0; + int m_lastY = 0; +}; + +/// 世界坐标等:可直接键盘输入小数,带宽幅 +class Vec2DoubleParamControl final : public QWidget { + Q_OBJECT +public: + explicit Vec2DoubleParamControl(QWidget* parent = nullptr); + void setValue(double x, double y); - double x() const; - double y() const; + [[nodiscard]] double x() const; + [[nodiscard]] double y() const; + + [[nodiscard]] bool isActivelyEditing() const; void setEnabled(bool on); @@ -49,12 +76,11 @@ signals: private: void emitIfChanged(); - QDoubleSpinBox* m_x = nullptr; - QDoubleSpinBox* m_y = nullptr; + QDoubleSpinBox* m_sx = nullptr; + QDoubleSpinBox* m_sy = nullptr; bool m_block = false; double m_lastX = 0.0; double m_lastY = 0.0; }; } // namespace gui - diff --git a/client/gui/props/BackgroundPropertySection.cpp b/client/gui/props/BackgroundPropertySection.cpp index c0b72a0..a65fc4c 100644 --- a/client/gui/props/BackgroundPropertySection.cpp +++ b/client/gui/props/BackgroundPropertySection.cpp @@ -1,6 +1,7 @@ #include "props/BackgroundPropertySection.h" -#include +#include "widgets/PropertyPanelControls.h" + #include #include #include @@ -10,23 +11,22 @@ namespace gui { BackgroundPropertySection::BackgroundPropertySection(QWidget* parent) : PropertySectionWidget(parent) { auto* lay = new QVBoxLayout(this); - lay->setContentsMargins(0, 0, 0, 0); - lay->setSpacing(6); + polishPropertyVBoxLayout(lay); auto* form = new QFormLayout(); - form->setContentsMargins(0, 0, 0, 0); - form->setSpacing(6); + polishPropertyFormLayout(form); m_sizeLabel = new QLabel(QStringLiteral("-"), this); m_sizeLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + m_sizeLabel->setWordWrap(true); form->addRow(QStringLiteral("背景尺寸"), m_sizeLabel); - m_showBackground = new QCheckBox(QStringLiteral("显示背景"), this); - m_showBackground->setToolTip(QStringLiteral("是否绘制背景图")); + m_showBackground = new PropertyPanelCheckBox(QStringLiteral("显示背景"), this); + m_showBackground->setToolTip({}); form->addRow(QString(), m_showBackground); - m_depthOverlay = new QCheckBox(QStringLiteral("叠加深度"), this); - m_depthOverlay->setToolTip(QStringLiteral("在背景上叠加深度伪彩图")); + m_depthOverlay = new PropertyPanelCheckBox(QStringLiteral("叠加深度"), this); + m_depthOverlay->setToolTip({}); form->addRow(QString(), m_depthOverlay); lay->addLayout(form); diff --git a/client/gui/props/BlackholePropertySection.cpp b/client/gui/props/BlackholePropertySection.cpp index d8d501e..0605571 100644 --- a/client/gui/props/BlackholePropertySection.cpp +++ b/client/gui/props/BlackholePropertySection.cpp @@ -1,5 +1,7 @@ #include "props/BlackholePropertySection.h" +#include "widgets/PropertyPanelControls.h" + #include #include #include @@ -9,17 +11,17 @@ namespace gui { BlackholePropertySection::BlackholePropertySection(QWidget* parent) : PropertySectionWidget(parent) { auto* lay = new QVBoxLayout(this); - lay->setContentsMargins(0, 0, 0, 0); - lay->setSpacing(6); + polishPropertyVBoxLayout(lay); auto* form = new QFormLayout(); - form->setContentsMargins(0, 0, 0, 0); - form->setSpacing(6); + polishPropertyFormLayout(form); m_name = new QLabel(this); m_status = new QLabel(this); m_method = new QLabel(this); - m_method->setWordWrap(true); + for (QLabel* lab : {m_name, m_status, m_method}) { + lab->setWordWrap(true); + } form->addRow(QStringLiteral("黑洞"), m_name); form->addRow(QStringLiteral("是否解决"), m_status); diff --git a/client/gui/props/CameraPropertySection.cpp b/client/gui/props/CameraPropertySection.cpp index 90ac1cb..7265002 100644 --- a/client/gui/props/CameraPropertySection.cpp +++ b/client/gui/props/CameraPropertySection.cpp @@ -4,6 +4,9 @@ #include #include + +#include "widgets/CompactNumericSpinBox.h" +#include "widgets/PropertyPanelControls.h" #include #include #include @@ -13,32 +16,29 @@ namespace gui { CameraPropertySection::CameraPropertySection(QWidget* parent) : PropertySectionWidget(parent) { auto* lay = new QVBoxLayout(this); - lay->setContentsMargins(0, 0, 0, 0); - lay->setSpacing(6); + polishPropertyVBoxLayout(lay); auto* form = new QFormLayout(); - form->setContentsMargins(0, 0, 0, 0); - form->setSpacing(6); + polishPropertyFormLayout(form); - m_name = new QLineEdit(this); + m_name = new PropertyPanelLineEdit(this); m_name->setPlaceholderText(QStringLiteral("显示名称…")); + polishPropertyStretchyTextField(m_name); form->addRow(QStringLiteral("名称"), m_name); m_center = new Vec2ParamControl(this); - m_center->setToolTip(QStringLiteral("摄像机中心(世界坐标),与画布上黄色圆点一致")); + m_center->setToolTip({}); form->addRow(QStringLiteral("中心"), m_center); - m_viewScale = new QDoubleSpinBox(this); + m_viewScale = new CompactDoubleSpinBox(this, 56); m_viewScale->setRange(1e-4, 1000.0); m_viewScale->setDecimals(5); m_viewScale->setSingleStep(0.01); - m_viewScale->setToolTip(QStringLiteral( - "视口缩放:在参考分辨率 1600×900 下的像素/世界单位比(与预览、画布上镜头框一致);" - "不随当前窗口大小改变镜头覆盖的世界范围。数值越小,可见的世界范围越大。")); + m_viewScale->setToolTip({}); form->addRow(QStringLiteral("缩放"), m_viewScale); - m_activePreview = new QCheckBox(QStringLiteral("用作预览展示镜头"), this); - m_activePreview->setToolTip(QStringLiteral("进入预览展示时,按该摄像机在当前帧的位置与缩放呈现画面")); + m_activePreview = new PropertyPanelCheckBox(QStringLiteral("用作预览展示镜头"), this); + m_activePreview->setToolTip({}); form->addRow(QStringLiteral("预览"), m_activePreview); lay->addLayout(form); @@ -47,7 +47,7 @@ CameraPropertySection::CameraPropertySection(QWidget* parent) : PropertySectionW connect(m_name, &QLineEdit::editingFinished, this, [this]() { if (m_name) emit displayNameCommitted(m_name->text()); }); - connect(m_center, &Vec2ParamControl::valueChanged, this, [this](double x, double y) { emit centerEdited(x, y); }); + connect(m_center, &Vec2ParamControl::valueChanged, this, [this](int x, int y) { emit centerEdited(x, y); }); connect(m_viewScale, qOverload(&QDoubleSpinBox::valueChanged), this, &CameraPropertySection::viewScaleEdited); connect(m_activePreview, &QCheckBox::toggled, this, &CameraPropertySection::activePreviewToggled); @@ -69,7 +69,7 @@ void CameraPropertySection::clearDisconnected() { } if (m_center) { m_center->blockSignals(true); - m_center->setValue(0.0, 0.0); + m_center->setValue(0, 0); m_center->blockSignals(false); } if (m_viewScale) { @@ -91,9 +91,9 @@ void CameraPropertySection::applyState(const CameraPropertyUiState& s) { m_name->setText(s.displayName); m_name->blockSignals(false); } - if (m_center) { + if (m_center && !m_center->isActivelyEditing()) { m_center->blockSignals(true); - m_center->setValue(s.centerWorld.x(), s.centerWorld.y()); + m_center->setValue(qRound(s.centerWorld.x()), qRound(s.centerWorld.y())); m_center->blockSignals(false); } if (m_viewScale) { diff --git a/client/gui/props/CameraPropertySection.h b/client/gui/props/CameraPropertySection.h index c810e32..b22ec29 100644 --- a/client/gui/props/CameraPropertySection.h +++ b/client/gui/props/CameraPropertySection.h @@ -33,7 +33,7 @@ public: signals: void displayNameCommitted(const QString& text); - void centerEdited(double x, double y); + void centerEdited(int x, int y); void viewScaleEdited(double viewScale); void activePreviewToggled(bool on); diff --git a/client/gui/props/EntityPropertySection.cpp b/client/gui/props/EntityPropertySection.cpp index 41f6536..c5dcb25 100644 --- a/client/gui/props/EntityPropertySection.cpp +++ b/client/gui/props/EntityPropertySection.cpp @@ -3,7 +3,11 @@ #include "params/ParamControls.h" #include + +#include "widgets/CompactNumericSpinBox.h" +#include "widgets/PropertyPanelControls.h" #include +#include #include #include #include @@ -20,101 +24,143 @@ namespace gui { EntityPropertySection::EntityPropertySection(QWidget* parent) : PropertySectionWidget(parent) { auto* lay = new QVBoxLayout(this); - lay->setContentsMargins(0, 0, 0, 0); - lay->setSpacing(6); + polishPropertyVBoxLayout(lay); auto* form = new QFormLayout(); - form->setContentsMargins(0, 0, 0, 0); - form->setSpacing(6); + polishPropertyFormLayout(form); - m_name = new QLineEdit(this); + m_name = new PropertyPanelLineEdit(this); m_name->setPlaceholderText(QStringLiteral("显示名称")); - m_name->setToolTip(QStringLiteral("仅显示用;内部 id 不变")); + m_name->setToolTip({}); + polishPropertyStretchyTextField(m_name); form->addRow(QStringLiteral("名称"), m_name); m_depth = new QLabel(QStringLiteral("-"), this); m_distScale = new QLabel(QStringLiteral("-"), this); for (QLabel* lab : {m_depth, m_distScale}) { lab->setTextInteractionFlags(Qt::TextSelectableByMouse); + lab->setWordWrap(true); } form->addRow(QStringLiteral("深度"), m_depth); form->addRow(QStringLiteral("距离缩放"), m_distScale); - m_pivotLabel = new QLabel(QStringLiteral("中心坐标"), this); - m_pivot = new Vec2ParamControl(this); - m_pivot->setToolTip(QStringLiteral("枢轴在世界坐标中的位置(限制在轮廓包络内),用于重定位局部原点")); - form->addRow(m_pivotLabel, m_pivot); + m_centerLabel = new QLabel(QStringLiteral("中心"), this); + m_centerPosition = new Vec2DoubleParamControl(this); + m_centerPosition->setToolTip( + QStringLiteral("无父级:形心世界坐标。有父级:形心相对父级形心的偏移。")); + form->addRow(m_centerLabel, m_centerPosition); - m_centroidLabel = new QLabel(QStringLiteral("位置"), this); - m_centroid = new Vec2ParamControl(this); - m_centroid->setToolTip(QStringLiteral("实体几何质心的世界坐标;修改将整体平移实体")); - form->addRow(m_centroidLabel, m_centroid); + m_relativeLabel = new QLabel(QStringLiteral("相对位置"), this); + m_relativeToOwnCenter = new Vec2DoubleParamControl(this); + m_relativeToOwnCenter->setToolTip(QStringLiteral("变换原点相对自身形心的偏移")); + form->addRow(m_relativeLabel, m_relativeToOwnCenter); - m_userScale = new QDoubleSpinBox(this); + m_priority = new CompactIntSpinBox(this, 72); + m_priority->setRange(-1000000, 1000000); + m_priority->setSingleStep(1); + m_priority->setAccelerated(true); + m_priority->setToolTip(QStringLiteral("优先级越高越靠上。背景为 0,实体默认 1;同优先级按距离缩放/深度决定覆盖关系。")); + form->addRow(QStringLiteral("优先级"), m_priority); + + m_userScale = new CompactDoubleSpinBox(this, 56); m_userScale->setRange(0.05, 20.0); m_userScale->setDecimals(3); m_userScale->setSingleStep(0.05); m_userScale->setValue(1.0); - m_userScale->setToolTip(QStringLiteral("人为整体缩放,与深度距离缩放相乘")); + m_userScale->setToolTip({}); form->addRow(QStringLiteral("整体缩放"), m_userScale); - m_ignoreDistanceScale = new QCheckBox(QStringLiteral("不受距离缩放影响"), this); - m_ignoreDistanceScale->setToolTip(QStringLiteral("开启后实体不受深度驱动的距离缩放影响,仅受整体缩放影响(对话气泡默认开启)")); + m_ignoreDistanceScale = new PropertyPanelCheckBox(QStringLiteral("不受距离缩放影响"), this); + m_ignoreDistanceScale->setToolTip({}); form->addRow(QStringLiteral("距离缩放"), m_ignoreDistanceScale); - m_visible = new QCheckBox(QString(), this); + m_visible = new PropertyPanelCheckBox(QString(), this); m_visible->setChecked(true); - m_visible->setToolTip(QStringLiteral("随帧变化:在当前帧切换会写入可见性关键帧(10帧淡入淡出)")); + m_visible->setToolTip({}); form->addRow(QStringLiteral("可见性"), m_visible); + m_spritePreview = new QLabel(this); + // 贴图预览必须稳定占位:若用 setPixmap(pm.scaled(label->size())),QLabel 的 sizeHint 会被 pixmap 反向撑大, + // 再触发布局扩张,形成“拖动刷新属性 → 预览越来越大”的正反馈。 + // 因此固定预览区域尺寸,pixmap 仅按该尺寸缩放。 + constexpr int kSpritePreviewW = 200; + constexpr int kSpritePreviewH = 120; + m_spritePreview->setFixedSize(kSpritePreviewW, kSpritePreviewH); + m_spritePreview->setFrameShape(QFrame::StyledPanel); + m_spritePreview->setAlignment(Qt::AlignCenter); + m_spritePreview->setText(QStringLiteral("无图像")); + form->addRow(QStringLiteral("贴图"), m_spritePreview); + + m_btnEditSprite = new PropertyPanelPushButton(QStringLiteral("编辑"), this); + m_btnEditSprite->setToolTip(QStringLiteral("编辑当前帧贴图/逐帧动画")); + form->addRow(QString(), m_btnEditSprite); + lay->addLayout(form); + auto* sep = new QFrame(this); + sep->setObjectName(QStringLiteral("PropertySectionSeparator")); + sep->setFrameShape(QFrame::HLine); + lay->addWidget(sep); + m_introHeader = new QWidget(this); + polishPropertyStretchyTextField(m_introHeader); auto* headLay = new QHBoxLayout(m_introHeader); - headLay->setContentsMargins(0, 2, 0, 2); - headLay->setSpacing(6); + headLay->setContentsMargins(0, 6, 0, 2); + headLay->setSpacing(4); m_introToggle = new QToolButton(m_introHeader); m_introToggle->setAutoRaise(true); m_introToggle->setToolButtonStyle(Qt::ToolButtonTextOnly); m_introToggle->setCursor(Qt::PointingHandCursor); - m_introToggle->setToolTip(QStringLiteral("展开或折叠介绍设置")); + m_introToggle->setToolTip(QStringLiteral("展开/折叠介绍")); m_introToggle->setText(QStringLiteral("▶")); m_introToggle->setFixedWidth(22); + m_introToggle->setFixedHeight(22); auto* introTitleLab = new QLabel(QStringLiteral("介绍"), m_introHeader); - introTitleLab->setStyleSheet(QStringLiteral("QLabel { font-weight: 600; }")); + introTitleLab->setObjectName(QStringLiteral("PropertyPanelHeading")); headLay->addWidget(m_introToggle, 0, Qt::AlignVCenter); headLay->addWidget(introTitleLab, 1, Qt::AlignVCenter); m_introContent = new QWidget(this); + polishPropertyStretchyTextField(m_introContent); auto* introLay = new QVBoxLayout(m_introContent); - introLay->setContentsMargins(8, 4, 0, 0); - introLay->setSpacing(6); + polishPropertyVBoxLayout(introLay); + introLay->setContentsMargins(0, 4, 0, 0); - m_introTitle = new QLineEdit(m_introContent); + m_introTitle = new PropertyPanelLineEdit(m_introContent); m_introTitle->setPlaceholderText(QStringLiteral("标题")); introLay->addWidget(new QLabel(QStringLiteral("标题"), m_introContent)); introLay->addWidget(m_introTitle); - m_introBody = new QTextEdit(m_introContent); - m_introBody->setPlaceholderText(QStringLiteral("正文(纯文本)")); - m_introBody->setMaximumHeight(100); + m_introBody = new PropertyPanelTextEdit(m_introContent); + m_introBody->setPlaceholderText(QStringLiteral("正文")); + m_introBody->setMaximumHeight(80); introLay->addWidget(new QLabel(QStringLiteral("正文"), m_introContent)); introLay->addWidget(m_introBody); m_introImages = new QListWidget(m_introContent); - m_introImages->setMinimumHeight(72); - m_introImages->setToolTip(QStringLiteral("配图相对路径列表;使用下方按钮从文件添加")); + m_introImages->setObjectName(QStringLiteral("PropertyPanelListWidget")); + m_introImages->setMinimumHeight(56); + m_introImages->setToolTip({}); + m_introImages->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + m_introImages->setWordWrap(true); introLay->addWidget(new QLabel(QStringLiteral("配图"), m_introContent)); introLay->addWidget(m_introImages); + m_btnIntroAddImage = new PropertyPanelPushButton(QStringLiteral("添加"), m_introContent); + m_btnIntroRemoveImage = new PropertyPanelPushButton(QStringLiteral("删除"), m_introContent); + m_btnIntroAddImage->setFixedWidth(52); + m_btnIntroRemoveImage->setFixedWidth(52); + m_btnIntroAddImage->setToolTip(QStringLiteral("添加配图")); + m_btnIntroRemoveImage->setToolTip(QStringLiteral("删除当前选中的配图")); auto* imgRow = new QHBoxLayout(); - m_btnIntroAddImage = new QPushButton(QStringLiteral("添加配图…"), m_introContent); - m_btnIntroRemoveImage = new QPushButton(QStringLiteral("移除选中"), m_introContent); - imgRow->addWidget(m_btnIntroAddImage); - imgRow->addWidget(m_btnIntroRemoveImage); + imgRow->setContentsMargins(0, 0, 0, 0); + imgRow->setSpacing(8); + imgRow->addWidget(m_btnIntroAddImage, 0); + imgRow->addWidget(m_btnIntroRemoveImage, 0); + imgRow->addStretch(1); introLay->addLayout(imgRow); - m_introVideo = new QLineEdit(m_introContent); + m_introVideo = new PropertyPanelLineEdit(m_introContent); m_introVideo->setPlaceholderText(QStringLiteral("可选:视频相对路径(如 assets/entities/xxx.mp4)")); introLay->addWidget(new QLabel(QStringLiteral("视频路径(预留)"), m_introContent)); introLay->addWidget(m_introVideo); @@ -141,11 +187,14 @@ EntityPropertySection::EntityPropertySection(QWidget* parent) emit displayNameCommitted(m_name->text()); } }); - connect(m_pivot, &Vec2ParamControl::valueChanged, this, &EntityPropertySection::pivotEdited); - connect(m_centroid, &Vec2ParamControl::valueChanged, this, &EntityPropertySection::centroidEdited); + connect(m_centerPosition, &Vec2DoubleParamControl::valueChanged, this, &EntityPropertySection::centerPositionEdited); + connect(m_relativeToOwnCenter, &Vec2DoubleParamControl::valueChanged, this, + &EntityPropertySection::relativeToOwnCenterEdited); + connect(m_priority, qOverload(&QSpinBox::valueChanged), this, &EntityPropertySection::priorityEdited); connect(m_userScale, qOverload(&QDoubleSpinBox::valueChanged), this, &EntityPropertySection::userScaleEdited); connect(m_ignoreDistanceScale, &QCheckBox::toggled, this, &EntityPropertySection::ignoreDistanceScaleToggled); connect(m_visible, &QCheckBox::toggled, this, &EntityPropertySection::visibleToggled); + connect(m_btnEditSprite, &QPushButton::clicked, this, &EntityPropertySection::spriteEditRequested); connect(m_introTitle, &QLineEdit::textChanged, this, [this](const QString&) { scheduleIntroPersist(); }); connect(m_introBody, &QTextEdit::textChanged, this, [this]() { scheduleIntroPersist(); }); @@ -189,16 +238,15 @@ void EntityPropertySection::clearDisconnected() { } if (m_depth) m_depth->setText(QStringLiteral("-")); if (m_distScale) m_distScale->setText(QStringLiteral("-")); - if (m_pivot) m_pivot->setValue(0.0, 0.0); - if (m_centroid) m_centroid->setValue(0.0, 0.0); - if (m_pivotLabel) m_pivotLabel->setText(QStringLiteral("中心坐标")); - if (m_centroidLabel) m_centroidLabel->setText(QStringLiteral("位置")); - if (m_pivot) { - m_pivot->setToolTip(QStringLiteral("枢轴在世界坐标中的位置(限制在轮廓包络内),用于重定位局部原点")); - } - if (m_centroid) { - m_centroid->setToolTip(QStringLiteral("实体几何质心的世界坐标;修改将整体平移实体")); + if (m_centerPosition) m_centerPosition->setValue(0, 0); + if (m_relativeToOwnCenter) m_relativeToOwnCenter->setValue(0, 0); + if (m_priority) { + m_priority->blockSignals(true); + m_priority->setValue(1); + m_priority->blockSignals(false); } + if (m_centerLabel) m_centerLabel->setText(QStringLiteral("中心")); + if (m_relativeLabel) m_relativeLabel->setText(QStringLiteral("相对位置")); if (m_userScale) { m_userScale->blockSignals(true); m_userScale->setValue(1.0); @@ -230,6 +278,10 @@ void EntityPropertySection::clearDisconnected() { m_introVideo->blockSignals(false); } if (m_introImages) m_introImages->clear(); + if (m_spritePreview) { + m_spritePreview->setPixmap(QPixmap()); + m_spritePreview->setText(QStringLiteral("无图像")); + } setIntroSectionExpanded(true); m_introBulkUpdate = false; } @@ -248,26 +300,23 @@ void EntityPropertySection::applyState(const EntityPropertyUiState& s) { } if (m_depth) m_depth->setText(QString::number(s.depthZ)); if (m_distScale) m_distScale->setText(s.distanceScaleText); - if (m_pivotLabel) { - m_pivotLabel->setText(QStringLiteral("中心坐标")); + if (m_centerLabel) { + m_centerLabel->setText(s.hasParent ? QStringLiteral("相对中心") : QStringLiteral("世界中心")); } - if (m_centroidLabel) { - m_centroidLabel->setText(QStringLiteral("位置")); + if (m_relativeLabel) { + m_relativeLabel->setText(QStringLiteral("相对位置")); } - if (m_pivot) { - m_pivot->setToolTip( - s.parentRelativeMode - ? QStringLiteral("枢轴相对父对象的坐标;修改将写入相对父对象的位置关键帧") - : QStringLiteral("枢轴在世界坐标中的位置(限制在轮廓包络内),用于重定位局部原点")); + if (m_centerPosition && !m_centerPosition->isActivelyEditing()) { + m_centerPosition->setValue(s.centerPosition.x(), s.centerPosition.y()); } - if (m_centroid) { - m_centroid->setToolTip( - s.parentRelativeMode - ? QStringLiteral("几何质心相对父对象的坐标;修改将写入相对父对象的位置关键帧") - : QStringLiteral("实体几何质心的世界坐标;修改将整体平移实体")); + if (m_relativeToOwnCenter && !m_relativeToOwnCenter->isActivelyEditing()) { + m_relativeToOwnCenter->setValue(s.relativeToOwnCenter.x(), s.relativeToOwnCenter.y()); + } + if (m_priority) { + m_priority->blockSignals(true); + m_priority->setValue(s.priority); + m_priority->blockSignals(false); } - if (m_pivot) m_pivot->setValue(s.pivot.x(), s.pivot.y()); - if (m_centroid) m_centroid->setValue(s.centroid.x(), s.centroid.y()); if (m_userScale) { m_userScale->blockSignals(true); m_userScale->setValue(s.userScale); @@ -283,6 +332,24 @@ void EntityPropertySection::applyState(const EntityPropertyUiState& s) { m_visible->setChecked(s.visible); m_visible->blockSignals(false); } + if (m_spritePreview && !s.spritePreview.isNull()) { + const QSize target = m_spritePreview->contentsRect().size().isValid() + ? m_spritePreview->contentsRect().size() + : m_spritePreview->size(); + const quint64 key = static_cast(s.spritePreview.cacheKey()); + if (m_spritePreviewCacheKey != key || m_spritePreviewLastTargetSize != target) { + const QPixmap pm = QPixmap::fromImage(s.spritePreview); + m_spritePreview->setPixmap( + pm.scaled(target, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + m_spritePreviewCacheKey = key; + m_spritePreviewLastTargetSize = target; + } + } else if (m_spritePreview) { + m_spritePreview->setPixmap(QPixmap()); + m_spritePreview->setText(QStringLiteral("无图像")); + m_spritePreviewCacheKey = 0; + m_spritePreviewLastTargetSize = {}; + } if (m_introTitle) { m_introTitle->blockSignals(true); m_introTitle->setText(s.intro.title); @@ -343,11 +410,13 @@ core::EntityIntroContent EntityPropertySection::introSnapshot() const { void EntityPropertySection::setEditingEnabled(bool on) { if (m_name) m_name->setEnabled(on); - if (m_pivot) m_pivot->setEnabled(on); - if (m_centroid) m_centroid->setEnabled(on); + if (m_centerPosition) m_centerPosition->setEnabled(on); + if (m_relativeToOwnCenter) m_relativeToOwnCenter->setEnabled(on); + if (m_priority) m_priority->setEnabled(on); if (m_userScale) m_userScale->setEnabled(on); if (m_ignoreDistanceScale) m_ignoreDistanceScale->setEnabled(on); if (m_visible) m_visible->setEnabled(on); + if (m_btnEditSprite) m_btnEditSprite->setEnabled(on); if (m_introHeader) m_introHeader->setEnabled(on); if (m_introToggle) m_introToggle->setEnabled(on); if (m_introTitle) m_introTitle->setEnabled(on); diff --git a/client/gui/props/EntityPropertySection.h b/client/gui/props/EntityPropertySection.h index 8837abc..b09d80d 100644 --- a/client/gui/props/EntityPropertySection.h +++ b/client/gui/props/EntityPropertySection.h @@ -4,11 +4,14 @@ #include "props/PropertySectionWidget.h" #include +#include #include +#include class QLabel; class QLineEdit; class QDoubleSpinBox; +class QSpinBox; class QCheckBox; class QTextEdit; class QListWidget; @@ -18,7 +21,7 @@ class QTimer; class QWidget; namespace gui { -class Vec2ParamControl; +class Vec2DoubleParamControl; } namespace gui { @@ -27,12 +30,16 @@ struct EntityPropertyUiState { QString displayName; int depthZ = 0; QString distanceScaleText; - QPointF pivot; - QPointF centroid; + /// 中心:无父级时为形心世界坐标(与画布十字一致,用于距离/位置缩放);有父级时为形心相对父级形心的偏移。 + QPointF centerPosition; + /// 相对位置:变换原点相对自身形心(世界坐标差)。 + QPointF relativeToOwnCenter; + bool hasParent = false; + int priority = 1; double userScale = 1.0; bool ignoreDistanceScale = false; bool visible = true; - bool parentRelativeMode = false; + QImage spritePreview; core::EntityIntroContent intro; }; @@ -50,8 +57,11 @@ public: signals: void displayNameCommitted(const QString& text); - void pivotEdited(double x, double y); - void centroidEdited(double x, double y); + /// 编辑中心:无父为形心世界坐标;有父为形心相对父形心的偏移(整体平移实体) + void centerPositionEdited(double x, double y); + /// 编辑相对自身形心的原点偏移(改枢轴、外形世界位置不变) + void relativeToOwnCenterEdited(double x, double y); + void priorityEdited(int value); void userScaleEdited(double value); void ignoreDistanceScaleToggled(bool on); // 可见性(动画通道):在当前帧写关键帧 @@ -59,6 +69,7 @@ signals: /// 介绍字段变更后防抖触发,由主窗口写入工程 void introContentEdited(); void introAddImageRequested(); + void spriteEditRequested(); private: void scheduleIntroPersist(); @@ -67,13 +78,18 @@ private: QLineEdit* m_name = nullptr; QLabel* m_depth = nullptr; QLabel* m_distScale = nullptr; - QLabel* m_pivotLabel = nullptr; - QLabel* m_centroidLabel = nullptr; - Vec2ParamControl* m_pivot = nullptr; - Vec2ParamControl* m_centroid = nullptr; + QLabel* m_centerLabel = nullptr; + QLabel* m_relativeLabel = nullptr; + QSpinBox* m_priority = nullptr; + Vec2DoubleParamControl* m_centerPosition = nullptr; + Vec2DoubleParamControl* m_relativeToOwnCenter = nullptr; QDoubleSpinBox* m_userScale = nullptr; QCheckBox* m_ignoreDistanceScale = nullptr; QCheckBox* m_visible = nullptr; + QLabel* m_spritePreview = nullptr; + QPushButton* m_btnEditSprite = nullptr; + quint64 m_spritePreviewCacheKey = 0; + QSize m_spritePreviewLastTargetSize; QLineEdit* m_introTitle = nullptr; QTextEdit* m_introBody = nullptr; diff --git a/client/gui/props/HotspotPropertySection.cpp b/client/gui/props/HotspotPropertySection.cpp new file mode 100644 index 0000000..0e2a733 --- /dev/null +++ b/client/gui/props/HotspotPropertySection.cpp @@ -0,0 +1,151 @@ +#include "props/HotspotPropertySection.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace gui { + +HotspotPropertySection::HotspotPropertySection(QWidget* parent) : PropertySectionWidget(parent) { + auto* root = new QVBoxLayout(this); + root->setContentsMargins(0, 0, 0, 0); + + auto* title = new QLabel(QStringLiteral("已添加热点"), this); + root->addWidget(title); + + m_hotspotList = new QListWidget(this); + m_hotspotList->setObjectName(QStringLiteral("PropertyPanelListWidget")); + m_hotspotList->setMinimumHeight(120); + m_hotspotList->setContextMenuPolicy(Qt::CustomContextMenu); + root->addWidget(m_hotspotList); + + auto* form = new QFormLayout(); + form->setSpacing(6); + root->addLayout(form); + + m_targetAnim = new QComboBox(this); + m_targetAnim->setMinimumWidth(120); + form->addRow(QStringLiteral("目标"), m_targetAnim); + + m_btnDelete = new QPushButton(QStringLiteral("删除"), this); + root->addWidget(m_btnDelete); + + connect(m_targetAnim, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int) { + if (m_hotspotId.isEmpty() || !m_targetAnim) { + return; + } + const QString aid = m_targetAnim->currentData().toString(); + emit targetAnimationChanged(m_hotspotId, aid); + }); + connect(m_hotspotList, &QListWidget::currentItemChanged, this, [this](QListWidgetItem* current, QListWidgetItem*) { + const QString hotspotId = current ? current->data(Qt::UserRole).toString() : QString(); + if (hotspotId == m_hotspotId) { + return; + } + m_hotspotId = hotspotId; + emit hotspotSelected(hotspotId); + }); + connect(m_hotspotList, &QListWidget::customContextMenuRequested, this, [this](const QPoint& pos) { + if (!m_hotspotList) { + return; + } + auto* item = m_hotspotList->itemAt(pos); + if (!item) { + return; + } + const QString hotspotId = item->data(Qt::UserRole).toString(); + if (hotspotId.isEmpty()) { + return; + } + m_hotspotList->setCurrentItem(item); + QMenu menu(this); + auto* actDelete = menu.addAction(QStringLiteral("移除热点")); + if (menu.exec(m_hotspotList->viewport()->mapToGlobal(pos)) == actDelete) { + emit deleteRequested(hotspotId); + } + }); + connect(m_btnDelete, &QPushButton::clicked, this, [this]() { + if (!m_hotspotId.isEmpty()) { + emit deleteRequested(m_hotspotId); + } + }); +} + +void HotspotPropertySection::clearDisconnected() { + m_hotspotId.clear(); +} + +void HotspotPropertySection::applyState(const QVector& hotspots, + const QString& selectedHotspotId, + const QVector>& animIdToLabelNonNone) { + m_hotspotId = selectedHotspotId; + if (!m_targetAnim || !m_hotspotList) { + return; + } + QString targetAnimationId; + QHash animLabelById; + for (const auto& p : animIdToLabelNonNone) { + animLabelById.insert(p.first, p.second); + } + + m_hotspotList->blockSignals(true); + m_hotspotList->clear(); + QListWidgetItem* selectedItem = nullptr; + for (const auto& hotspot : hotspots) { + QString text = hotspot.id; + const QString animLabel = animLabelById.value(hotspot.targetAnimationId); + if (!animLabel.isEmpty()) { + text += QStringLiteral(" -> ") + animLabel; + } else if (!hotspot.targetAnimationId.isEmpty()) { + text += QStringLiteral(" -> ") + hotspot.targetAnimationId; + } else { + text += QStringLiteral(" -> 未设"); + } + auto* item = new QListWidgetItem(text, m_hotspotList); + item->setData(Qt::UserRole, hotspot.id); + if (hotspot.id == selectedHotspotId) { + selectedItem = item; + targetAnimationId = hotspot.targetAnimationId; + } + } + if (selectedItem) { + m_hotspotList->setCurrentItem(selectedItem); + } else { + m_hotspotList->setCurrentRow(-1); + m_hotspotId.clear(); + } + m_hotspotList->blockSignals(false); + + m_targetAnim->blockSignals(true); + m_targetAnim->clear(); + m_targetAnim->addItem(QStringLiteral("未设"), QString()); + for (const auto& p : animIdToLabelNonNone) { + m_targetAnim->addItem(p.second, p.first); + } + int idx = -1; + for (int i = 0; i < m_targetAnim->count(); ++i) { + if (m_targetAnim->itemData(i).toString() == targetAnimationId) { + idx = i; + break; + } + } + if (idx >= 0) { + m_targetAnim->setCurrentIndex(idx); + } else { + m_targetAnim->setCurrentIndex(0); + } + m_targetAnim->blockSignals(false); + + const bool ok = !m_hotspotId.isEmpty() && m_targetAnim->count() > 0; + m_targetAnim->setEnabled(ok); + if (m_btnDelete) { + m_btnDelete->setEnabled(!m_hotspotId.isEmpty()); + } +} + +} // namespace gui diff --git a/client/gui/props/HotspotPropertySection.h b/client/gui/props/HotspotPropertySection.h new file mode 100644 index 0000000..e1fe9cb --- /dev/null +++ b/client/gui/props/HotspotPropertySection.h @@ -0,0 +1,36 @@ +#pragma once + +#include "core/domain/Project.h" +#include "props/PropertySectionWidget.h" + +#include + +class QComboBox; +class QListWidget; +class QPushButton; + +namespace gui { + +class HotspotPropertySection final : public PropertySectionWidget { + Q_OBJECT +public: + explicit HotspotPropertySection(QWidget* parent = nullptr); + + void clearDisconnected(); + void applyState(const QVector& hotspots, + const QString& selectedHotspotId, + const QVector>& animIdToLabelNonNone); + +signals: + void hotspotSelected(const QString& hotspotId); + void targetAnimationChanged(const QString& hotspotId, const QString& animationId); + void deleteRequested(const QString& hotspotId); + +private: + QString m_hotspotId; + QListWidget* m_hotspotList = nullptr; + QComboBox* m_targetAnim = nullptr; + QPushButton* m_btnDelete = nullptr; +}; + +} // namespace gui diff --git a/client/gui/props/ToolPropertySection.cpp b/client/gui/props/ToolPropertySection.cpp index 2b27a5f..4f9d221 100644 --- a/client/gui/props/ToolPropertySection.cpp +++ b/client/gui/props/ToolPropertySection.cpp @@ -11,6 +11,9 @@ #include #include #include + +#include "widgets/CompactNumericSpinBox.h" +#include "widgets/PropertyPanelControls.h" #include namespace gui { @@ -18,43 +21,50 @@ namespace gui { ToolPropertySection::ToolPropertySection(QWidget* parent) : PropertySectionWidget(parent) { auto* lay = new QVBoxLayout(this); - lay->setContentsMargins(0, 0, 0, 0); - lay->setSpacing(6); + polishPropertyVBoxLayout(lay); auto* form = new QFormLayout(); - form->setContentsMargins(0, 0, 0, 0); - form->setSpacing(6); + polishPropertyFormLayout(form); - m_text = new QLineEdit(this); + m_text = new PropertyPanelLineEdit(this); m_text->setPlaceholderText(QStringLiteral("对话内容…")); + polishPropertyStretchyTextField(m_text); form->addRow(QStringLiteral("文字"), m_text); m_positionLabel = new QLabel(QStringLiteral("位置"), this); m_position = new Vec2ParamControl(this); - m_position->setToolTip(QStringLiteral("工具在世界坐标中的位置")); + m_position->setToolTip({}); form->addRow(m_positionLabel, m_position); + m_priority = new CompactIntSpinBox(this, 72); + m_priority->setRange(-1000000, 1000000); + m_priority->setSingleStep(1); + m_priority->setAccelerated(true); + m_priority->setToolTip(QStringLiteral("优先级越高越靠上。背景为 0;工具默认 10。")); + form->addRow(QStringLiteral("优先级"), m_priority); + m_pointerT = new QSlider(Qt::Horizontal, this); m_pointerT->setRange(0, 1000); m_pointerT->setSingleStep(10); m_pointerT->setPageStep(50); m_pointerT->setValue(500); - m_pointerT->setToolTip(QStringLiteral("发言实体位置")); + m_pointerT->setToolTip({}); form->addRow(QStringLiteral("指向"), m_pointerT); - m_fontPx = new QSpinBox(this); + m_fontPx = new CompactIntSpinBox(this); m_fontPx->setRange(8, 120); m_fontPx->setSingleStep(1); m_fontPx->setValue(18); form->addRow(QStringLiteral("字号"), m_fontPx); - m_align = new QComboBox(this); + m_align = new PropertyPanelComboBox(this); m_align->addItems({QStringLiteral("左对齐"), QStringLiteral("居中"), QStringLiteral("右对齐")}); + polishPropertyStretchyTextField(m_align); form->addRow(QStringLiteral("对齐"), m_align); - m_visible = new QCheckBox(QString(), this); + m_visible = new PropertyPanelCheckBox(QString(), this); m_visible->setChecked(true); - m_visible->setToolTip(QStringLiteral("随帧变化:在当前帧切换会写入可见性关键帧")); + m_visible->setToolTip({}); form->addRow(QStringLiteral("可见性"), m_visible); lay->addLayout(form); @@ -67,11 +77,13 @@ ToolPropertySection::ToolPropertySection(QWidget* parent) connect(m_fontPx, qOverload(&QSpinBox::valueChanged), this, &ToolPropertySection::fontPxChanged); connect(m_align, qOverload(&QComboBox::currentIndexChanged), this, &ToolPropertySection::alignChanged); connect(m_position, &Vec2ParamControl::valueChanged, this, &ToolPropertySection::positionEdited); + connect(m_priority, qOverload(&QSpinBox::valueChanged), this, &ToolPropertySection::priorityEdited); connect(m_visible, &QCheckBox::toggled, this, &ToolPropertySection::visibleToggled); } void ToolPropertySection::setEditingEnabled(bool on) { for (auto* w : {static_cast(m_text), static_cast(m_position), + static_cast(m_priority), static_cast(m_pointerT), static_cast(m_fontPx), static_cast(m_align), static_cast(m_visible)}) { @@ -89,10 +101,15 @@ void ToolPropertySection::clearDisconnected() { if (m_positionLabel) m_positionLabel->setText(QStringLiteral("位置")); if (m_position) { m_position->blockSignals(true); - m_position->setToolTip(QStringLiteral("工具在世界坐标中的位置")); - m_position->setValue(0.0, 0.0); + m_position->setToolTip({}); + m_position->setValue(0, 0); m_position->blockSignals(false); } + if (m_priority) { + m_priority->blockSignals(true); + m_priority->setValue(10); + m_priority->blockSignals(false); + } if (m_pointerT) { m_pointerT->blockSignals(true); m_pointerT->setValue(500); @@ -125,15 +142,17 @@ void ToolPropertySection::applyState(const ToolPropertyUiState& s) { if (m_positionLabel) { m_positionLabel->setText(QStringLiteral("位置")); } - if (m_position) { + if (m_position && !m_position->isActivelyEditing()) { m_position->blockSignals(true); - m_position->setToolTip( - s.parentRelativeMode - ? QStringLiteral("工具相对父对象的位置;修改将写入相对父对象的位置关键帧") - : QStringLiteral("工具在世界坐标中的位置")); - m_position->setValue(s.position.x(), s.position.y()); + m_position->setToolTip({}); + m_position->setValue(qRound(s.position.x()), qRound(s.position.y())); m_position->blockSignals(false); } + if (m_priority) { + m_priority->blockSignals(true); + m_priority->setValue(s.priority); + m_priority->blockSignals(false); + } if (m_pointerT) { m_pointerT->blockSignals(true); m_pointerT->setValue(std::clamp(s.pointerTThousandths, 0, 1000)); diff --git a/client/gui/props/ToolPropertySection.h b/client/gui/props/ToolPropertySection.h index e5115f0..35c0da2 100644 --- a/client/gui/props/ToolPropertySection.h +++ b/client/gui/props/ToolPropertySection.h @@ -23,6 +23,7 @@ struct ToolPropertyUiState { QString text; QPointF position; bool parentRelativeMode = false; + int priority = 10; int pointerTThousandths = 500; // bubblePointerT01 * 1000,0=左 1000=右 int fontPx = 18; int alignIndex = 1; // 0=left,1=center,2=right @@ -43,13 +44,15 @@ signals: void pointerTChanged(int thousandths); void fontPxChanged(int px); void alignChanged(int alignIndex); - void positionEdited(double x, double y); + void positionEdited(int x, int y); + void priorityEdited(int value); // 可见性(动画通道):在当前帧写关键帧 void visibleToggled(bool on); private: QLabel* m_positionLabel = nullptr; Vec2ParamControl* m_position = nullptr; + QSpinBox* m_priority = nullptr; QLineEdit* m_text = nullptr; QSlider* m_pointerT = nullptr; QSpinBox* m_fontPx = nullptr; diff --git a/client/gui/timeline/TimelineEditorModel.h b/client/gui/timeline/TimelineEditorModel.h new file mode 100644 index 0000000..0d259d9 --- /dev/null +++ b/client/gui/timeline/TimelineEditorModel.h @@ -0,0 +1,62 @@ +#pragma once + +#include "core/domain/Project.h" + +#include +#include +#include +#include + +// TimelineEditorModel:把时间轴的“可视数据 + 选择状态 + 命中测试结果”从 Widget 拆出来, +// 便于支持多轨、统一交互逻辑,并降低 TimelineWidget 的复杂度。 +// +// 阶段1最小实现:仅负责存储轨道视图数据与选择状态;Widget 仍自行处理鼠标事件, +// 但数据不再用固定 4 条 QVector 传递。 + +class TimelineEditorModel final { +public: + enum class TrackKind { Location, UserScale, Image, Visibility }; + + struct KeyRef { + TrackKind kind {TrackKind::Location}; + int frame {-1}; + }; + + struct TrackView { + TrackKind kind {TrackKind::Location}; + QString label; + QColor color; + QVector keyframes; // sorted unique frames + }; + + void clear() { + m_tracks.clear(); + clearSelection(); + clearInterval(); + } + + void setTracks(QVector tracks) { m_tracks = std::move(tracks); } + const QVector& tracks() const { return m_tracks; } + + void clearSelection() { m_hasSelectedKey = false; m_selected = {}; } + void setSelectedKey(const KeyRef& ref) { m_hasSelectedKey = (ref.frame >= 0); m_selected = ref; } + bool hasSelectedKey() const { return m_hasSelectedKey; } + const KeyRef& selectedKey() const { return m_selected; } + + void clearInterval() { m_selStart = -1; m_selEnd = -1; } + void setInterval(int a, int b) { + if (a < 0 || b < 0) { clearInterval(); return; } + m_selStart = std::min(a, b); + m_selEnd = std::max(a, b); + } + int intervalStart() const { return m_selStart; } + int intervalEnd() const { return m_selEnd; } + +private: + QVector m_tracks; + bool m_hasSelectedKey {false}; + KeyRef m_selected; + int m_selStart {-1}; + int m_selEnd {-1}; +}; + diff --git a/client/gui/timeline/TimelineWidget.cpp b/client/gui/timeline/TimelineWidget.cpp index 13ff7d1..2f25b66 100644 --- a/client/gui/timeline/TimelineWidget.cpp +++ b/client/gui/timeline/TimelineWidget.cpp @@ -1,5 +1,7 @@ #include "timeline/TimelineWidget.h" +#include "timeline/TimelineEditorModel.h" + #include #include @@ -38,7 +40,11 @@ TimelineWidget::TimelineWidget(QWidget* parent) // 单行紧凑:标尺 + 轨道(帧号画在播放头处,随坐标轴滚动) setMinimumHeight(kRulerHeight + 18 + 6); setFocusPolicy(Qt::StrongFocus); - setToolTip(QStringLiteral("片段时间轴(固定 0-600):左键拖动播放头;滚轮:逐帧")); + setToolTip({}); +} + +void TimelineWidget::setModel(TimelineEditorModel* model) { + m_model = model; } void TimelineWidget::resizeEvent(QResizeEvent* e) { @@ -47,8 +53,10 @@ void TimelineWidget::resizeEvent(QResizeEvent* e) { } void TimelineWidget::setFrameRange(int start, int end) { - (void)start; - (void)end; + m_start = std::max(0, start); + m_end = std::max(m_start + 1, end); + m_currentFrame = std::clamp(m_currentFrame, m_start, m_end - 1); + setSelectionRange(m_selStart, m_selEnd); update(); } @@ -57,7 +65,7 @@ void TimelineWidget::setCurrentFrame(int frame) { } void TimelineWidget::setCurrentFrameProgrammatic(int frame) { - const int f = std::clamp(frame, kStart, kEnd - 1); + const int f = std::clamp(frame, m_start, m_end - 1); if (m_currentFrame == f) { return; } @@ -74,8 +82,8 @@ void TimelineWidget::setSelectionRange(int start, int end) { } const int lo = std::min(start, end); const int hi = std::max(start, end); - m_selStart = std::clamp(lo, kStart, kEnd - 1); - m_selEnd = std::clamp(hi, m_selStart, kEnd - 1); + m_selStart = std::clamp(lo, m_start, m_end - 1); + m_selEnd = std::clamp(hi, m_selStart, m_end - 1); update(); } @@ -111,6 +119,29 @@ void TimelineWidget::setKeyframeTracks(const QVector& locFrames, m_selKeyFrame = -1; emit keyframeSelectionChanged(m_selKeyKind, m_selKeyFrame); } + if (m_model) { + QVector tracks; + tracks.reserve(4); + tracks.push_back({TimelineEditorModel::TrackKind::Image, QStringLiteral("图像"), QColor(70, 130, 240), m_imgFrames}); + tracks.push_back({TimelineEditorModel::TrackKind::Location, QStringLiteral("位置"), QColor(240, 110, 40), m_locFrames}); + tracks.push_back({TimelineEditorModel::TrackKind::UserScale, QStringLiteral("缩放"), QColor(80, 190, 90), m_scaleFrames}); + tracks.push_back({TimelineEditorModel::TrackKind::Visibility, QStringLiteral("显隐"), QColor(160, 100, 230), m_visFrames}); + m_model->setTracks(std::move(tracks)); + if (m_selKeyKind == KeyKind::None) { + m_model->clearSelection(); + } else { + TimelineEditorModel::TrackKind kind = TimelineEditorModel::TrackKind::Location; + switch (m_selKeyKind) { + case KeyKind::Location: kind = TimelineEditorModel::TrackKind::Location; break; + case KeyKind::UserScale: kind = TimelineEditorModel::TrackKind::UserScale; break; + case KeyKind::Image: kind = TimelineEditorModel::TrackKind::Image; break; + case KeyKind::Visibility: kind = TimelineEditorModel::TrackKind::Visibility; break; + default: break; + } + m_model->setSelectedKey({kind, m_selKeyFrame}); + } + m_model->setInterval(m_selStart, m_selEnd); + } update(); } @@ -131,6 +162,21 @@ void TimelineWidget::setToolKeyframeTracks(const QVector& locFrames, m_selKeyFrame = -1; emit keyframeSelectionChanged(m_selKeyKind, m_selKeyFrame); } + if (m_model) { + QVector tracks; + tracks.reserve(2); + tracks.push_back({TimelineEditorModel::TrackKind::Location, QStringLiteral("位置"), QColor(240, 110, 40), m_locFrames}); + tracks.push_back({TimelineEditorModel::TrackKind::Visibility, QStringLiteral("显隐"), QColor(160, 100, 230), m_visFrames}); + m_model->setTracks(std::move(tracks)); + if (m_selKeyKind == KeyKind::None) { + m_model->clearSelection(); + } else { + TimelineEditorModel::TrackKind kind = TimelineEditorModel::TrackKind::Location; + if (m_selKeyKind == KeyKind::Visibility) kind = TimelineEditorModel::TrackKind::Visibility; + m_model->setSelectedKey({kind, m_selKeyFrame}); + } + m_model->setInterval(m_selStart, m_selEnd); + } update(); } @@ -151,8 +197,8 @@ QRect TimelineWidget::keyAreaRect() const { } double TimelineWidget::frameToXf(double frame) const { - const double pxf = double(contentWidth()) / double(std::max(1, kEnd - kStart)); - return double(contentLeft()) + (frame - double(kStart)) * pxf; + const double pxf = double(contentWidth()) / double(std::max(1, m_end - m_start)); + return double(contentLeft()) + (frame - double(m_start)) * pxf; } int TimelineWidget::frameToX(int frame) const { @@ -160,8 +206,8 @@ int TimelineWidget::frameToX(int frame) const { } double TimelineWidget::xToFramef(int x) const { - const double pxf = double(contentWidth()) / double(std::max(1, kEnd - kStart)); - return double(kStart) + double(x - contentLeft()) / std::max(pxf, 1e-9); + const double pxf = double(contentWidth()) / double(std::max(1, m_end - m_start)); + return double(m_start) + double(x - contentLeft()) / std::max(pxf, 1e-9); } int TimelineWidget::xToFrame(int x) const { @@ -169,7 +215,7 @@ int TimelineWidget::xToFrame(int x) const { } void TimelineWidget::setFrameInternal(int frame, bool commit) { - const int f = std::clamp(frame, kStart, kEnd - 1); + const int f = std::clamp(frame, m_start, m_end - 1); // 松手时若帧未变:只发 committed,禁止再发 scrubbed,否则主窗口会双次求值/刷新导致帧号与红线闪烁 if (m_currentFrame == f) { if (commit) { @@ -194,9 +240,9 @@ void TimelineWidget::paintEvent(QPaintEvent*) { const QRect cr = contentRect(); const QRect kr = keyAreaRect(); const QRect rr = rulerRect(); - const double fLeft = double(kStart); - const int visMin = kStart; - const int visMax = kEnd; + const double fLeft = double(m_start); + const int visMin = m_start; + const int visMax = m_end; auto frameVisible = [&](int fr) { return fr >= visMin && fr <= visMax; }; @@ -292,7 +338,7 @@ void TimelineWidget::paintEvent(QPaintEvent*) { p.setBrush(palette().alternateBase()); p.drawRoundedRect(rr, 3, 3); - const double pxf = double(contentWidth()) / double(std::max(1, kEnd - kStart)); + const double pxf = double(contentWidth()) / double(std::max(1, m_end - m_start)); const int major = pickMajorStep(pxf); const int minor = pickMinorStep(major); QPen minorPen(QColor(60, 60, 60, 100)); diff --git a/client/gui/timeline/TimelineWidget.h b/client/gui/timeline/TimelineWidget.h index 5eb4eee..1f9bba5 100644 --- a/client/gui/timeline/TimelineWidget.h +++ b/client/gui/timeline/TimelineWidget.h @@ -3,13 +3,13 @@ #include class QResizeEvent; +class TimelineEditorModel; class TimelineWidget final : public QWidget { Q_OBJECT public: explicit TimelineWidget(QWidget* parent = nullptr); - // 兼容旧接口:NLA/片段系统下时间轴始终固定为 0..600(local frame)。 void setFrameRange(int start, int end); void setCurrentFrame(int frame); /// 由主窗口同步工程帧时调用:不发射 frameScrubbed,避免与拖动/刷新打架造成数字闪烁 @@ -20,6 +20,9 @@ public: int selectionStart() const { return m_selStart; } int selectionEnd() const { return m_selEnd; } + void setModel(TimelineEditorModel* model); + TimelineEditorModel* model() const { return m_model; } + // 轨道数据直接由上层提供(通常来自当前条带引用的 clip)。 void setKeyframeTracks(const QVector& locFrames, const QVector& scaleFrames, @@ -63,9 +66,9 @@ private: void setFrameInternal(int frame, bool commit); - static constexpr int kStart = 0; - static constexpr int kEnd = 600; // exclusive for mapping, inclusive for UI labels - int m_currentFrame = 0; // local frame: 0..599 + int m_start = 0; + int m_end = 600; // exclusive for mapping + int m_currentFrame = 0; int m_selStart = -1; int m_selEnd = -1; @@ -83,4 +86,6 @@ private: KeyKind m_selKeyKind = KeyKind::None; int m_selKeyFrame = -1; + + TimelineEditorModel* m_model = nullptr; // non-owning }; diff --git a/client/gui/widgets/CompactNumericSpinBox.cpp b/client/gui/widgets/CompactNumericSpinBox.cpp new file mode 100644 index 0000000..b3a8f5d --- /dev/null +++ b/client/gui/widgets/CompactNumericSpinBox.cpp @@ -0,0 +1,34 @@ +#include "widgets/CompactNumericSpinBox.h" + +#include +#include + +namespace gui { + +namespace { + +void polishCompact(QAbstractSpinBox* box, int maxWidthPx) { + if (!box) { + return; + } + box->setObjectName(QStringLiteral("CompactNumericSpin")); + box->setButtonSymbols(QAbstractSpinBox::UpDownArrows); + box->setFocusPolicy(Qt::StrongFocus); + box->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); + box->setMinimumWidth(22); + box->setMaximumWidth(maxWidthPx); +} + +} // namespace + +CompactIntSpinBox::CompactIntSpinBox(QWidget* parent, int maxWidthPx) + : QSpinBox(parent) { + polishCompact(this, maxWidthPx); +} + +CompactDoubleSpinBox::CompactDoubleSpinBox(QWidget* parent, int maxWidthPx) + : QDoubleSpinBox(parent) { + polishCompact(this, maxWidthPx); +} + +} // namespace gui diff --git a/client/gui/widgets/CompactNumericSpinBox.h b/client/gui/widgets/CompactNumericSpinBox.h new file mode 100644 index 0000000..47cae4f --- /dev/null +++ b/client/gui/widgets/CompactNumericSpinBox.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include + +namespace gui { + +/// 属性栏用紧凑数值框:固定最大宽度,避免撑开过宽。 +class CompactIntSpinBox final : public QSpinBox { +public: + explicit CompactIntSpinBox(QWidget* parent = nullptr, int maxWidthPx = 56); +}; + +class CompactDoubleSpinBox final : public QDoubleSpinBox { +public: + explicit CompactDoubleSpinBox(QWidget* parent = nullptr, int maxWidthPx = 58); +}; + +} // namespace gui diff --git a/client/gui/widgets/PropertyPanelControls.cpp b/client/gui/widgets/PropertyPanelControls.cpp new file mode 100644 index 0000000..f0a5f3a --- /dev/null +++ b/client/gui/widgets/PropertyPanelControls.cpp @@ -0,0 +1,82 @@ +#include "widgets/PropertyPanelControls.h" + +#include +#include +#include +#include +#include + +namespace gui { + +void polishPropertyStretchyTextField(QWidget* w) { + if (!w) { + return; + } + w->setMinimumWidth(0); + w->setMaximumWidth(QWIDGETSIZE_MAX); + const QSizePolicy sp = w->sizePolicy(); + w->setSizePolicy(QSizePolicy::MinimumExpanding, sp.verticalPolicy()); +} + +void polishPropertyFormLayout(QFormLayout* form) { + if (!form) { + return; + } + form->setContentsMargins(0, 0, 0, 0); + form->setSpacing(6); + form->setHorizontalSpacing(8); + form->setVerticalSpacing(6); + form->setRowWrapPolicy(QFormLayout::WrapAllRows); + form->setLabelAlignment(Qt::AlignLeft | Qt::AlignTop); + form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); +} + +void polishPropertyVBoxLayout(QVBoxLayout* lay) { + if (!lay) { + return; + } + lay->setContentsMargins(0, 0, 0, 0); + lay->setSpacing(8); +} + +PropertyPanelLineEdit::PropertyPanelLineEdit(QWidget* parent) + : QLineEdit(parent) { + setObjectName(QStringLiteral("PropertyPanelLineEdit")); + setMinimumWidth(0); + setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Fixed); + setMaximumHeight(26); +} + +PropertyPanelComboBox::PropertyPanelComboBox(QWidget* parent) + : QComboBox(parent) { + setObjectName(QStringLiteral("PropertyPanelComboBox")); + setMinimumWidth(0); + setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Fixed); + setMaximumHeight(26); +} + +PropertyPanelCheckBox::PropertyPanelCheckBox(const QString& text, QWidget* parent) + : QCheckBox(text, parent) { + setObjectName(QStringLiteral("PropertyPanelCheckBox")); + setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Fixed); +} + +PropertyPanelPushButton::PropertyPanelPushButton(const QString& text, QWidget* parent) + : QPushButton(text, parent) { + setObjectName(QStringLiteral("PropertyPanelPushButton")); + setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Fixed); + setMaximumHeight(26); +} + +// TextEdit +PropertyPanelTextEdit::PropertyPanelTextEdit(QWidget* parent) + : QTextEdit(parent) { + setObjectName(QStringLiteral("PropertyPanelTextEdit")); + setMinimumWidth(80); + setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Minimum); + setWordWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); + setLineWrapMode(QTextEdit::WidgetWidth); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); +} + +} // namespace gui diff --git a/client/gui/widgets/PropertyPanelControls.h b/client/gui/widgets/PropertyPanelControls.h new file mode 100644 index 0000000..de9f790 --- /dev/null +++ b/client/gui/widgets/PropertyPanelControls.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace gui { + +/// 单行文本/容器:随面板宽度伸缩,不强行指定最小像素宽。 +void polishPropertyStretchyTextField(QWidget* w); + +/// 紧凑属性表:标签在上、控件在下(默认 ~200px 宽即可完整显示,无并排裁切)。 +void polishPropertyFormLayout(QFormLayout* form); + +void polishPropertyVBoxLayout(QVBoxLayout* lay); + +class PropertyPanelLineEdit final : public QLineEdit { +public: + explicit PropertyPanelLineEdit(QWidget* parent = nullptr); +}; + +class PropertyPanelComboBox final : public QComboBox { +public: + explicit PropertyPanelComboBox(QWidget* parent = nullptr); +}; + +class PropertyPanelCheckBox final : public QCheckBox { +public: + explicit PropertyPanelCheckBox(const QString& text, QWidget* parent = nullptr); +}; + +class PropertyPanelPushButton final : public QPushButton { +public: + explicit PropertyPanelPushButton(const QString& text, QWidget* parent = nullptr); +}; + +class PropertyPanelTextEdit final : public QTextEdit { +public: + explicit PropertyPanelTextEdit(QWidget* parent = nullptr); +}; + +} // namespace gui diff --git a/python_server/app/__init__.py b/python_server/app/__init__.py new file mode 100644 index 0000000..eefb79e --- /dev/null +++ b/python_server/app/__init__.py @@ -0,0 +1 @@ +"""HFUT Model Server 应用包(FastAPI 路由与服务)。""" diff --git a/python_server/app/deps.py b/python_server/app/deps.py new file mode 100644 index 0000000..57e2ba8 --- /dev/null +++ b/python_server/app/deps.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from pathlib import Path + +# python_server/ 根目录(app/ 的上一级) +APP_ROOT = Path(__file__).resolve().parent.parent +OUTPUT_DIR = APP_ROOT / "outputs" +OUTPUT_DIR.mkdir(parents=True, exist_ok=True) diff --git a/python_server/app/main.py b/python_server/app/main.py new file mode 100644 index 0000000..bce2566 --- /dev/null +++ b/python_server/app/main.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from fastapi import FastAPI + +from .routes import animate, depth, inpaint, meta, segment + +app = FastAPI(title="HFUT Model Server", version="0.1.0") + +app.include_router(meta.router) +app.include_router(depth.router) +app.include_router(segment.router) +app.include_router(inpaint.router) +app.include_router(animate.router) diff --git a/python_server/app/routes/__init__.py b/python_server/app/routes/__init__.py new file mode 100644 index 0000000..20025d4 --- /dev/null +++ b/python_server/app/routes/__init__.py @@ -0,0 +1 @@ +"""HTTP 路由(按领域拆分)。""" diff --git a/python_server/app/routes/animate.py b/python_server/app/routes/animate.py new file mode 100644 index 0000000..e32abfc --- /dev/null +++ b/python_server/app/routes/animate.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import datetime +import shutil +from typing import Any, Dict + +from fastapi import APIRouter +from PIL import Image + +from ..deps import OUTPUT_DIR +from ..schemas import AnimateRequest, CharacterAnimateRequest +from ..services.animation_frames import ( + align_size_to_input, + composite_rgba_on_background, + list_png_frames, + parse_background_color, + remove_background_by_color, +) +from ..services.image_io import b64_to_pil_image, pil_image_to_png_b64 +from ..services.predictors import get_animation_predictor + +router = APIRouter(tags=["animation"]) + + +@router.post("/animate") +def animate(req: AnimateRequest) -> Dict[str, Any]: + try: + model_name = req.model_name or "animatediff" + predictor = get_animation_predictor(model_name) + + result_path = predictor( + prompt=req.prompt, + negative_prompt=req.negative_prompt or "", + num_inference_steps=req.num_inference_steps, + guidance_scale=req.guidance_scale, + width=req.width, + height=req.height, + video_length=req.video_length, + seed=req.seed, + ) + + out_dir = OUTPUT_DIR / "animation" + out_dir.mkdir(parents=True, exist_ok=True) + ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + out_path = out_dir / f"{model_name}_{ts}.gif" + shutil.copy2(result_path, out_path) + + return {"success": True, "output_path": str(out_path)} + except Exception as e: + return {"success": False, "error": str(e)} + + +@router.post("/animate/character_sequence") +def animate_character_sequence(req: CharacterAnimateRequest) -> Dict[str, Any]: + """ + 输入透明背景角色 PNG,生成 30fps 角色动画 PNG 序列。 + + 流程: + - 将角色合成到指定纯色背景,作为 AnimateDiff 控制图 + - 以与输入相近、且对齐到 8 的分辨率生成 PNG 序列 + - 对每帧按背景色做色键剔除,返回透明背景 PNG 序列给前端 + """ + try: + model_name = req.model_name or "animatediff" + background_rgb = parse_background_color(req.background_color) + + character = b64_to_pil_image(req.image_b64).convert("RGBA") + orig_w, orig_h = character.size + run_w, run_h = align_size_to_input(orig_w, orig_h, req.max_side) + + predictor = get_animation_predictor(model_name) + + out_dir = OUTPUT_DIR / "animation" / "character_sequence" + out_dir.mkdir(parents=True, exist_ok=True) + ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + seq_dir = out_dir / f"{model_name}_{ts}" + seq_dir.mkdir(parents=True, exist_ok=True) + + control_path = seq_dir / "control_image.png" + control_image = composite_rgba_on_background( + character, + background_rgb, + size=(run_w, run_h), + ) + control_image.save(control_path) + + frame_dir = predictor( + prompt=req.prompt, + negative_prompt=req.negative_prompt or "", + num_inference_steps=req.num_inference_steps, + guidance_scale=req.guidance_scale, + width=run_w, + height=run_h, + video_length=req.video_length, + seed=req.seed, + control_image_path=str(control_path), + output_format="png_sequence", + ) + + frame_paths = list_png_frames(frame_dir) + if not frame_paths: + raise FileNotFoundError(f"未找到 AnimateDiff 输出 PNG 序列: {frame_dir}") + + frames: list[dict[str, Any]] = [] + processed_dir = seq_dir / "frames" + processed_dir.mkdir(parents=True, exist_ok=True) + for idx, frame_path in enumerate(frame_paths): + frame = remove_background_by_color( + Image.open(frame_path), + background_rgb, + tolerance=req.background_tolerance, + output_size=(orig_w, orig_h), + ) + out_path = processed_dir / f"{idx:04d}.png" + frame.save(out_path) + frames.append( + { + "index": idx, + "image_b64": pil_image_to_png_b64(frame), + "path": str(out_path), + } + ) + + return { + "success": True, + "fps": 30, + "frame_count": len(frames), + "width": orig_w, + "height": orig_h, + "background_color": req.background_color, + "output_dir": str(processed_dir), + "frames": frames, + } + except Exception as e: + return {"success": False, "error": str(e), "frames": []} diff --git a/python_server/app/routes/depth.py b/python_server/app/routes/depth.py new file mode 100644 index 0000000..7f288c9 --- /dev/null +++ b/python_server/app/routes/depth.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import numpy as np +from fastapi import APIRouter, HTTPException +from fastapi.responses import Response + +from ..schemas import DepthRequest +from ..services.depth_io import depth_to_png16_bytes +from ..services.image_io import b64_to_pil_image +from ..services.predictors import get_depth_predictor + +router = APIRouter(tags=["depth"]) + + +@router.post("/depth") +def depth(req: DepthRequest): + """ + 计算深度并直接返回二进制 PNG(16-bit 灰度)。 + + 约束: + - 前端不传/不选模型;模型选择写死在后端 config.py + - 成功:HTTP 200 + Content-Type: image/png + - 失败:HTTP 500,detail 为错误信息 + """ + try: + pil = b64_to_pil_image(req.image_b64).convert("RGB") + predictor = get_depth_predictor() + depth_arr = predictor(pil) # type: ignore[misc] + png_bytes = depth_to_png16_bytes(np.asarray(depth_arr)) + return Response(content=png_bytes, media_type="image/png") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e diff --git a/python_server/app/routes/inpaint.py b/python_server/app/routes/inpaint.py new file mode 100644 index 0000000..e7e99b8 --- /dev/null +++ b/python_server/app/routes/inpaint.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from typing import Any, Dict + +from fastapi import APIRouter + +from ..deps import OUTPUT_DIR +from ..schemas import InpaintRequest +from ..services.image_io import b64_to_pil_image, default_half_mask, pil_image_to_png_b64 +from ..services.predictors import get_inpaint_predictor + +router = APIRouter(tags=["inpaint"]) + + +@router.post("/inpaint") +def inpaint(req: InpaintRequest) -> Dict[str, Any]: + try: + model_name = req.model_name or "sdxl_inpaint" + pil = b64_to_pil_image(req.image_b64).convert("RGB") + + if req.mask_b64: + mask = b64_to_pil_image(req.mask_b64).convert("L") + else: + mask = default_half_mask(pil) + + predictor = get_inpaint_predictor(model_name) + out = predictor( + pil, + mask, + req.prompt or "", + req.negative_prompt or "", + strength=req.strength, + max_side=req.max_side, + ) + + out_dir = OUTPUT_DIR / "inpaint" + out_dir.mkdir(parents=True, exist_ok=True) + out_path = out_dir / f"{model_name}_inpaint.png" + out.save(out_path) + + return { + "success": True, + "output_path": str(out_path), + "output_image_b64": pil_image_to_png_b64(out), + } + except Exception as e: + return {"success": False, "error": str(e)} diff --git a/python_server/app/routes/meta.py b/python_server/app/routes/meta.py new file mode 100644 index 0000000..c446df3 --- /dev/null +++ b/python_server/app/routes/meta.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from typing import Any, Dict + +from fastapi import APIRouter + +router = APIRouter(tags=["meta"]) + + +@router.get("/models") +def get_models() -> Dict[str, Any]: + """ + 返回一个兼容 Qt 前端的 schema: + { + "models": { + "depth": { "key": { "name": "..."} ... }, + "segment": { ... }, + "inpaint": { "key": { "name": "...", "params": [...] } ... } + } + } + """ + return { + "models": { + "depth": { + "midas": {"name": "MiDaS (default)"}, + "zoedepth_n": {"name": "ZoeDepth (ZoeD_N)"}, + "zoedepth_k": {"name": "ZoeDepth (ZoeD_K)"}, + "zoedepth_nk": {"name": "ZoeDepth (ZoeD_NK)"}, + "depth_anything_v2_vits": {"name": "Depth Anything V2 (vits)"}, + "depth_anything_v2_vitb": {"name": "Depth Anything V2 (vitb)"}, + "depth_anything_v2_vitl": {"name": "Depth Anything V2 (vitl)"}, + "depth_anything_v2_vitg": {"name": "Depth Anything V2 (vitg)"}, + "dpt_large": {"name": "DPT (large)"}, + "dpt_hybrid": {"name": "DPT (hybrid)"}, + "midas_dpt_beit_large_512": {"name": "MiDaS (dpt_beit_large_512)"}, + "midas_dpt_swin2_large_384": {"name": "MiDaS (dpt_swin2_large_384)"}, + "midas_dpt_swin2_tiny_256": {"name": "MiDaS (dpt_swin2_tiny_256)"}, + "midas_dpt_levit_224": {"name": "MiDaS (dpt_levit_224)"}, + }, + "segment": { + "sam": {"name": "SAM (vit_h)"}, + "mask2former_debug": {"name": "SAM (compat mask2former_debug)"}, + "mask2former": {"name": "Mask2Former (not implemented)"}, + }, + "inpaint": { + "copy": {"name": "Copy (no-op)", "params": []}, + "sdxl_inpaint": { + "name": "SDXL Inpaint", + "params": [ + {"id": "prompt", "label": "提示词", "optional": True}, + ], + }, + "lama": {"name": "LaMa (Erase / Remove Object)", "params": []}, + "controlnet": { + "name": "ControlNet Inpaint (canny)", + "params": [ + {"id": "prompt", "label": "提示词", "optional": True}, + ], + }, + }, + "animation": { + "animatediff": { + "name": "AnimateDiff (Text-to-Video)", + "params": [ + {"id": "prompt", "label": "提示词", "optional": False}, + {"id": "negative_prompt", "label": "负向提示词", "optional": True}, + {"id": "num_inference_steps", "label": "采样步数", "optional": True}, + {"id": "guidance_scale", "label": "CFG Scale", "optional": True}, + {"id": "width", "label": "宽度", "optional": True}, + {"id": "height", "label": "高度", "optional": True}, + {"id": "video_length", "label": "帧数", "optional": True}, + {"id": "seed", "label": "随机种子", "optional": True}, + ], + }, + "character_sequence": { + "name": "Character PNG Sequence (30 FPS)", + "endpoint": "/animate/character_sequence", + "params": [ + {"id": "image_b64", "label": "透明背景角色 PNG", "optional": False}, + {"id": "prompt", "label": "动画提示词", "optional": False}, + {"id": "background_color", "label": "纯色背景", "optional": True}, + {"id": "video_length", "label": "帧数", "optional": True}, + {"id": "background_tolerance", "label": "背景剔除阈值", "optional": True}, + ], + }, + }, + } + } + + +@router.get("/health") +def health() -> Dict[str, str]: + return {"status": "ok"} diff --git a/python_server/app/routes/segment.py b/python_server/app/routes/segment.py new file mode 100644 index 0000000..d371611 --- /dev/null +++ b/python_server/app/routes/segment.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from typing import Any, Dict + +import numpy as np +from fastapi import APIRouter +from PIL import Image + +from ..deps import OUTPUT_DIR +from ..schemas import SamPromptSegmentRequest, SegmentRequest +from ..services.image_io import b64_to_pil_image +from ..services.predictors import get_seg_predictor +from model.Seg.seg_loader import expand_mask, mask_to_contour_xy, run_sam_prompt + +router = APIRouter(tags=["segment"]) + + +@router.post("/segment") +def segment(req: SegmentRequest) -> Dict[str, Any]: + try: + model_name = req.model_name or "sam" + pil = b64_to_pil_image(req.image_b64).convert("RGB") + rgb = np.array(pil, dtype=np.uint8) + + predictor = get_seg_predictor(model_name) + label_map = predictor(rgb).astype(np.int32) + + out_dir = OUTPUT_DIR / "segment" + out_dir.mkdir(parents=True, exist_ok=True) + out_path = out_dir / f"{model_name}_label.png" + Image.fromarray(np.clip(label_map, 0, 255).astype(np.uint8), mode="L").save(out_path) + + return {"success": True, "label_path": str(out_path)} + except Exception as e: + return {"success": False, "error": str(e)} + + +@router.post("/segment/sam_prompt") +def segment_sam_prompt(req: SamPromptSegmentRequest) -> Dict[str, Any]: + """ + 交互式 SAM:裁剪图 + 点/框提示,返回掩膜外轮廓点列(裁剪像素坐标)。 + """ + try: + pil = b64_to_pil_image(req.image_b64).convert("RGB") + rgb = np.array(pil, dtype=np.uint8) + h, w = rgb.shape[0], rgb.shape[1] + + if req.overlay_b64: + ov = b64_to_pil_image(req.overlay_b64) + if ov.size != (w, h): + return { + "success": False, + "error": f"overlay 尺寸 {ov.size} 与 image {w}x{h} 不一致", + "contour": [], + } + + if len(req.point_coords) != len(req.point_labels): + return { + "success": False, + "error": "point_coords 与 point_labels 长度不一致", + "contour": [], + } + if len(req.point_coords) < 1: + return {"success": False, "error": "至少需要一个提示点", "contour": []} + + pc = np.array(req.point_coords, dtype=np.float32) + if pc.ndim != 2 or pc.shape[1] != 2: + return {"success": False, "error": "point_coords 每项须为 [x,y]", "contour": []} + + pl = np.array(req.point_labels, dtype=np.int64) + box = np.array(req.box_xyxy, dtype=np.float32) + + mask = run_sam_prompt(rgb, pc, pl, box_xyxy=box) + if not np.any(mask): + return {"success": False, "error": "SAM 未产生有效掩膜", "contour": []} + + mask = expand_mask(mask, int(req.expand_px)) + contour = mask_to_contour_xy(mask, epsilon_px=2.0) + if len(contour) < 3: + return {"success": False, "error": "轮廓点数不足", "contour": []} + + return {"success": True, "contour": contour, "error": None} + except Exception as e: + return {"success": False, "error": str(e), "contour": []} diff --git a/python_server/app/schemas.py b/python_server/app/schemas.py new file mode 100644 index 0000000..21a0a06 --- /dev/null +++ b/python_server/app/schemas.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, Field + + +class ImageInput(BaseModel): + image_b64: str = Field(..., description="PNG/JPG 编码后的 base64(不含 data: 前缀)") + model_name: Optional[str] = Field(None, description="模型 key(来自 /models)") + + +class DepthRequest(ImageInput): + pass + + +class SegmentRequest(ImageInput): + pass + + +class SamPromptSegmentRequest(BaseModel): + image_b64: str = Field(..., description="裁剪后的 RGB 图 base64(PNG/JPG)") + overlay_b64: Optional[str] = Field( + None, + description="与裁剪同尺寸的标记叠加 PNG base64(可选;当前用于校验尺寸一致)", + ) + point_coords: list[list[float]] = Field( + ..., + description="裁剪坐标系下的提示点 [[x,y], ...]", + ) + point_labels: list[int] = Field( + ..., + description="与 point_coords 等长:1=前景,0=背景", + ) + box_xyxy: list[float] = Field( + ..., + description="裁剪内笔画紧包围盒 [x1,y1,x2,y2](像素)", + min_length=4, + max_length=4, + ) + expand_px: int = Field( + 2, + ge=0, + le=20, + description="对输出 mask 做轻微膨胀的像素半径(0 表示不扩大;建议 1~3)", + ) + + +class InpaintRequest(ImageInput): + prompt: Optional[str] = Field("", description="补全 prompt") + strength: float = Field(0.8, ge=0.0, le=1.0) + negative_prompt: Optional[str] = Field("", description="负向 prompt") + mask_b64: Optional[str] = Field(None, description="mask PNG base64(可选)") + max_side: int = Field(1024, ge=128, le=2048) + + +class AnimateRequest(BaseModel): + model_name: Optional[str] = Field(None, description="模型 key(来自 /models)") + prompt: str = Field(..., description="文本提示词") + negative_prompt: Optional[str] = Field("", description="负向提示词") + num_inference_steps: int = Field(25, ge=1, le=200) + guidance_scale: float = Field(8.0, ge=0.0, le=30.0) + width: int = Field(512, ge=128, le=2048) + height: int = Field(512, ge=128, le=2048) + video_length: int = Field(16, ge=1, le=128) + seed: int = Field(-1, description="-1 表示随机种子") + + +class CharacterAnimateRequest(BaseModel): + image_b64: str = Field(..., description="透明背景角色 PNG 的 base64(不含 data: 前缀)") + model_name: Optional[str] = Field(None, description="模型 key(来自 /models)") + prompt: str = Field(..., description="角色动画提示词") + negative_prompt: Optional[str] = Field("", description="负向提示词") + background_color: str = Field( + "#00FF00", + description="生成时使用的纯色背景,支持 #RRGGBB 或 R,G,B", + ) + background_tolerance: int = Field( + 40, + ge=0, + le=255, + description="自动剔除背景的 RGB 色差阈值,越大剔除越激进", + ) + num_inference_steps: int = Field(25, ge=1, le=200) + guidance_scale: float = Field(8.0, ge=0.0, le=30.0) + video_length: int = Field(16, ge=1, le=128, description="前端请求生成的帧数") + seed: int = Field(-1, description="-1 表示随机种子") + max_side: int = Field( + 768, + ge=128, + le=2048, + description="推理分辨率长边上限;会保持输入比例并对齐到 8 的倍数", + ) diff --git a/python_server/app/services/__init__.py b/python_server/app/services/__init__.py new file mode 100644 index 0000000..5d3d17a --- /dev/null +++ b/python_server/app/services/__init__.py @@ -0,0 +1 @@ +"""业务服务层(预测器缓存、图像编解码等)。""" diff --git a/python_server/app/services/animation_frames.py b/python_server/app/services/animation_frames.py new file mode 100644 index 0000000..47b24a4 --- /dev/null +++ b/python_server/app/services/animation_frames.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from collections import deque +from pathlib import Path + +import numpy as np +from PIL import Image + + +def parse_background_color(value: str) -> tuple[int, int, int]: + text = value.strip() + if text.startswith("#"): + hex_text = text[1:] + if len(hex_text) != 6: + raise ValueError("background_color 使用 #RRGGBB 时必须是 6 位十六进制") + try: + return tuple(int(hex_text[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore[return-value] + except ValueError as e: + raise ValueError(f"background_color 不是有效十六进制颜色: {value}") from e + + parts = [p.strip() for p in text.split(",")] + if len(parts) != 3: + raise ValueError("background_color 必须为 #RRGGBB 或 R,G,B") + try: + rgb = tuple(int(p) for p in parts) + except ValueError as e: + raise ValueError(f"background_color 不是有效 RGB 颜色: {value}") from e + if any(c < 0 or c > 255 for c in rgb): + raise ValueError("background_color 的 RGB 分量必须在 0~255") + return rgb # type: ignore[return-value] + + +def align_size_to_input(width: int, height: int, max_side: int) -> tuple[int, int]: + run_w, run_h = width, height + if max(width, height) > max_side: + scale = max_side / float(max(width, height)) + run_w = int(round(width * scale)) + run_h = int(round(height * scale)) + + run_w = max(8, run_w - (run_w % 8)) + run_h = max(8, run_h - (run_h % 8)) + return run_w, run_h + + +def composite_rgba_on_background( + image: Image.Image, + background_rgb: tuple[int, int, int], + size: tuple[int, int], +) -> Image.Image: + rgba = image.convert("RGBA") + if rgba.size != size: + rgba = rgba.resize(size, resample=Image.BICUBIC) + + bg = Image.new("RGB", size, background_rgb) + alpha = rgba.getchannel("A") + bg.paste(rgba.convert("RGB"), mask=alpha) + return bg + + +def remove_background_by_color( + image: Image.Image, + background_rgb: tuple[int, int, int], + tolerance: int, + output_size: tuple[int, int] | None = None, +) -> Image.Image: + rgba = image.convert("RGBA") + if output_size is not None and rgba.size != output_size: + rgba = rgba.resize(output_size, resample=Image.BICUBIC) + + arr = np.asarray(rgba, dtype=np.uint8).copy() + rgb = arr[:, :, :3].astype(np.int16) + bg = np.array(background_rgb, dtype=np.int16).reshape(1, 1, 3) + diff = np.abs(rgb - bg) + bg_like = np.max(diff, axis=2) <= int(tolerance) + bg_mask = _edge_connected_mask(bg_like) + arr[bg_mask, 3] = 0 + return Image.fromarray(arr, mode="RGBA") + + +def list_png_frames(frame_dir: Path) -> list[Path]: + frames = [p for p in frame_dir.iterdir() if p.is_file() and p.suffix.lower() == ".png"] + return sorted(frames, key=lambda p: p.name) + + +def _edge_connected_mask(mask: np.ndarray) -> np.ndarray: + h, w = mask.shape + visited = np.zeros((h, w), dtype=bool) + queue: deque[tuple[int, int]] = deque() + + for x in range(w): + if mask[0, x]: + queue.append((0, x)) + if mask[h - 1, x]: + queue.append((h - 1, x)) + for y in range(h): + if mask[y, 0]: + queue.append((y, 0)) + if mask[y, w - 1]: + queue.append((y, w - 1)) + + while queue: + y, x = queue.popleft() + if visited[y, x] or not mask[y, x]: + continue + visited[y, x] = True + if y > 0: + queue.append((y - 1, x)) + if y + 1 < h: + queue.append((y + 1, x)) + if x > 0: + queue.append((y, x - 1)) + if x + 1 < w: + queue.append((y, x + 1)) + + return visited diff --git a/python_server/app/services/depth_io.py b/python_server/app/services/depth_io.py new file mode 100644 index 0000000..fb5d8f6 --- /dev/null +++ b/python_server/app/services/depth_io.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import io + +import numpy as np +from PIL import Image + + +def depth_to_png16_bytes(depth: np.ndarray) -> bytes: + depth = np.asarray(depth, dtype=np.float32) + dmin = float(depth.min()) + dmax = float(depth.max()) + if dmax > dmin: + norm = (depth - dmin) / (dmax - dmin) + else: + norm = np.zeros_like(depth, dtype=np.float32) + # 前后端约定:最远=255,最近=0(8-bit) + u8 = ((1.0 - norm) * 255.0).clip(0, 255).astype(np.uint8) + img = Image.fromarray(u8, mode="L") + buf = io.BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() diff --git a/python_server/app/services/image_io.py b/python_server/app/services/image_io.py new file mode 100644 index 0000000..3720f92 --- /dev/null +++ b/python_server/app/services/image_io.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import base64 +import io + +from PIL import Image, ImageDraw + + +def b64_to_pil_image(b64: str) -> Image.Image: + raw = base64.b64decode(b64) + return Image.open(io.BytesIO(raw)) + + +def pil_image_to_png_b64(img: Image.Image) -> str: + buf = io.BytesIO() + img.save(buf, format="PNG") + return base64.b64encode(buf.getvalue()).decode("ascii") + + +def default_half_mask(img: Image.Image) -> Image.Image: + w, h = img.size + mask = Image.new("L", (w, h), 0) + draw = ImageDraw.Draw(mask) + draw.rectangle([w // 2, 0, w, h], fill=255) + return mask diff --git a/python_server/app/services/predictors.py b/python_server/app/services/predictors.py new file mode 100644 index 0000000..61e2523 --- /dev/null +++ b/python_server/app/services/predictors.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from typing import Any + +from PIL import Image + +from config_loader import load_app_config, get_depth_backend_from_app +from model.Animation.animation_loader import ( + UnifiedAnimationConfig, + AnimationBackend, + build_animation_predictor, +) +from model.Depth.depth_loader import UnifiedDepthConfig, DepthBackend, build_depth_predictor +from model.Inpaint.inpaint_loader import UnifiedInpaintConfig, InpaintBackend, build_inpaint_predictor +from model.Seg.seg_loader import UnifiedSegConfig, SegBackend, build_seg_predictor + +_depth_predictor = None +_depth_backend: DepthBackend | None = None + +_seg_cache: dict[str, Any] = {} + +_inpaint_cache: dict[str, Any] = {} + +_animation_cache: dict[str, Any] = {} + + +def ensure_depth_predictor() -> None: + global _depth_predictor, _depth_backend + if _depth_predictor is not None and _depth_backend is not None: + return + + app_cfg = load_app_config() + backend_str = get_depth_backend_from_app(app_cfg) + try: + backend = DepthBackend(backend_str) + except Exception as e: + raise ValueError(f"config.py 中 depth.backend 不合法: {backend_str}") from e + + _depth_predictor, _depth_backend = build_depth_predictor(UnifiedDepthConfig(backend=backend)) + + +def get_depth_predictor(): + ensure_depth_predictor() + return _depth_predictor + + +def get_seg_predictor(model_name: str): + if model_name == "mask2former_debug": + model_name = "sam" + + if model_name in _seg_cache: + return _seg_cache[model_name] + + if model_name == "sam": + pred, _ = build_seg_predictor(UnifiedSegConfig(backend=SegBackend.SAM)) + _seg_cache[model_name] = pred + return pred + + if model_name == "mask2former": + pred, _ = build_seg_predictor(UnifiedSegConfig(backend=SegBackend.MASK2FORMER)) + _seg_cache[model_name] = pred + return pred + + raise ValueError(f"未知 segment model_name: {model_name}") + + +def get_inpaint_predictor(model_name: str): + if model_name in _inpaint_cache: + return _inpaint_cache[model_name] + + if model_name == "copy": + + def _copy(image: Image.Image, *_args, **_kwargs) -> Image.Image: + return image.convert("RGB") + + _inpaint_cache[model_name] = _copy + return _copy + + if model_name == "sdxl_inpaint": + pred, _ = build_inpaint_predictor(UnifiedInpaintConfig(backend=InpaintBackend.SDXL_INPAINT)) + _inpaint_cache[model_name] = pred + return pred + + if model_name == "controlnet": + pred, _ = build_inpaint_predictor(UnifiedInpaintConfig(backend=InpaintBackend.CONTROLNET)) + _inpaint_cache[model_name] = pred + return pred + + if model_name == "lama": + pred, _ = build_inpaint_predictor(UnifiedInpaintConfig(backend=InpaintBackend.LAMA)) + _inpaint_cache[model_name] = pred + return pred + + raise ValueError(f"未知 inpaint model_name: {model_name}") + + +def get_animation_predictor(model_name: str): + if model_name in _animation_cache: + return _animation_cache[model_name] + + if model_name == "animatediff": + pred, _ = build_animation_predictor( + UnifiedAnimationConfig(backend=AnimationBackend.ANIMATEDIFF) + ) + _animation_cache[model_name] = pred + return pred + + raise ValueError(f"未知 animation model_name: {model_name}") diff --git a/python_server/model/Inpaint/inpaint_loader.py b/python_server/model/Inpaint/inpaint_loader.py index 3cdf477..660ea63 100644 --- a/python_server/model/Inpaint/inpaint_loader.py +++ b/python_server/model/Inpaint/inpaint_loader.py @@ -5,7 +5,8 @@ from __future__ import annotations 当前支持: - SDXL Inpaint(diffusers AutoPipelineForInpainting) -- ControlNet(占位,暂未统一封装) +- ControlNet(Stable Diffusion + ControlNet Inpaint) +- LaMa(torchscript big-lama,本地封装) """ from dataclasses import dataclass @@ -26,6 +27,7 @@ from config_loader import ( class InpaintBackend(str, Enum): SDXL_INPAINT = "sdxl_inpaint" CONTROLNET = "controlnet" + LAMA = "lama" @dataclass @@ -278,11 +280,13 @@ def _make_controlnet_predictor(_: UnifiedInpaintConfig): image_run = image mask_run = mask - # control image:使用 canny 边缘作为约束(最通用) + # control image:使用 Canny 边缘作为条件(更通用、更稳定)。 + # 备注:当前项目的 ControlNet 权重未必是 inpaint 专用模型, + # 若直接把洞内置零作为条件,容易把输出“拉黑”。先回退到稳定方案。 rgb = np.array(image_run, dtype=np.uint8) edges = cv2.Canny(rgb, 100, 200) edges3 = np.stack([edges, edges, edges], axis=-1) - control_image = Image.fromarray(edges3) + control_image = Image.fromarray(edges3, mode="RGB") if device == "cuda": try: @@ -312,6 +316,37 @@ def _make_controlnet_predictor(_: UnifiedInpaintConfig): return _predict +def _make_lama_predictor(cfg: UnifiedInpaintConfig): + import torch + + app_cfg = load_app_config() + device = cfg.device + if device is None: + device = app_cfg.inpaint.device + device = "cuda" if device.startswith("cuda") and torch.cuda.is_available() else "cpu" + + from .lama_torchscript import LaMaTorchscript + + lama = LaMaTorchscript(device=device) + + def _predict( + image: Image.Image, + mask: Image.Image, + _prompt: str, + _negative_prompt: str = "", + strength: float = 0.8, + guidance_scale: float = 7.5, + num_inference_steps: int = 30, + max_side: int = 1024, + **_kwargs, + ) -> Image.Image: + # LaMa 不使用 prompt/negative_prompt,也不使用 strength/steps 等扩散参数 + _ = (strength, guidance_scale, num_inference_steps, max_side) + return lama(image, mask) + + return _predict + + def build_inpaint_predictor( cfg: UnifiedInpaintConfig | None = None, ) -> tuple[Callable[..., Image.Image], InpaintBackend]: @@ -326,6 +361,9 @@ def build_inpaint_predictor( if cfg.backend == InpaintBackend.CONTROLNET: return _make_controlnet_predictor(cfg), InpaintBackend.CONTROLNET + if cfg.backend == InpaintBackend.LAMA: + return _make_lama_predictor(cfg), InpaintBackend.LAMA + raise ValueError(f"不支持的补全后端: {cfg.backend}") diff --git a/python_server/model/Inpaint/lama_torchscript.py b/python_server/model/Inpaint/lama_torchscript.py new file mode 100644 index 0000000..c0ac88f --- /dev/null +++ b/python_server/model/Inpaint/lama_torchscript.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Optional + +import numpy as np +from PIL import Image + + +DEFAULT_LAMA_URL = os.environ.get( + "LAMA_MODEL_URL", + "https://github.com/Sanster/models/releases/download/add_big_lama/big-lama.pt", +) + + +def _default_cache_path() -> Path: + # 放在 python_server/outputs/cache 下,避免污染用户家目录 + here = Path(__file__).resolve() + cache_dir = here.parents[2] / "outputs" / "cache" + cache_dir.mkdir(parents=True, exist_ok=True) + return cache_dir / "big-lama.pt" + + +def _download(url: str, dst: Path) -> None: + import requests + + dst.parent.mkdir(parents=True, exist_ok=True) + resp = requests.get(url, stream=True, timeout=120) + resp.raise_for_status() + tmp = dst.with_suffix(dst.suffix + ".tmp") + with tmp.open("wb") as f: + for chunk in resp.iter_content(chunk_size=1024 * 1024): + if not chunk: + continue + f.write(chunk) + tmp.replace(dst) + + +def _pad_to_mod8(img: np.ndarray) -> tuple[np.ndarray, tuple[int, int]]: + h, w = img.shape[:2] + pad_h = (8 - (h % 8)) % 8 + pad_w = (8 - (w % 8)) % 8 + if pad_h == 0 and pad_w == 0: + return img, (0, 0) + out = np.pad(img, ((0, pad_h), (0, pad_w), (0, 0)), mode="reflect") + return out, (pad_h, pad_w) + + +def _unpad(img: np.ndarray, pad_hw: tuple[int, int]) -> np.ndarray: + pad_h, pad_w = pad_hw + if pad_h == 0 and pad_w == 0: + return img + h, w = img.shape[:2] + return img[: h - pad_h, : w - pad_w] + + +class LaMaTorchscript: + """ + 轻量 LaMa(big-lama.pt torchscript)推理封装。 + - 输入:RGB 图 + L mask(白=需要修补) + - 输出:RGB 图(同尺寸) + """ + + def __init__(self, device: str = "cpu", model_path: Optional[str] = None, model_url: Optional[str] = None): + import torch + + self.device = device + self.model_url = model_url or DEFAULT_LAMA_URL + p = Path(model_path) if model_path else _default_cache_path() + if not p.exists(): + _download(self.model_url, p) + self.model_path = p + self.model = torch.jit.load(str(self.model_path), map_location=device).eval() + + @staticmethod + def _norm_img_u8(rgb: np.ndarray) -> np.ndarray: + # [H,W,3] uint8 -> float32 [0,1] + return (rgb.astype(np.float32) / 255.0).clip(0.0, 1.0) + + def __call__(self, image: Image.Image, mask: Image.Image) -> Image.Image: + import torch + + img = np.asarray(image.convert("RGB"), dtype=np.uint8) + m = np.asarray(mask.convert("L"), dtype=np.uint8) + m = (m >= 128).astype(np.float32) # 1=hole + + img_f = self._norm_img_u8(img) + # pad to multiple of 8 + img_pad, pad_hw = _pad_to_mod8(img_f) + m_pad, _ = _pad_to_mod8(np.repeat(m[:, :, None], 3, axis=2)) + m1 = m_pad[:, :, 0] # [H,W] + + # torch: [1,C,H,W] + it = torch.from_numpy(img_pad).permute(2, 0, 1).unsqueeze(0).to(self.device) + mt = torch.from_numpy(m1).unsqueeze(0).unsqueeze(0).to(self.device) + + with torch.no_grad(): + out = self.model(it, mt) + + out_np = out[0].permute(1, 2, 0).detach().cpu().numpy() + out_np = np.clip(out_np * 255.0, 0.0, 255.0).astype(np.uint8) + out_np = _unpad(out_np, pad_hw) + return Image.fromarray(out_np, mode="RGB") + diff --git a/python_server/model/Seg/seg_loader.py b/python_server/model/Seg/seg_loader.py index 754cc4e..048f57d 100644 --- a/python_server/model/Seg/seg_loader.py +++ b/python_server/model/Seg/seg_loader.py @@ -95,6 +95,43 @@ def run_sam_prompt( return m +def expand_mask(mask_bool: np.ndarray, expand_px: int = 0) -> np.ndarray: + """ + 轻微扩大二值 mask(用于消除边界过软/过细带来的“漏边”观感)。 + + - expand_px <= 0: 原样返回 + - expand_px > 0: 做 dilation(膨胀)expand_px 像素级别 + """ + mask_bool = np.asarray(mask_bool, dtype=bool) + if expand_px <= 0: + return mask_bool + + # 优先用 OpenCV(通常更快) + try: + import cv2 # type: ignore[import] + + u8 = (mask_bool.astype(np.uint8) * 255) + k = int(expand_px) * 2 + 1 + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k)) + out = cv2.dilate(u8, kernel, iterations=1) + return out > 127 + except Exception: + pass + + # 回退到 scipy(若存在) + try: + from scipy.ndimage import binary_dilation # type: ignore[import] + + # 使用近似圆形结构元素 + r = int(expand_px) + yy, xx = np.ogrid[-r : r + 1, -r : r + 1] + selem = (xx * xx + yy * yy) <= (r * r) + return binary_dilation(mask_bool, structure=selem) + except Exception: + # 再回退:无依赖时就不扩大(保持可用性) + return mask_bool + + def mask_to_contour_xy( mask_bool: np.ndarray, epsilon_px: float = 2.0, diff --git a/python_server/requirements.txt b/python_server/requirements.txt new file mode 100644 index 0000000..0a85744 --- /dev/null +++ b/python_server/requirements.txt @@ -0,0 +1,82 @@ +accelerate==1.13.0 +annotated-doc==0.0.4 +annotated-types==0.7.0 +antlr4-python3-runtime==4.9.3 +anyio==4.12.1 +certifi==2026.2.25 +charset-normalizer==3.4.5 +click==8.3.1 +cuda-bindings==12.9.4 +cuda-pathfinder==1.4.1 +diffusers==0.37.0 +einops==0.8.2 +fastapi==0.135.1 +filelock==3.25.0 +fsspec==2026.2.0 +h11==0.16.0 +hf-xet==1.3.2 +httpcore==1.0.9 +httptools==0.7.1 +httpx==0.28.1 +huggingface_hub==1.6.0 +idna==3.11 +ImageIO==2.37.3 +importlib_metadata==8.7.1 +Jinja2==3.1.6 +markdown-it-py==4.0.0 +MarkupSafe==3.0.3 +mdurl==0.1.2 +mpmath==1.3.0 +networkx==3.6.1 +numpy==2.4.3 +nvidia-cublas-cu12==12.8.4.1 +nvidia-cuda-cupti-cu12==12.8.90 +nvidia-cuda-nvrtc-cu12==12.8.93 +nvidia-cuda-runtime-cu12==12.8.90 +nvidia-cudnn-cu12==9.10.2.21 +nvidia-cufft-cu12==11.3.3.83 +nvidia-cufile-cu12==1.13.1.3 +nvidia-curand-cu12==10.3.9.90 +nvidia-cusolver-cu12==11.7.3.90 +nvidia-cusparse-cu12==12.5.8.93 +nvidia-cusparselt-cu12==0.7.1 +nvidia-nccl-cu12==2.27.5 +nvidia-nvjitlink-cu12==12.8.93 +nvidia-nvshmem-cu12==3.4.5 +nvidia-nvtx-cu12==12.8.90 +omegaconf==2.3.0 +opencv-python==4.13.0.92 +packaging==26.0 +pillow==12.1.1 +psutil==7.2.2 +pydantic==2.12.5 +pydantic_core==2.41.5 +Pygments==2.19.2 +python-dotenv==1.2.2 +PyYAML==6.0.3 +regex==2026.2.28 +requests==2.32.5 +rich==14.3.3 +safetensors==0.7.0 +scipy==1.17.1 +setuptools==82.0.0 +shellingham==1.5.4 +starlette==0.52.1 +sympy==1.14.0 +timm==0.6.13 +tokenizers==0.22.2 +torch==2.10.0 +torchvision==0.25.0 +tqdm==4.67.3 +transformers==5.3.0 +triton==3.6.0 +typer==0.24.1 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +urllib3==2.6.3 +uvicorn==0.41.0 +uvloop==0.22.1 +watchfiles==1.1.1 +websockets==16.0 +xformers==0.0.35 +zipp==3.23.0 diff --git a/python_server/server.py b/python_server/server.py index 22a9ba2..baace26 100644 --- a/python_server/server.py +++ b/python_server/server.py @@ -1,488 +1,18 @@ +""" + source .venv/bin/activate # 仓库根 hfut-bishe/.venv + cd python_server && uvicorn app.main:app --host 0.0.0.0 --port 8000 +""" + from __future__ import annotations -import base64 -import datetime -import io import os -import shutil -from dataclasses import asdict -from pathlib import Path -from typing import Any, Dict, Optional -import numpy as np -from fastapi import FastAPI, HTTPException -from fastapi.responses import Response -from pydantic import BaseModel, Field -from PIL import Image, ImageDraw - -from config_loader import load_app_config, get_depth_backend_from_app -from model.Depth.depth_loader import UnifiedDepthConfig, DepthBackend, build_depth_predictor - -from model.Seg.seg_loader import ( - UnifiedSegConfig, - SegBackend, - build_seg_predictor, - mask_to_contour_xy, - run_sam_prompt, -) -from model.Inpaint.inpaint_loader import UnifiedInpaintConfig, InpaintBackend, build_inpaint_predictor -from model.Animation.animation_loader import ( - UnifiedAnimationConfig, - AnimationBackend, - build_animation_predictor, -) - - -APP_ROOT = Path(__file__).resolve().parent -OUTPUT_DIR = APP_ROOT / "outputs" -OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - -app = FastAPI(title="HFUT Model Server", version="0.1.0") - - -class ImageInput(BaseModel): - image_b64: str = Field(..., description="PNG/JPG 编码后的 base64(不含 data: 前缀)") - model_name: Optional[str] = Field(None, description="模型 key(来自 /models)") - - -class DepthRequest(ImageInput): - pass - - -class SegmentRequest(ImageInput): - pass - - -class SamPromptSegmentRequest(BaseModel): - image_b64: str = Field(..., description="裁剪后的 RGB 图 base64(PNG/JPG)") - overlay_b64: Optional[str] = Field( - None, - description="与裁剪同尺寸的标记叠加 PNG base64(可选;当前用于校验尺寸一致)", - ) - point_coords: list[list[float]] = Field( - ..., - description="裁剪坐标系下的提示点 [[x,y], ...]", - ) - point_labels: list[int] = Field( - ..., - description="与 point_coords 等长:1=前景,0=背景", - ) - box_xyxy: list[float] = Field( - ..., - description="裁剪内笔画紧包围盒 [x1,y1,x2,y2](像素)", - min_length=4, - max_length=4, - ) - - -class InpaintRequest(ImageInput): - prompt: Optional[str] = Field("", description="补全 prompt") - strength: float = Field(0.8, ge=0.0, le=1.0) - negative_prompt: Optional[str] = Field("", description="负向 prompt") - # 可选 mask(白色区域为重绘) - mask_b64: Optional[str] = Field(None, description="mask PNG base64(可选)") - # 推理缩放上限(避免 OOM) - max_side: int = Field(1024, ge=128, le=2048) - - -class AnimateRequest(BaseModel): - model_name: Optional[str] = Field(None, description="模型 key(来自 /models)") - prompt: str = Field(..., description="文本提示词") - negative_prompt: Optional[str] = Field("", description="负向提示词") - num_inference_steps: int = Field(25, ge=1, le=200) - guidance_scale: float = Field(8.0, ge=0.0, le=30.0) - width: int = Field(512, ge=128, le=2048) - height: int = Field(512, ge=128, le=2048) - video_length: int = Field(16, ge=1, le=128) - seed: int = Field(-1, description="-1 表示随机种子") - - -def _b64_to_pil_image(b64: str) -> Image.Image: - raw = base64.b64decode(b64) - return Image.open(io.BytesIO(raw)) - - -def _pil_image_to_png_b64(img: Image.Image) -> str: - buf = io.BytesIO() - img.save(buf, format="PNG") - return base64.b64encode(buf.getvalue()).decode("ascii") - - -def _depth_to_png16_b64(depth: np.ndarray) -> str: - depth = np.asarray(depth, dtype=np.float32) - dmin = float(depth.min()) - dmax = float(depth.max()) - if dmax > dmin: - norm = (depth - dmin) / (dmax - dmin) - else: - norm = np.zeros_like(depth, dtype=np.float32) - u16 = (norm * 65535.0).clip(0, 65535).astype(np.uint16) - img = Image.fromarray(u16, mode="I;16") - return _pil_image_to_png_b64(img) - -def _depth_to_png16_bytes(depth: np.ndarray) -> bytes: - depth = np.asarray(depth, dtype=np.float32) - dmin = float(depth.min()) - dmax = float(depth.max()) - if dmax > dmin: - norm = (depth - dmin) / (dmax - dmin) - else: - norm = np.zeros_like(depth, dtype=np.float32) - # 前后端约定:最远=255,最近=0(8-bit) - u8 = ((1.0 - norm) * 255.0).clip(0, 255).astype(np.uint8) - img = Image.fromarray(u8, mode="L") - buf = io.BytesIO() - img.save(buf, format="PNG") - return buf.getvalue() - - -def _default_half_mask(img: Image.Image) -> Image.Image: - w, h = img.size - mask = Image.new("L", (w, h), 0) - draw = ImageDraw.Draw(mask) - draw.rectangle([w // 2, 0, w, h], fill=255) - return mask - - -# ----------------------------- -# /models(给前端/GUI 使用) -# ----------------------------- - - -@app.get("/models") -def get_models() -> Dict[str, Any]: - """ - 返回一个兼容 Qt 前端的 schema: - { - "models": { - "depth": { "key": { "name": "..."} ... }, - "segment": { ... }, - "inpaint": { "key": { "name": "...", "params": [...] } ... } - } - } - """ - return { - "models": { - "depth": { - # 兼容旧配置默认值 - "midas": {"name": "MiDaS (default)"}, - "zoedepth_n": {"name": "ZoeDepth (ZoeD_N)"}, - "zoedepth_k": {"name": "ZoeDepth (ZoeD_K)"}, - "zoedepth_nk": {"name": "ZoeDepth (ZoeD_NK)"}, - "depth_anything_v2_vits": {"name": "Depth Anything V2 (vits)"}, - "depth_anything_v2_vitb": {"name": "Depth Anything V2 (vitb)"}, - "depth_anything_v2_vitl": {"name": "Depth Anything V2 (vitl)"}, - "depth_anything_v2_vitg": {"name": "Depth Anything V2 (vitg)"}, - "dpt_large": {"name": "DPT (large)"}, - "dpt_hybrid": {"name": "DPT (hybrid)"}, - "midas_dpt_beit_large_512": {"name": "MiDaS (dpt_beit_large_512)"}, - "midas_dpt_swin2_large_384": {"name": "MiDaS (dpt_swin2_large_384)"}, - "midas_dpt_swin2_tiny_256": {"name": "MiDaS (dpt_swin2_tiny_256)"}, - "midas_dpt_levit_224": {"name": "MiDaS (dpt_levit_224)"}, - }, - "segment": { - "sam": {"name": "SAM (vit_h)"}, - # 兼容旧配置默认值 - "mask2former_debug": {"name": "SAM (compat mask2former_debug)"}, - "mask2former": {"name": "Mask2Former (not implemented)"}, - }, - "inpaint": { - # 兼容旧配置默认值:copy 表示不做补全 - "copy": {"name": "Copy (no-op)", "params": []}, - "sdxl_inpaint": { - "name": "SDXL Inpaint", - "params": [ - {"id": "prompt", "label": "提示词", "optional": True}, - ], - }, - "controlnet": { - "name": "ControlNet Inpaint (canny)", - "params": [ - {"id": "prompt", "label": "提示词", "optional": True}, - ], - }, - }, - "animation": { - "animatediff": { - "name": "AnimateDiff (Text-to-Video)", - "params": [ - {"id": "prompt", "label": "提示词", "optional": False}, - {"id": "negative_prompt", "label": "负向提示词", "optional": True}, - {"id": "num_inference_steps", "label": "采样步数", "optional": True}, - {"id": "guidance_scale", "label": "CFG Scale", "optional": True}, - {"id": "width", "label": "宽度", "optional": True}, - {"id": "height", "label": "高度", "optional": True}, - {"id": "video_length", "label": "帧数", "optional": True}, - {"id": "seed", "label": "随机种子", "optional": True}, - ], - }, - }, - } - } - - -# ----------------------------- -# Depth -# ----------------------------- - - -_depth_predictor = None -_depth_backend: DepthBackend | None = None - - -def _ensure_depth_predictor() -> None: - global _depth_predictor, _depth_backend - if _depth_predictor is not None and _depth_backend is not None: - return - - app_cfg = load_app_config() - backend_str = get_depth_backend_from_app(app_cfg) - try: - backend = DepthBackend(backend_str) - except Exception as e: - raise ValueError(f"config.py 中 depth.backend 不合法: {backend_str}") from e - - _depth_predictor, _depth_backend = build_depth_predictor(UnifiedDepthConfig(backend=backend)) - - -@app.post("/depth") -def depth(req: DepthRequest): - """ - 计算深度并直接返回二进制 PNG(16-bit 灰度)。 - - 约束: - - 前端不传/不选模型;模型选择写死在后端 config.py - - 成功:HTTP 200 + Content-Type: image/png - - 失败:HTTP 500,detail 为错误信息 - """ - try: - _ensure_depth_predictor() - pil = _b64_to_pil_image(req.image_b64).convert("RGB") - depth_arr = _depth_predictor(pil) # type: ignore[misc] - png_bytes = _depth_to_png16_bytes(np.asarray(depth_arr)) - return Response(content=png_bytes, media_type="image/png") - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -# ----------------------------- -# Segment -# ----------------------------- - - -_seg_cache: Dict[str, Any] = {} - - -def _get_seg_predictor(model_name: str): - if model_name in _seg_cache: - return _seg_cache[model_name] - - # 兼容旧默认 key - if model_name == "mask2former_debug": - model_name = "sam" - - if model_name == "sam": - pred, _ = build_seg_predictor(UnifiedSegConfig(backend=SegBackend.SAM)) - _seg_cache[model_name] = pred - return pred - - if model_name == "mask2former": - pred, _ = build_seg_predictor(UnifiedSegConfig(backend=SegBackend.MASK2FORMER)) - _seg_cache[model_name] = pred - return pred - - raise ValueError(f"未知 segment model_name: {model_name}") - - -@app.post("/segment") -def segment(req: SegmentRequest) -> Dict[str, Any]: - try: - model_name = req.model_name or "sam" - pil = _b64_to_pil_image(req.image_b64).convert("RGB") - rgb = np.array(pil, dtype=np.uint8) - - predictor = _get_seg_predictor(model_name) - label_map = predictor(rgb).astype(np.int32) - - out_dir = OUTPUT_DIR / "segment" - out_dir.mkdir(parents=True, exist_ok=True) - out_path = out_dir / f"{model_name}_label.png" - # 保存为 8-bit 灰度(若 label 超过 255 会截断;当前 SAM 通常不会太大) - Image.fromarray(np.clip(label_map, 0, 255).astype(np.uint8), mode="L").save(out_path) - - return {"success": True, "label_path": str(out_path)} - except Exception as e: - return {"success": False, "error": str(e)} - - -@app.post("/segment/sam_prompt") -def segment_sam_prompt(req: SamPromptSegmentRequest) -> Dict[str, Any]: - """ - 交互式 SAM:裁剪图 + 点/框提示,返回掩膜外轮廓点列(裁剪像素坐标)。 - """ - try: - pil = _b64_to_pil_image(req.image_b64).convert("RGB") - rgb = np.array(pil, dtype=np.uint8) - h, w = rgb.shape[0], rgb.shape[1] - - if req.overlay_b64: - ov = _b64_to_pil_image(req.overlay_b64) - if ov.size != (w, h): - return { - "success": False, - "error": f"overlay 尺寸 {ov.size} 与 image {w}x{h} 不一致", - "contour": [], - } - - if len(req.point_coords) != len(req.point_labels): - return { - "success": False, - "error": "point_coords 与 point_labels 长度不一致", - "contour": [], - } - if len(req.point_coords) < 1: - return {"success": False, "error": "至少需要一个提示点", "contour": []} - - pc = np.array(req.point_coords, dtype=np.float32) - if pc.ndim != 2 or pc.shape[1] != 2: - return {"success": False, "error": "point_coords 每项须为 [x,y]", "contour": []} - - pl = np.array(req.point_labels, dtype=np.int64) - box = np.array(req.box_xyxy, dtype=np.float32) - - mask = run_sam_prompt(rgb, pc, pl, box_xyxy=box) - if not np.any(mask): - return {"success": False, "error": "SAM 未产生有效掩膜", "contour": []} - - contour = mask_to_contour_xy(mask, epsilon_px=2.0) - if len(contour) < 3: - return {"success": False, "error": "轮廓点数不足", "contour": []} - - return {"success": True, "contour": contour, "error": None} - except Exception as e: - return {"success": False, "error": str(e), "contour": []} - - -# ----------------------------- -# Inpaint -# ----------------------------- - - -_inpaint_cache: Dict[str, Any] = {} - - -def _get_inpaint_predictor(model_name: str): - if model_name in _inpaint_cache: - return _inpaint_cache[model_name] - - if model_name == "copy": - def _copy(image: Image.Image, *_args, **_kwargs) -> Image.Image: - return image.convert("RGB") - _inpaint_cache[model_name] = _copy - return _copy - - if model_name == "sdxl_inpaint": - pred, _ = build_inpaint_predictor(UnifiedInpaintConfig(backend=InpaintBackend.SDXL_INPAINT)) - _inpaint_cache[model_name] = pred - return pred - - if model_name == "controlnet": - pred, _ = build_inpaint_predictor(UnifiedInpaintConfig(backend=InpaintBackend.CONTROLNET)) - _inpaint_cache[model_name] = pred - return pred - - raise ValueError(f"未知 inpaint model_name: {model_name}") - - -@app.post("/inpaint") -def inpaint(req: InpaintRequest) -> Dict[str, Any]: - try: - model_name = req.model_name or "sdxl_inpaint" - pil = _b64_to_pil_image(req.image_b64).convert("RGB") - - if req.mask_b64: - mask = _b64_to_pil_image(req.mask_b64).convert("L") - else: - mask = _default_half_mask(pil) - - predictor = _get_inpaint_predictor(model_name) - out = predictor( - pil, - mask, - req.prompt or "", - req.negative_prompt or "", - strength=req.strength, - max_side=req.max_side, - ) - - out_dir = OUTPUT_DIR / "inpaint" - out_dir.mkdir(parents=True, exist_ok=True) - out_path = out_dir / f"{model_name}_inpaint.png" - out.save(out_path) - - # 兼容 Qt 前端:直接返回结果图,避免前端再去读取服务器磁盘路径 - return { - "success": True, - "output_path": str(out_path), - "output_image_b64": _pil_image_to_png_b64(out), - } - except Exception as e: - return {"success": False, "error": str(e)} - - -_animation_cache: Dict[str, Any] = {} - - -def _get_animation_predictor(model_name: str): - if model_name in _animation_cache: - return _animation_cache[model_name] - - if model_name == "animatediff": - pred, _ = build_animation_predictor( - UnifiedAnimationConfig(backend=AnimationBackend.ANIMATEDIFF) - ) - _animation_cache[model_name] = pred - return pred - - raise ValueError(f"未知 animation model_name: {model_name}") - - -@app.post("/animate") -def animate(req: AnimateRequest) -> Dict[str, Any]: - try: - model_name = req.model_name or "animatediff" - predictor = _get_animation_predictor(model_name) - - result_path = predictor( - prompt=req.prompt, - negative_prompt=req.negative_prompt or "", - num_inference_steps=req.num_inference_steps, - guidance_scale=req.guidance_scale, - width=req.width, - height=req.height, - video_length=req.video_length, - seed=req.seed, - ) - - out_dir = OUTPUT_DIR / "animation" - out_dir.mkdir(parents=True, exist_ok=True) - ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") - out_path = out_dir / f"{model_name}_{ts}.gif" - shutil.copy2(result_path, out_path) - - return {"success": True, "output_path": str(out_path)} - except Exception as e: - return {"success": False, "error": str(e)} - - -@app.get("/health") -def health() -> Dict[str, str]: - return {"status": "ok"} +from app.main import app +__all__ = ["app"] if __name__ == "__main__": import uvicorn port = int(os.environ.get("MODEL_SERVER_PORT", "8000")) uvicorn.run(app, host="0.0.0.0", port=port) -