update
This commit is contained in:
+1
-1
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
#include "animation/AnimationEvaluator.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace core {
|
||||
namespace {
|
||||
|
||||
QVector<Project::AnimationKey> sortedKeys(const QVector<Project::AnimationKey>& keys) {
|
||||
QVector<Project::AnimationKey> 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<Project::AnimationKey>& 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<Project::AnimationKey>& 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<Project::AnimationKey>& 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<Project::AnimationKey>& 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<Project::AnimationKey>& 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<Project::Entity::ImageFrame> spriteFramesForEntity(const Project::Animation& animation,
|
||||
const QString& entityId) {
|
||||
QVector<Project::Entity::ImageFrame> 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
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include "domain/Project.h"
|
||||
|
||||
#include <QHash>
|
||||
#include <QPointF>
|
||||
#include <QString>
|
||||
|
||||
namespace core {
|
||||
|
||||
struct AnimationSample {
|
||||
QHash<QString, QPointF> entityPosition;
|
||||
QHash<QString, double> entityUserScale;
|
||||
QHash<QString, double> entityDepthScale01;
|
||||
QHash<QString, QByteArray> entitySpritePng;
|
||||
QHash<QString, bool> entityVisibility;
|
||||
QHash<QString, double> entityPriority;
|
||||
|
||||
QHash<QString, QPointF> toolPosition;
|
||||
QHash<QString, bool> toolVisibility;
|
||||
QHash<QString, double> toolPriority;
|
||||
|
||||
QHash<QString, QPointF> cameraPosition;
|
||||
QHash<QString, double> cameraScale;
|
||||
};
|
||||
|
||||
[[nodiscard]] AnimationSample evaluateAnimation(const Project::Animation& animation, int frame);
|
||||
[[nodiscard]] QVector<Project::Entity::ImageFrame> spriteFramesForEntity(const Project::Animation& animation,
|
||||
const QString& entityId);
|
||||
|
||||
} // namespace core
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
#include "domain/EntityIntro.h"
|
||||
|
||||
#include <QRect>
|
||||
#include <QString>
|
||||
#include <QPointF>
|
||||
#include <QHash>
|
||||
#include <QByteArray>
|
||||
#include <QVector>
|
||||
|
||||
#include <algorithm>
|
||||
@@ -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<KeyframeVec2> locationKeys;
|
||||
QVector<KeyframeFloat01> depthScaleKeys;
|
||||
QVector<KeyframeDouble> userScaleKeys;
|
||||
@@ -128,6 +133,7 @@ public:
|
||||
|
||||
// 基准位置(无关键帧时使用)
|
||||
QPointF originWorld;
|
||||
int priority = 10; // 默认高于实体(实体默认 1);越大越靠上
|
||||
QVector<Entity::KeyframeVec2> 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<AnimationKey> keys;
|
||||
};
|
||||
|
||||
struct Animation {
|
||||
QString id;
|
||||
QString name;
|
||||
|
||||
// Entity channels (keyed by entity id)
|
||||
QHash<QString, QVector<Entity::KeyframeVec2>> entityLocationKeys;
|
||||
QHash<QString, QVector<Entity::KeyframeDouble>> entityUserScaleKeys;
|
||||
QHash<QString, QVector<Entity::ImageFrame>> entityImageFrames;
|
||||
QHash<QString, QVector<ToolKeyframeBool>> entityVisibilityKeys;
|
||||
|
||||
// Tool channels (keyed by tool id)
|
||||
QHash<QString, QVector<Entity::KeyframeVec2>> toolLocationKeys;
|
||||
QHash<QString, QVector<ToolKeyframeBool>> toolVisibilityKeys;
|
||||
|
||||
QHash<QString, QVector<Entity::KeyframeVec2>> cameraLocationKeys;
|
||||
QHash<QString, QVector<Entity::KeyframeDouble>> cameraScaleKeys;
|
||||
QString payloadPath; // 例如 "assets/animations/animation-1.hfa"
|
||||
int fps = 30;
|
||||
int lengthFrames = kClipFixedFrames;
|
||||
bool loop = true;
|
||||
QVector<AnimationTrack> tracks;
|
||||
};
|
||||
|
||||
struct NlaStrip {
|
||||
void setAnimations(const QVector<Animation>& animations) { m_animations = animations; }
|
||||
const QVector<Animation>& 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<NlaStrip> strips;
|
||||
};
|
||||
|
||||
struct AnimationScheme {
|
||||
QString id;
|
||||
QString name;
|
||||
QVector<NlaTrack> tracks;
|
||||
};
|
||||
|
||||
void setAnimationClips(const QVector<AnimationClip>& clips) { m_clips = clips; }
|
||||
const QVector<AnimationClip>& animationClips() const { return m_clips; }
|
||||
|
||||
void setAnimationSchemes(const QVector<AnimationScheme>& schemes) { m_schemes = schemes; }
|
||||
const QVector<AnimationScheme>& 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<PresentationHotspot>& v) { m_presentationHotspots = v; }
|
||||
const QVector<PresentationHotspot>& presentationHotspots() const { return m_presentationHotspots; }
|
||||
QVector<PresentationHotspot>& presentationHotspotsRef() { return m_presentationHotspots; }
|
||||
|
||||
private:
|
||||
QString m_name;
|
||||
@@ -271,10 +260,9 @@ private:
|
||||
QVector<Camera> m_cameras;
|
||||
QString m_activeCameraId;
|
||||
|
||||
QVector<AnimationClip> m_clips;
|
||||
QVector<AnimationScheme> m_schemes;
|
||||
QString m_activeSchemeId;
|
||||
QString m_selectedStripId;
|
||||
QVector<Animation> m_animations;
|
||||
QString m_activeAnimationId;
|
||||
QVector<PresentationHotspot> m_presentationHotspots;
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
#include "eval/ProjectEvaluator.h"
|
||||
|
||||
#include "animation/AnimationEvaluator.h"
|
||||
#include "animation/AnimationSampling.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <unordered_set>
|
||||
|
||||
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<core::Project::ToolKeyframeBool>& 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<QString, NodeRef> 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<core::Project::ToolKeyframeBool> 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<core::Project::ToolKeyframeBool> 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)});
|
||||
}
|
||||
|
||||
|
||||
@@ -29,8 +29,9 @@ struct ResolvedProjectFrame {
|
||||
QVector<ResolvedCamera> 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
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
|
||||
#include <QtGlobal>
|
||||
#include <QImageReader>
|
||||
#include <QRect>
|
||||
#include <QSize>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,99 @@
|
||||
#include "image/ImageFileLoader.h"
|
||||
|
||||
#include "image/ImageDecodeConfig.h"
|
||||
#include "large_image/VipsBackend.h"
|
||||
|
||||
#include <QImageReader>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include <QImage>
|
||||
#include <QRect>
|
||||
#include <QString>
|
||||
#include <QSize>
|
||||
|
||||
#include <QtGlobal>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,271 @@
|
||||
// 必须在任何 Qt 头(会定义宏 signals)之前包含 vips/glib
|
||||
#ifdef LT_HAVE_VIPS
|
||||
# include <vips/vips.h>
|
||||
#endif
|
||||
|
||||
#include "large_image/VipsBackend.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
|
||||
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<const uchar*>(buf), static_cast<int>(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<double>(maxOutputWidth) / static_cast<double>(cw);
|
||||
const double sy = static_cast<double>(maxOutputHeight) / static_cast<double>(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<const uchar*>(buf), static_cast<int>(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
|
||||
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
#include <QtGlobal>
|
||||
|
||||
#include <QImage>
|
||||
#include <QRect>
|
||||
#include <QString>
|
||||
|
||||
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
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
#include <functional>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonValue>
|
||||
#include <QRect>
|
||||
|
||||
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<core::Project::Entity::KeyframeVec2>(
|
||||
@@ -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<core::Project::Entity::KeyframeVec2>& dst) -> bool {
|
||||
dst.clear();
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
#include <QTimer>
|
||||
#include <QUrl>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
#include "persistence/AnimationBinary.h"
|
||||
|
||||
#include "persistence/PersistentBinaryObject.h"
|
||||
|
||||
#include <QDataStream>
|
||||
#include <QFile>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace core {
|
||||
namespace {
|
||||
|
||||
qint32 toInt(Project::AnimationTargetKind v) {
|
||||
return static_cast<qint32>(v);
|
||||
}
|
||||
|
||||
qint32 toInt(Project::AnimationProperty v) {
|
||||
return static_cast<qint32>(v);
|
||||
}
|
||||
|
||||
qint32 toInt(Project::AnimationValueType v) {
|
||||
return static_cast<qint32>(v);
|
||||
}
|
||||
|
||||
qint32 toInt(Project::AnimationInterpolation v) {
|
||||
return static_cast<qint32>(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<Project::AnimationKey>& keys) {
|
||||
std::sort(keys.begin(), keys.end(), [](const auto& a, const auto& b) {
|
||||
return a.frame < b.frame;
|
||||
});
|
||||
QVector<Project::AnimationKey> 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
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
|
||||
#include "domain/Project.h"
|
||||
|
||||
#include <QString>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,162 @@
|
||||
#include "persistence/AnimationBundleStore.h"
|
||||
|
||||
#include "persistence/JsonFileAtomic.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include "domain/Project.h"
|
||||
|
||||
#include <QString>
|
||||
|
||||
namespace core::persistence {
|
||||
|
||||
/// 动画 payload 的目录 bundle(v7 起):
|
||||
/// assets/animations/<id>/
|
||||
/// 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
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
#include "persistence/AsyncProjectWriter.h"
|
||||
|
||||
#include "persistence/AnimationBundleStore.h"
|
||||
#include "persistence/EntityBundleStore.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QEventLoop>
|
||||
#include <QTimer>
|
||||
#include <QtConcurrent/QtConcurrent>
|
||||
|
||||
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<QString, Project::Entity> ents;
|
||||
QHash<QString, Project::Animation> 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
|
||||
@@ -0,0 +1,51 @@
|
||||
#pragma once
|
||||
|
||||
#include "domain/Project.h"
|
||||
|
||||
#include <QHash>
|
||||
#include <QObject>
|
||||
#include <QSet>
|
||||
#include <QString>
|
||||
#include <QTimer>
|
||||
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
|
||||
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<QString, Project::Entity> m_pendingEntities;
|
||||
QHash<QString, Project::Animation> m_pendingAnimations;
|
||||
|
||||
std::atomic<int> m_outstandingDrains{0};
|
||||
};
|
||||
|
||||
} // namespace core::persistence
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
#include "persistence/EntityBundleStore.h"
|
||||
|
||||
#include "persistence/JsonFileAtomic.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
|
||||
namespace core::persistence {
|
||||
namespace {
|
||||
|
||||
QJsonArray pointsToJson(const QVector<QPointF>& 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<QPointF>& 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 <typename KeyT>
|
||||
QJsonArray boolKeysToJson(const QVector<KeyT>& 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<Project::Entity::KeyframeVec2>& 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<Project::Entity::KeyframeFloat01>& 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<Project::Entity::KeyframeDouble>& 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<Project::Entity::ImageFrame>& 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 <typename KeyT>
|
||||
bool boolKeysFromJson(const QJsonValue& v, QVector<KeyT>& 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<Project::Entity::KeyframeVec2>& 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<Project::Entity::KeyframeFloat01>& 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<Project::Entity::KeyframeDouble>& 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<Project::Entity::ImageFrame>& 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<Project::ToolKeyframeBool>(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<Project::ToolKeyframeBool>(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
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include "domain/Project.h"
|
||||
|
||||
#include <QString>
|
||||
|
||||
namespace core::persistence {
|
||||
|
||||
/// 实体 payload 的目录 bundle 结构(v7 起):
|
||||
/// assets/entities/<id>/
|
||||
/// 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
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
#include <QDataStream>
|
||||
#include <QFile>
|
||||
#include <QRect>
|
||||
#include <QtGlobal>
|
||||
|
||||
#include <algorithm>
|
||||
@@ -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<int>(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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
#include "persistence/JsonFileAtomic.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,19 +5,24 @@
|
||||
#include <QImage>
|
||||
#include <QJsonObject>
|
||||
#include <QRect>
|
||||
#include <QSet>
|
||||
#include <QTimer>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QVector>
|
||||
#include <QPoint>
|
||||
|
||||
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<Project::Entity::ImageFrame> 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<Project::PresentationHotspot>& 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<Project::Tool> afterTools;
|
||||
QVector<Project::Camera> beforeCameras;
|
||||
QVector<Project::Camera> afterCameras;
|
||||
QVector<Project::Animation> beforeAnimations;
|
||||
QVector<Project::Animation> afterAnimations;
|
||||
bool animBundlesPresentationHotspots = false;
|
||||
QVector<Project::PresentationHotspot> animSnapshotBeforePresentationHotspots;
|
||||
QVector<Project::PresentationHotspot> animSnapshotAfterPresentationHotspots;
|
||||
|
||||
QVector<Project::PresentationHotspot> beforePresentationHotspots;
|
||||
QVector<Project::PresentationHotspot> afterPresentationHotspots;
|
||||
QString beforeProjectTitle;
|
||||
QString afterProjectTitle;
|
||||
int beforeFrameStart = 0;
|
||||
@@ -200,19 +249,36 @@ private:
|
||||
bool applyEntities(const QVector<Project::Entity>& entities, bool recordHistory, const QString& label);
|
||||
bool applyTools(const QVector<Project::Tool>& tools, bool recordHistory, const QString& label);
|
||||
bool applyCameras(const QVector<Project::Camera>& cameras, bool recordHistory, const QString& label);
|
||||
bool applyAnimations(const QVector<Project::Animation>& 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<Operation> m_undoStack;
|
||||
QVector<Operation> m_redoStack;
|
||||
QSet<QString> m_dirtyEntityIds;
|
||||
QSet<QString> m_dirtyAnimationIds;
|
||||
bool m_indexDirty = false;
|
||||
QTimer* m_payloadSyncTimer = nullptr;
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
namespace gui {
|
||||
|
||||
// 全局 QSS:仅负责外观统一,不承载业务逻辑。
|
||||
QString appStyleSheet();
|
||||
|
||||
} // namespace gui
|
||||
|
||||
+11
-5
@@ -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 <QApplication>
|
||||
#include <QImageReader>
|
||||
#include <QStyleFactory>
|
||||
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
#include "dialogs/AnimationSchemeSettingsDialog.h"
|
||||
|
||||
#include "core/domain/Project.h"
|
||||
#include "core/workspace/ProjectWorkspace.h"
|
||||
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFormLayout>
|
||||
#include <QLineEdit>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
#include <QSpinBox>
|
||||
#include <QVBoxLayout>
|
||||
#include <QCheckBox>
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
|
||||
#include <QDialog>
|
||||
#include <QString>
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#include "dialogs/EntityFinalizeDialog.h"
|
||||
|
||||
#include "widgets/CompactNumericSpinBox.h"
|
||||
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFormLayout>
|
||||
#include <QLineEdit>
|
||||
#include <QDoubleSpinBox>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
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);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
#include "dialogs/EntityIntroPopup.h"
|
||||
|
||||
#include "core/image/ImageDecodeConfig.h"
|
||||
#include "core/image/ImageFileLoader.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QGuiApplication>
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <QBoxLayout>
|
||||
#include <QAbstractItemView>
|
||||
#include <QBuffer>
|
||||
#include <QColor>
|
||||
#include <QColorDialog>
|
||||
#include <QDialog>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QDir>
|
||||
#include <QFormLayout>
|
||||
#include <QFileDialog>
|
||||
#include <QFileInfo>
|
||||
#include <QImage>
|
||||
#include <QImageReader>
|
||||
#include <QIODevice>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QListWidget>
|
||||
#include <QMessageBox>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QPixmap>
|
||||
#include <QPushButton>
|
||||
#include <QUrl>
|
||||
|
||||
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<double>(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<int> FrameAnimationDialog::targetFramesForWrite(int requestedCount) const {
|
||||
QVector<int> 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<int> 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<int>(sorted.size()));
|
||||
const QVector<int> targets = targetFramesForWrite(static_cast<int>(sorted.size()));
|
||||
const int count = std::min(static_cast<int>(targets.size()), static_cast<int>(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<int>(files.size()));
|
||||
const QVector<int> targets = targetFramesForWrite(static_cast<int>(files.size()));
|
||||
const int count = std::min(static_cast<int>(targets.size()), static_cast<int>(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<int> targetFrames = targetFramesForWrite(0);
|
||||
const int frameCount = static_cast<int>(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<int>(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();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <QDialog>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QVector>
|
||||
|
||||
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<int> 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;
|
||||
};
|
||||
|
||||
@@ -1,64 +1,122 @@
|
||||
#include "dialogs/ImageCropDialog.h"
|
||||
|
||||
#include "core/image/ImageDecodeConfig.h"
|
||||
#include "core/image/ImageFileLoader.h"
|
||||
|
||||
#include <QBoxLayout>
|
||||
#include <QCursor>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QLabel>
|
||||
#include <QMouseEvent>
|
||||
#include <QPainter>
|
||||
#include <QPushButton>
|
||||
#include <QImageReader>
|
||||
#include <QResizeEvent>
|
||||
#include <QWheelEvent>
|
||||
#include <QtMath>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#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<qreal>(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<double>(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();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <QDialog>
|
||||
#include <QImage>
|
||||
#include <QRect>
|
||||
#include <QSize>
|
||||
|
||||
class QLabel;
|
||||
class QPushButton;
|
||||
@@ -30,5 +31,7 @@ private:
|
||||
|
||||
QString m_imagePath;
|
||||
QImage m_image;
|
||||
/// read() 前 reader.size() 得到的原图逻辑尺寸(与 m_image 可能为缩放预览不一致)
|
||||
QSize m_sourceLogicalSize;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
+1120
-360
File diff suppressed because it is too large
Load Diff
@@ -2,13 +2,17 @@
|
||||
|
||||
#include "core/domain/Project.h"
|
||||
|
||||
#include <atomic>
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QJsonArray>
|
||||
#include <QPixmap>
|
||||
#include <QPoint>
|
||||
#include <QPointF>
|
||||
#include <QHash>
|
||||
#include <QImage>
|
||||
#include <QPainterPath>
|
||||
#include <QRect>
|
||||
#include <QVector>
|
||||
#include <QWidget>
|
||||
#include <QElapsedTimer>
|
||||
@@ -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<core::Project::PresentationHotspot>& 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<core::Project::Entity>& entities,
|
||||
const QVector<double>& 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<QString, QImage> 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<uint32_t> m_vpToken{0};
|
||||
std::atomic<bool> 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<core::Project::PresentationHotspot> 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<QPointF> m_dragPolyBase;
|
||||
|
||||
@@ -68,7 +68,23 @@ QImage extractEntityImage(const QImage& bg, const QVector<QPointF>& 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 {};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+1697
-654
File diff suppressed because it is too large
Load Diff
@@ -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<QString> 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;
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
#include "params/ParamControls.h"
|
||||
|
||||
#include "widgets/CompactNumericSpinBox.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
#include <QAbstractSpinBox>
|
||||
#include <QApplication>
|
||||
#include <QDoubleSpinBox>
|
||||
#include <QHBoxLayout>
|
||||
#include <QSizePolicy>
|
||||
#include <QSlider>
|
||||
#include <QSpinBox>
|
||||
#include <QWidget>
|
||||
|
||||
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<double>(&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<double>(&QDoubleSpinBox::valueChanged), this, [this]() { emitIfChanged(); });
|
||||
connect(m_y, qOverload<double>(&QDoubleSpinBox::valueChanged), this, [this]() { emitIfChanged(); });
|
||||
connect(m_sx, qOverload<int>(&QSpinBox::valueChanged), this, [this]() { emitIfChanged(); });
|
||||
connect(m_sy, qOverload<int>(&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<double>(&QDoubleSpinBox::valueChanged), this, [this]() { emitIfChanged(); });
|
||||
connect(m_sy, qOverload<double>(&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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "props/BackgroundPropertySection.h"
|
||||
|
||||
#include <QCheckBox>
|
||||
#include "widgets/PropertyPanelControls.h"
|
||||
|
||||
#include <QFormLayout>
|
||||
#include <QLabel>
|
||||
#include <QVBoxLayout>
|
||||
@@ -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);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "props/BlackholePropertySection.h"
|
||||
|
||||
#include "widgets/PropertyPanelControls.h"
|
||||
|
||||
#include <QFormLayout>
|
||||
#include <QLabel>
|
||||
#include <QVBoxLayout>
|
||||
@@ -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);
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QDoubleSpinBox>
|
||||
|
||||
#include "widgets/CompactNumericSpinBox.h"
|
||||
#include "widgets/PropertyPanelControls.h"
|
||||
#include <QFormLayout>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
@@ -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<double>(&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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
#include "params/ParamControls.h"
|
||||
|
||||
#include <QDoubleSpinBox>
|
||||
|
||||
#include "widgets/CompactNumericSpinBox.h"
|
||||
#include "widgets/PropertyPanelControls.h"
|
||||
#include <QFormLayout>
|
||||
#include <QFrame>
|
||||
#include <QHBoxLayout>
|
||||
#include <QCheckBox>
|
||||
#include <QLabel>
|
||||
@@ -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<int>(&QSpinBox::valueChanged), this, &EntityPropertySection::priorityEdited);
|
||||
connect(m_userScale, qOverload<double>(&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<quint64>(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);
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
#include "props/PropertySectionWidget.h"
|
||||
|
||||
#include <QPointF>
|
||||
#include <QImage>
|
||||
#include <QString>
|
||||
#include <QtGlobal>
|
||||
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
#include "props/HotspotPropertySection.h"
|
||||
|
||||
#include <QComboBox>
|
||||
#include <QFormLayout>
|
||||
#include <QHash>
|
||||
#include <QLabel>
|
||||
#include <QListWidget>
|
||||
#include <QMenu>
|
||||
#include <QPushButton>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
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<int>::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<core::Project::PresentationHotspot>& hotspots,
|
||||
const QString& selectedHotspotId,
|
||||
const QVector<QPair<QString, QString>>& animIdToLabelNonNone) {
|
||||
m_hotspotId = selectedHotspotId;
|
||||
if (!m_targetAnim || !m_hotspotList) {
|
||||
return;
|
||||
}
|
||||
QString targetAnimationId;
|
||||
QHash<QString, QString> 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
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
#include "core/domain/Project.h"
|
||||
#include "props/PropertySectionWidget.h"
|
||||
|
||||
#include <QString>
|
||||
|
||||
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<core::Project::PresentationHotspot>& hotspots,
|
||||
const QString& selectedHotspotId,
|
||||
const QVector<QPair<QString, QString>>& 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
|
||||
@@ -11,6 +11,9 @@
|
||||
#include <QLineEdit>
|
||||
#include <QSlider>
|
||||
#include <QSpinBox>
|
||||
|
||||
#include "widgets/CompactNumericSpinBox.h"
|
||||
#include "widgets/PropertyPanelControls.h"
|
||||
#include <QVBoxLayout>
|
||||
|
||||
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<int>(&QSpinBox::valueChanged), this, &ToolPropertySection::fontPxChanged);
|
||||
connect(m_align, qOverload<int>(&QComboBox::currentIndexChanged), this, &ToolPropertySection::alignChanged);
|
||||
connect(m_position, &Vec2ParamControl::valueChanged, this, &ToolPropertySection::positionEdited);
|
||||
connect(m_priority, qOverload<int>(&QSpinBox::valueChanged), this, &ToolPropertySection::priorityEdited);
|
||||
connect(m_visible, &QCheckBox::toggled, this, &ToolPropertySection::visibleToggled);
|
||||
}
|
||||
|
||||
void ToolPropertySection::setEditingEnabled(bool on) {
|
||||
for (auto* w : {static_cast<QWidget*>(m_text), static_cast<QWidget*>(m_position),
|
||||
static_cast<QWidget*>(m_priority),
|
||||
static_cast<QWidget*>(m_pointerT),
|
||||
static_cast<QWidget*>(m_fontPx), static_cast<QWidget*>(m_align),
|
||||
static_cast<QWidget*>(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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
#pragma once
|
||||
|
||||
#include "core/domain/Project.h"
|
||||
|
||||
#include <QColor>
|
||||
#include <QPoint>
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
|
||||
// TimelineEditorModel:把时间轴的“可视数据 + 选择状态 + 命中测试结果”从 Widget 拆出来,
|
||||
// 便于支持多轨、统一交互逻辑,并降低 TimelineWidget 的复杂度。
|
||||
//
|
||||
// 阶段1最小实现:仅负责存储轨道视图数据与选择状态;Widget 仍自行处理鼠标事件,
|
||||
// 但数据不再用固定 4 条 QVector<int> 传递。
|
||||
|
||||
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<int> keyframes; // sorted unique frames
|
||||
};
|
||||
|
||||
void clear() {
|
||||
m_tracks.clear();
|
||||
clearSelection();
|
||||
clearInterval();
|
||||
}
|
||||
|
||||
void setTracks(QVector<TrackView> tracks) { m_tracks = std::move(tracks); }
|
||||
const QVector<TrackView>& 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<TrackView> m_tracks;
|
||||
bool m_hasSelectedKey {false};
|
||||
KeyRef m_selected;
|
||||
int m_selStart {-1};
|
||||
int m_selEnd {-1};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "timeline/TimelineWidget.h"
|
||||
|
||||
#include "timeline/TimelineEditorModel.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
@@ -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<int>& locFrames,
|
||||
m_selKeyFrame = -1;
|
||||
emit keyframeSelectionChanged(m_selKeyKind, m_selKeyFrame);
|
||||
}
|
||||
if (m_model) {
|
||||
QVector<TimelineEditorModel::TrackView> 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<int>& locFrames,
|
||||
m_selKeyFrame = -1;
|
||||
emit keyframeSelectionChanged(m_selKeyKind, m_selKeyFrame);
|
||||
}
|
||||
if (m_model) {
|
||||
QVector<TimelineEditorModel::TrackView> 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));
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
#include <QWidget>
|
||||
|
||||
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<int>& locFrames,
|
||||
const QVector<int>& 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
|
||||
};
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
#include "widgets/CompactNumericSpinBox.h"
|
||||
|
||||
#include <QAbstractSpinBox>
|
||||
#include <QSizePolicy>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
#include <QDoubleSpinBox>
|
||||
#include <QSpinBox>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,82 @@
|
||||
#include "widgets/PropertyPanelControls.h"
|
||||
|
||||
#include <QFormLayout>
|
||||
#include <QSizePolicy>
|
||||
#include <QTextEdit>
|
||||
#include <QTextOption>
|
||||
#include <QWidget>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QComboBox>
|
||||
#include <QFormLayout>
|
||||
#include <QLineEdit>
|
||||
#include <QPushButton>
|
||||
#include <QTextEdit>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
"""HFUT Model Server 应用包(FastAPI 路由与服务)。"""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""HTTP 路由(按领域拆分)。"""
|
||||
@@ -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": []}
|
||||
@@ -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
|
||||
@@ -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)}
|
||||
@@ -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"}
|
||||
@@ -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": []}
|
||||
@@ -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 的倍数",
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""业务服务层(预测器缓存、图像编解码等)。"""
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
+7
-477
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user