This commit is contained in:
2026-05-14 13:30:06 +08:00
parent 974946cee4
commit e43171521d
91 changed files with 10485 additions and 3254 deletions
+1 -1
View File
@@ -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)
+59
View File
@@ -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
+80 -92
View File
@@ -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:
// v2project.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
+65 -169
View File
@@ -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});
}
// Toolsresolved 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)});
}
+3 -2
View File
@@ -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
+60
View File
@@ -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
+99
View File
@@ -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
+29
View File
@@ -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
+271
View File
@@ -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
+39
View File
@@ -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
+18 -2
View File
@@ -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();
+62
View File
@@ -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,
+15
View File
@@ -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_sequenceJSON 响应由调用方解析(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;
+204
View File
@@ -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
+18
View File
@@ -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 的目录 bundlev7 起):
/// 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 bitsmeta/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; // 保留 stubid/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
+78 -137
View File
@@ -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
+20
View File
@@ -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
+73 -7
View File
@@ -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
+11
View File
@@ -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
)
+561
View File
@@ -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
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include <QString>
namespace gui {
// 全局 QSS:仅负责外观统一,不承载业务逻辑。
QString appStyleSheet();
} // namespace gui
+11 -5
View File
@@ -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;
};
+20 -50
View File
@@ -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;
+3 -2
View File
@@ -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);
+5 -1
View File
@@ -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);
+358 -53
View File
@@ -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;
};
+431 -93
View File
@@ -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
View File
@@ -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;
};
+2 -2
View File
@@ -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);
File diff suppressed because it is too large Load Diff
+67 -5
View File
@@ -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;
+17 -1
View File
@@ -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 {};
+1 -1
View File
@@ -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);
}
}
File diff suppressed because it is too large Load Diff
+50 -3
View File
@@ -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;
+125 -35
View File
@@ -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
+34 -8
View File
@@ -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);
+16 -16
View File
@@ -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) {
+1 -1
View File
@@ -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);
+135 -66
View File
@@ -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);
+26 -10
View File
@@ -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;
+151
View File
@@ -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
+36
View File
@@ -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
+38 -19
View File
@@ -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));
+4 -1
View File
@@ -23,6 +23,7 @@ struct ToolPropertyUiState {
QString text;
QPointF position;
bool parentRelativeMode = false;
int priority = 10;
int pointerTThousandths = 500; // bubblePointerT01 * 10000=左 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;
+62
View File
@@ -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};
};
+61 -15
View File
@@ -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));
+9 -4
View File
@@ -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..600local 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
+1
View File
@@ -0,0 +1 @@
"""HFUT Model Server 应用包(FastAPI 路由与服务)。"""
+8
View File
@@ -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)
+13
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""HTTP 路由(按领域拆分)。"""
+135
View File
@@ -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": []}
+32
View File
@@ -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):
"""
计算深度并直接返回二进制 PNG16-bit 灰度
约束
- 前端不传/不选模型模型选择写死在后端 config.py
- 成功HTTP 200 + Content-Type: image/png
- 失败HTTP 500detail 为错误信息
"""
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
+47
View File
@@ -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)}
+93
View File
@@ -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"}
+84
View File
@@ -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": []}
+93
View File
@@ -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 图 base64PNG/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 的倍数",
)
+1
View File
@@ -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
+22
View File
@@ -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()
+25
View File
@@ -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
+108
View File
@@ -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}")
+41 -3
View File
@@ -5,7 +5,8 @@ from __future__ import annotations
当前支持
- SDXL Inpaintdiffusers AutoPipelineForInpainting
- ControlNet占位暂未统一封装
- ControlNetStable Diffusion + ControlNet Inpaint
- LaMatorchscript 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:
"""
轻量 LaMabig-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")
+37
View File
@@ -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,
+82
View File
@@ -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
View File
@@ -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 图 base64PNG/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):
"""
计算深度并直接返回二进制 PNG16-bit 灰度
约束
- 前端不传/不选模型模型选择写死在后端 config.py
- 成功HTTP 200 + Content-Type: image/png
- 失败HTTP 500detail 为错误信息
"""
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)