update
This commit is contained in:
@@ -1,11 +1,21 @@
|
||||
# 模块:domain、persistence、workspace、depth、animation(时间采样)
|
||||
set(CORE_ROOT ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
|
||||
option(LT_WITH_VIPS "Use libvips for GB-scale PNG/JPEG (libvips-dev / pkg-config vips)" ON)
|
||||
|
||||
set(CORE_SOURCES
|
||||
${CORE_ROOT}/domain/Project.cpp
|
||||
${CORE_ROOT}/image/ImageFileLoader.cpp
|
||||
${CORE_ROOT}/large_image/VipsBackend.cpp
|
||||
${CORE_ROOT}/workspace/ProjectWorkspace.cpp
|
||||
${CORE_ROOT}/persistence/PersistentBinaryObject.cpp
|
||||
${CORE_ROOT}/persistence/AnimationBinary.cpp
|
||||
${CORE_ROOT}/persistence/EntityPayloadBinary.cpp
|
||||
${CORE_ROOT}/persistence/JsonFileAtomic.cpp
|
||||
${CORE_ROOT}/persistence/EntityBundleStore.cpp
|
||||
${CORE_ROOT}/persistence/AnimationBundleStore.cpp
|
||||
${CORE_ROOT}/persistence/AsyncProjectWriter.cpp
|
||||
${CORE_ROOT}/animation/AnimationEvaluator.cpp
|
||||
${CORE_ROOT}/animation/AnimationSampling.cpp
|
||||
${CORE_ROOT}/depth/DepthService.cpp
|
||||
${CORE_ROOT}/net/ModelServerClient.cpp
|
||||
@@ -17,11 +27,20 @@ set(CORE_SOURCES
|
||||
)
|
||||
|
||||
set(CORE_HEADERS
|
||||
${CORE_ROOT}/image/ImageDecodeConfig.h
|
||||
${CORE_ROOT}/image/ImageFileLoader.h
|
||||
${CORE_ROOT}/large_image/VipsBackend.h
|
||||
${CORE_ROOT}/domain/EntityIntro.h
|
||||
${CORE_ROOT}/domain/Project.h
|
||||
${CORE_ROOT}/workspace/ProjectWorkspace.h
|
||||
${CORE_ROOT}/persistence/PersistentBinaryObject.h
|
||||
${CORE_ROOT}/persistence/AnimationBinary.h
|
||||
${CORE_ROOT}/persistence/EntityPayloadBinary.h
|
||||
${CORE_ROOT}/persistence/JsonFileAtomic.h
|
||||
${CORE_ROOT}/persistence/EntityBundleStore.h
|
||||
${CORE_ROOT}/persistence/AnimationBundleStore.h
|
||||
${CORE_ROOT}/persistence/AsyncProjectWriter.h
|
||||
${CORE_ROOT}/animation/AnimationEvaluator.h
|
||||
${CORE_ROOT}/animation/AnimationSampling.h
|
||||
${CORE_ROOT}/depth/DepthService.h
|
||||
${CORE_ROOT}/net/ModelServerClient.h
|
||||
@@ -47,4 +66,44 @@ target_link_libraries(core
|
||||
${QT_PACKAGE}::Core
|
||||
${QT_PACKAGE}::Gui
|
||||
${QT_PACKAGE}::Network
|
||||
${QT_PACKAGE}::Concurrent
|
||||
)
|
||||
|
||||
# libvips:优先 IMPORTED_TARGET;失败则用 pkg-config 变量(部分环境下更稳)
|
||||
set(LT_VIPS_ENABLED OFF)
|
||||
if(LT_WITH_VIPS)
|
||||
find_package(PkgConfig QUIET)
|
||||
if(PKG_CONFIG_FOUND)
|
||||
pkg_check_modules(VIPS_PC IMPORTED_TARGET vips)
|
||||
if(TARGET PkgConfig::VIPS_PC)
|
||||
target_compile_definitions(core PRIVATE LT_HAVE_VIPS=1)
|
||||
target_link_libraries(core PUBLIC PkgConfig::VIPS_PC)
|
||||
set(LT_VIPS_ENABLED ON)
|
||||
message(STATUS "libvips: enabled via IMPORTED_TARGET (LT_HAVE_VIPS)")
|
||||
else()
|
||||
pkg_check_modules(VIPS_LEGACY QUIET vips)
|
||||
if(VIPS_LEGACY_FOUND)
|
||||
target_compile_definitions(core PRIVATE LT_HAVE_VIPS=1)
|
||||
target_include_directories(core PRIVATE ${VIPS_LEGACY_INCLUDE_DIRS})
|
||||
target_link_directories(core PUBLIC ${VIPS_LEGACY_LIBRARY_DIRS})
|
||||
target_link_libraries(core PUBLIC ${VIPS_LEGACY_LIBRARIES})
|
||||
if(VIPS_LEGACY_CFLAGS_OTHER)
|
||||
separate_arguments(_vips_cflags UNIX_COMMAND "${VIPS_LEGACY_CFLAGS_OTHER}")
|
||||
target_compile_options(core PRIVATE ${_vips_cflags})
|
||||
endif()
|
||||
set(LT_VIPS_ENABLED ON)
|
||||
message(STATUS "libvips: enabled via pkg-config variables (LT_HAVE_VIPS)")
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
if(LT_WITH_VIPS AND NOT LT_VIPS_ENABLED)
|
||||
message(
|
||||
WARNING
|
||||
"未找到 libvips(无 pkg-config 模块 vips),已关闭 LT_HAVE_VIPS,大图仍走 Qt 解码。\n"
|
||||
" Debian/Ubuntu: sudo apt install pkg-config libvips-dev\n"
|
||||
" Fedora: sudo dnf install pkgconf-pkg-config vips-devel\n"
|
||||
" 安装后终端执行: pkg-config --modversion vips (应打印版本号)\n"
|
||||
" 若已安装仍失败: export PKG_CONFIG_PATH=\"/usr/lib/x86_64-linux-gnu/pkgconfig:\$PKG_CONFIG_PATH\""
|
||||
)
|
||||
endif()
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
#include "animation/AnimationEvaluator.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace core {
|
||||
namespace {
|
||||
|
||||
QVector<Project::AnimationKey> sortedKeys(const QVector<Project::AnimationKey>& keys) {
|
||||
QVector<Project::AnimationKey> out = keys;
|
||||
std::sort(out.begin(), out.end(), [](const auto& a, const auto& b) { return a.frame < b.frame; });
|
||||
return out;
|
||||
}
|
||||
|
||||
bool sampleBool(const QVector<Project::AnimationKey>& keys, int frame, bool fallback) {
|
||||
auto sorted = sortedKeys(keys);
|
||||
bool out = fallback;
|
||||
for (const auto& k : sorted) {
|
||||
if (k.frame <= frame) out = k.boolValue;
|
||||
else break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
QString sampleString(const QVector<Project::AnimationKey>& keys, int frame, const QString& fallback = {}) {
|
||||
auto sorted = sortedKeys(keys);
|
||||
QString out = fallback;
|
||||
for (const auto& k : sorted) {
|
||||
if (k.frame <= frame && !k.stringValue.isEmpty()) out = k.stringValue;
|
||||
else if (k.frame > frame) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
QByteArray sampleBytes(const QVector<Project::AnimationKey>& keys, int frame, const QByteArray& fallback = {}) {
|
||||
auto sorted = sortedKeys(keys);
|
||||
QByteArray out = fallback;
|
||||
for (const auto& k : sorted) {
|
||||
if (k.frame <= frame && !k.bytesValue.isEmpty()) out = k.bytesValue;
|
||||
else if (k.frame > frame) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
double sampleDouble(const QVector<Project::AnimationKey>& keys,
|
||||
int frame,
|
||||
double fallback,
|
||||
Project::AnimationInterpolation interpolation) {
|
||||
auto sorted = sortedKeys(keys);
|
||||
if (sorted.isEmpty()) return fallback;
|
||||
if (interpolation == Project::AnimationInterpolation::Hold) {
|
||||
double out = fallback;
|
||||
for (const auto& k : sorted) {
|
||||
if (k.frame <= frame) out = k.numberValue;
|
||||
else break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (frame <= sorted.front().frame) return sorted.front().numberValue;
|
||||
if (frame >= sorted.back().frame) return sorted.back().numberValue;
|
||||
for (int i = 0; i + 1 < sorted.size(); ++i) {
|
||||
const auto& a = sorted[i];
|
||||
const auto& b = sorted[i + 1];
|
||||
if (frame >= a.frame && frame <= b.frame) {
|
||||
if (a.frame == b.frame) return a.numberValue;
|
||||
const double t = double(frame - a.frame) / double(b.frame - a.frame);
|
||||
return a.numberValue + (b.numberValue - a.numberValue) * t;
|
||||
}
|
||||
}
|
||||
return sorted.back().numberValue;
|
||||
}
|
||||
|
||||
QPointF sampleVec2(const QVector<Project::AnimationKey>& keys,
|
||||
int frame,
|
||||
const QPointF& fallback,
|
||||
Project::AnimationInterpolation interpolation) {
|
||||
auto sorted = sortedKeys(keys);
|
||||
if (sorted.isEmpty()) return fallback;
|
||||
if (interpolation == Project::AnimationInterpolation::Hold) {
|
||||
QPointF out = fallback;
|
||||
for (const auto& k : sorted) {
|
||||
if (k.frame <= frame) out = k.vec2Value;
|
||||
else break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (frame <= sorted.front().frame) return sorted.front().vec2Value;
|
||||
if (frame >= sorted.back().frame) return sorted.back().vec2Value;
|
||||
for (int i = 0; i + 1 < sorted.size(); ++i) {
|
||||
const auto& a = sorted[i];
|
||||
const auto& b = sorted[i + 1];
|
||||
if (frame >= a.frame && frame <= b.frame) {
|
||||
if (a.frame == b.frame) return a.vec2Value;
|
||||
const double t = double(frame - a.frame) / double(b.frame - a.frame);
|
||||
return QPointF(a.vec2Value.x() + (b.vec2Value.x() - a.vec2Value.x()) * t,
|
||||
a.vec2Value.y() + (b.vec2Value.y() - a.vec2Value.y()) * t);
|
||||
}
|
||||
}
|
||||
return sorted.back().vec2Value;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
AnimationSample evaluateAnimation(const Project::Animation& animation, int frame) {
|
||||
AnimationSample out;
|
||||
const int f = animation.loop && animation.lengthFrames > 0
|
||||
? (std::max(0, frame) % animation.lengthFrames)
|
||||
: std::clamp(frame, 0, std::max(0, animation.lengthFrames - 1));
|
||||
|
||||
for (const auto& t : animation.tracks) {
|
||||
if (t.targetId.isEmpty() || t.keys.isEmpty()) continue;
|
||||
if (t.targetKind == Project::AnimationTargetKind::Entity) {
|
||||
switch (t.property) {
|
||||
case Project::AnimationProperty::Position:
|
||||
out.entityPosition.insert(t.targetId, sampleVec2(t.keys, f, QPointF(), t.interpolation));
|
||||
break;
|
||||
case Project::AnimationProperty::UserScale:
|
||||
out.entityUserScale.insert(t.targetId, sampleDouble(t.keys, f, 1.0, t.interpolation));
|
||||
break;
|
||||
case Project::AnimationProperty::DepthScale:
|
||||
out.entityDepthScale01.insert(t.targetId, sampleDouble(t.keys, f, 0.5, t.interpolation));
|
||||
break;
|
||||
case Project::AnimationProperty::Sprite:
|
||||
// 贴图仅支持二进制 PNG bytes(不兼容路径关键帧)。
|
||||
if (t.valueType == Project::AnimationValueType::PngBytes) {
|
||||
out.entitySpritePng.insert(t.targetId, sampleBytes(t.keys, f));
|
||||
}
|
||||
break;
|
||||
case Project::AnimationProperty::Visibility:
|
||||
out.entityVisibility.insert(t.targetId, sampleBool(t.keys, f, true));
|
||||
break;
|
||||
case Project::AnimationProperty::Priority:
|
||||
out.entityPriority.insert(t.targetId, sampleDouble(t.keys, f, 1.0, t.interpolation));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} else if (t.targetKind == Project::AnimationTargetKind::Tool) {
|
||||
if (t.property == Project::AnimationProperty::Position) {
|
||||
out.toolPosition.insert(t.targetId, sampleVec2(t.keys, f, QPointF(), t.interpolation));
|
||||
} else if (t.property == Project::AnimationProperty::Visibility) {
|
||||
out.toolVisibility.insert(t.targetId, sampleBool(t.keys, f, true));
|
||||
} else if (t.property == Project::AnimationProperty::Priority) {
|
||||
out.toolPriority.insert(t.targetId, sampleDouble(t.keys, f, 10.0, t.interpolation));
|
||||
}
|
||||
} else if (t.targetKind == Project::AnimationTargetKind::Camera) {
|
||||
if (t.property == Project::AnimationProperty::Position) {
|
||||
out.cameraPosition.insert(t.targetId, sampleVec2(t.keys, f, QPointF(), t.interpolation));
|
||||
} else if (t.property == Project::AnimationProperty::CameraScale) {
|
||||
out.cameraScale.insert(t.targetId, sampleDouble(t.keys, f, 1.0, t.interpolation));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
QVector<Project::Entity::ImageFrame> spriteFramesForEntity(const Project::Animation& animation,
|
||||
const QString& entityId) {
|
||||
QVector<Project::Entity::ImageFrame> out;
|
||||
for (const auto& t : animation.tracks) {
|
||||
if (t.targetKind != Project::AnimationTargetKind::Entity ||
|
||||
t.targetId != entityId ||
|
||||
t.property != Project::AnimationProperty::Sprite) {
|
||||
continue;
|
||||
}
|
||||
out.reserve(out.size() + t.keys.size());
|
||||
for (const auto& k : t.keys) {
|
||||
if (t.valueType == Project::AnimationValueType::PngBytes) {
|
||||
if (!k.bytesValue.isEmpty()) {
|
||||
out.push_back(Project::Entity::ImageFrame{k.frame, QString()});
|
||||
}
|
||||
} else if (t.valueType == Project::AnimationValueType::ImagePath) {
|
||||
// 旧格式:不再支持路径型 sprite 关键帧(按“无/回退默认贴图”处理)
|
||||
}
|
||||
}
|
||||
}
|
||||
std::sort(out.begin(), out.end(), [](const auto& a, const auto& b) { return a.frame < b.frame; });
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include "domain/Project.h"
|
||||
|
||||
#include <QHash>
|
||||
#include <QPointF>
|
||||
#include <QString>
|
||||
|
||||
namespace core {
|
||||
|
||||
struct AnimationSample {
|
||||
QHash<QString, QPointF> entityPosition;
|
||||
QHash<QString, double> entityUserScale;
|
||||
QHash<QString, double> entityDepthScale01;
|
||||
QHash<QString, QByteArray> entitySpritePng;
|
||||
QHash<QString, bool> entityVisibility;
|
||||
QHash<QString, double> entityPriority;
|
||||
|
||||
QHash<QString, QPointF> toolPosition;
|
||||
QHash<QString, bool> toolVisibility;
|
||||
QHash<QString, double> toolPriority;
|
||||
|
||||
QHash<QString, QPointF> cameraPosition;
|
||||
QHash<QString, double> cameraScale;
|
||||
};
|
||||
|
||||
[[nodiscard]] AnimationSample evaluateAnimation(const Project::Animation& animation, int frame);
|
||||
[[nodiscard]] QVector<Project::Entity::ImageFrame> spriteFramesForEntity(const Project::Animation& animation,
|
||||
const QString& entityId);
|
||||
|
||||
} // namespace core
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
#include "domain/EntityIntro.h"
|
||||
|
||||
#include <QRect>
|
||||
#include <QString>
|
||||
#include <QPointF>
|
||||
#include <QHash>
|
||||
#include <QByteArray>
|
||||
#include <QVector>
|
||||
|
||||
#include <algorithm>
|
||||
@@ -59,9 +61,16 @@ public:
|
||||
bool blackholeVisible = true;
|
||||
// 背景空缺修复方案:copy_background / use_original_background / model_inpaint(预留)
|
||||
QString blackholeResolvedBy;
|
||||
/// 修复结果相对项目根路径;与 blackholeOverlayRect 一起用于画布叠加,不修改 background 原图
|
||||
QString blackholeOverlayPath;
|
||||
/// 覆盖层在背景图坐标系中的像素矩形;宽或高 <=0 表示无
|
||||
QRect blackholeOverlayRect;
|
||||
QPointF originWorld;
|
||||
int depth = 0; // 0..255
|
||||
int priority = 1; // 0=背景之上最底层;越大越靠上(同优先级再按距离缩放/深度)
|
||||
QString imagePath; // 相对路径,例如 "assets/entities/entity-1.png"
|
||||
QByteArray defaultImagePng; // 默认贴图 PNG bytes(不兼容旧路径工程时此为唯一来源)
|
||||
QByteArray runtimeImagePng; // 运行时求值后的 PNG bytes(不序列化)
|
||||
QPointF imageTopLeftWorld; // 贴图左上角 world 坐标
|
||||
// 人为整体缩放,与深度驱动的距离缩放相乘(画布中 visualScale = distanceScale * userScale;
|
||||
// distanceScale 在有 distanceScaleCalibMult 时为 (0.5+depth01)/calib,使抠图处为 1.0)
|
||||
@@ -73,8 +82,7 @@ public:
|
||||
// 约定:对话气泡等 UI 元素默认打开。
|
||||
bool ignoreDistanceScale = false;
|
||||
|
||||
// 父子关系:当 parentId 非空时,实体会保持相对父实体的偏移(world 坐标)。
|
||||
// parentOffsetWorld 表示「childOrigin - parentOrigin」在 world 中的偏移。
|
||||
// 父子关系:在「原点已与形心对齐」时,parentOffsetWorld 表示子形心相对父形心的世界偏移;否则为子原点相对父原点的偏移(与求值器一致)。
|
||||
QString parentId;
|
||||
QPointF parentOffsetWorld;
|
||||
|
||||
@@ -97,9 +105,6 @@ public:
|
||||
|
||||
// v2:project.json 仅存 id + payload,几何与动画在 entityPayloadPath(.hfe)中。
|
||||
QString entityPayloadPath; // 例如 "assets/entities/entity-1.hfe"
|
||||
// 仅打开 v1 项目时由 JSON 的 animationBundle 填入,用于合并旧 .anim;保存 v2 前应为空。
|
||||
QString legacyAnimSidecarPath;
|
||||
|
||||
QVector<KeyframeVec2> locationKeys;
|
||||
QVector<KeyframeFloat01> depthScaleKeys;
|
||||
QVector<KeyframeDouble> userScaleKeys;
|
||||
@@ -128,6 +133,7 @@ public:
|
||||
|
||||
// 基准位置(无关键帧时使用)
|
||||
QPointF originWorld;
|
||||
int priority = 10; // 默认高于实体(实体默认 1);越大越靠上
|
||||
QVector<Entity::KeyframeVec2> locationKeys;
|
||||
|
||||
// 可见性轨道:布尔关键帧(显示/隐藏);渲染时会被解释为“10 帧淡入淡出”。
|
||||
@@ -166,96 +172,79 @@ public:
|
||||
void setActiveCameraId(const QString& id) { m_activeCameraId = id; }
|
||||
const QString& activeCameraId() const { return m_activeCameraId; }
|
||||
|
||||
// —— 动画系统(Blender/NLA 风格简化版,工程级)——
|
||||
struct AnimationClip {
|
||||
// —— 动画系统(Godot AnimationPlayer 风格简化版)——
|
||||
enum class AnimationTargetKind { Entity, Tool, Camera };
|
||||
enum class AnimationProperty { Position, UserScale, Sprite, Visibility, DepthScale, CameraScale, Priority };
|
||||
enum class AnimationValueType { Vec2, Double, Bool, ImagePath, PngBytes };
|
||||
enum class AnimationInterpolation { Hold, Linear };
|
||||
|
||||
struct AnimationKey {
|
||||
int frame = 0;
|
||||
QPointF vec2Value;
|
||||
double numberValue = 0.0;
|
||||
bool boolValue = true;
|
||||
QString stringValue;
|
||||
QByteArray bytesValue;
|
||||
};
|
||||
|
||||
struct AnimationTrack {
|
||||
QString id;
|
||||
AnimationTargetKind targetKind = AnimationTargetKind::Entity;
|
||||
QString targetId;
|
||||
AnimationProperty property = AnimationProperty::Position;
|
||||
AnimationValueType valueType = AnimationValueType::Vec2;
|
||||
AnimationInterpolation interpolation = AnimationInterpolation::Linear;
|
||||
QVector<AnimationKey> keys;
|
||||
};
|
||||
|
||||
struct Animation {
|
||||
QString id;
|
||||
QString name;
|
||||
|
||||
// Entity channels (keyed by entity id)
|
||||
QHash<QString, QVector<Entity::KeyframeVec2>> entityLocationKeys;
|
||||
QHash<QString, QVector<Entity::KeyframeDouble>> entityUserScaleKeys;
|
||||
QHash<QString, QVector<Entity::ImageFrame>> entityImageFrames;
|
||||
QHash<QString, QVector<ToolKeyframeBool>> entityVisibilityKeys;
|
||||
|
||||
// Tool channels (keyed by tool id)
|
||||
QHash<QString, QVector<Entity::KeyframeVec2>> toolLocationKeys;
|
||||
QHash<QString, QVector<ToolKeyframeBool>> toolVisibilityKeys;
|
||||
|
||||
QHash<QString, QVector<Entity::KeyframeVec2>> cameraLocationKeys;
|
||||
QHash<QString, QVector<Entity::KeyframeDouble>> cameraScaleKeys;
|
||||
QString payloadPath; // 例如 "assets/animations/animation-1.hfa"
|
||||
int fps = 30;
|
||||
int lengthFrames = kClipFixedFrames;
|
||||
bool loop = true;
|
||||
QVector<AnimationTrack> tracks;
|
||||
};
|
||||
|
||||
struct NlaStrip {
|
||||
void setAnimations(const QVector<Animation>& animations) { m_animations = animations; }
|
||||
const QVector<Animation>& animations() const { return m_animations; }
|
||||
|
||||
void setActiveAnimationId(const QString& id) { m_activeAnimationId = id; }
|
||||
const QString& activeAnimationId() const { return m_activeAnimationId; }
|
||||
|
||||
const Animation* findAnimationById(const QString& id) const {
|
||||
for (const auto& a : m_animations) {
|
||||
if (a.id == id) return &a;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
Animation* findAnimationById(const QString& id) {
|
||||
for (auto& a : m_animations) {
|
||||
if (a.id == id) return &a;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
const Animation* activeAnimationOrNull() const {
|
||||
const Animation* a = findAnimationById(m_activeAnimationId);
|
||||
if (a) return a;
|
||||
return m_animations.isEmpty() ? nullptr : &m_animations.front();
|
||||
}
|
||||
Animation* activeAnimationOrNull() {
|
||||
Animation* a = findAnimationById(m_activeAnimationId);
|
||||
if (a) return a;
|
||||
return m_animations.isEmpty() ? nullptr : &m_animations.front();
|
||||
}
|
||||
|
||||
struct PresentationHotspot {
|
||||
QString id;
|
||||
QString clipId;
|
||||
int startSlot = 0; // slot index; 1 slot = kClipFixedFrames frames
|
||||
int slotLen = 1; // currently fixed to 1; reserved for future
|
||||
bool enabled = true;
|
||||
bool muted = false;
|
||||
QPointF centerWorld;
|
||||
QString targetAnimationId;
|
||||
};
|
||||
|
||||
struct NlaTrack {
|
||||
QString id;
|
||||
QString name;
|
||||
bool muted = false;
|
||||
bool solo = false;
|
||||
QVector<NlaStrip> strips;
|
||||
};
|
||||
|
||||
struct AnimationScheme {
|
||||
QString id;
|
||||
QString name;
|
||||
QVector<NlaTrack> tracks;
|
||||
};
|
||||
|
||||
void setAnimationClips(const QVector<AnimationClip>& clips) { m_clips = clips; }
|
||||
const QVector<AnimationClip>& animationClips() const { return m_clips; }
|
||||
|
||||
void setAnimationSchemes(const QVector<AnimationScheme>& schemes) { m_schemes = schemes; }
|
||||
const QVector<AnimationScheme>& animationSchemes() const { return m_schemes; }
|
||||
|
||||
void setActiveSchemeId(const QString& id) { m_activeSchemeId = id; }
|
||||
const QString& activeSchemeId() const { return m_activeSchemeId; }
|
||||
|
||||
void setSelectedStripId(const QString& id) { m_selectedStripId = id; }
|
||||
const QString& selectedStripId() const { return m_selectedStripId; }
|
||||
|
||||
const AnimationScheme* findSchemeById(const QString& id) const {
|
||||
for (const auto& s : m_schemes) {
|
||||
if (s.id == id) return &s;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
AnimationScheme* findSchemeById(const QString& id) {
|
||||
for (auto& s : m_schemes) {
|
||||
if (s.id == id) return &s;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const AnimationClip* findClipById(const QString& id) const {
|
||||
for (const auto& c : m_clips) {
|
||||
if (c.id == id) return &c;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
AnimationClip* findClipById(const QString& id) {
|
||||
for (auto& c : m_clips) {
|
||||
if (c.id == id) return &c;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const AnimationScheme* activeSchemeOrNull() const {
|
||||
const AnimationScheme* s = findSchemeById(m_activeSchemeId);
|
||||
if (s) return s;
|
||||
return m_schemes.isEmpty() ? nullptr : &m_schemes.front();
|
||||
}
|
||||
AnimationScheme* activeSchemeOrNull() {
|
||||
AnimationScheme* s = findSchemeById(m_activeSchemeId);
|
||||
if (s) return s;
|
||||
return m_schemes.isEmpty() ? nullptr : &m_schemes.front();
|
||||
}
|
||||
void setPresentationHotspots(const QVector<PresentationHotspot>& v) { m_presentationHotspots = v; }
|
||||
const QVector<PresentationHotspot>& presentationHotspots() const { return m_presentationHotspots; }
|
||||
QVector<PresentationHotspot>& presentationHotspotsRef() { return m_presentationHotspots; }
|
||||
|
||||
private:
|
||||
QString m_name;
|
||||
@@ -271,10 +260,9 @@ private:
|
||||
QVector<Camera> m_cameras;
|
||||
QString m_activeCameraId;
|
||||
|
||||
QVector<AnimationClip> m_clips;
|
||||
QVector<AnimationScheme> m_schemes;
|
||||
QString m_activeSchemeId;
|
||||
QString m_selectedStripId;
|
||||
QVector<Animation> m_animations;
|
||||
QString m_activeAnimationId;
|
||||
QVector<PresentationHotspot> m_presentationHotspots;
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
#include "eval/ProjectEvaluator.h"
|
||||
|
||||
#include "animation/AnimationEvaluator.h"
|
||||
#include "animation/AnimationSampling.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace core::eval {
|
||||
@@ -15,68 +17,6 @@ struct NodeRef {
|
||||
int index = -1;
|
||||
};
|
||||
|
||||
QPointF sampledOriginForEntity(const core::Project::Entity& e,
|
||||
const core::Project::AnimationClip* clipOrNull,
|
||||
int localFrame) {
|
||||
if (clipOrNull && clipOrNull->entityLocationKeys.contains(e.id)) {
|
||||
const auto& keys = clipOrNull->entityLocationKeys.value(e.id);
|
||||
return core::sampleLocation(keys, localFrame, e.originWorld, core::KeyInterpolation::Linear);
|
||||
}
|
||||
return core::sampleLocation(e.locationKeys, localFrame, e.originWorld, core::KeyInterpolation::Linear);
|
||||
}
|
||||
|
||||
QPointF sampledRelativeForEntity(const core::Project::Entity& e,
|
||||
const core::Project::AnimationClip* clipOrNull,
|
||||
int localFrame) {
|
||||
if (clipOrNull && clipOrNull->entityLocationKeys.contains(e.id)) {
|
||||
const auto& keys = clipOrNull->entityLocationKeys.value(e.id);
|
||||
return core::sampleLocation(keys, localFrame, e.parentOffsetWorld, core::KeyInterpolation::Linear);
|
||||
}
|
||||
return core::sampleLocation(e.locationKeys, localFrame, e.parentOffsetWorld, core::KeyInterpolation::Linear);
|
||||
}
|
||||
|
||||
QPointF sampledOriginForTool(const core::Project::Tool& t,
|
||||
const core::Project::AnimationClip* clipOrNull,
|
||||
int localFrame) {
|
||||
if (clipOrNull && clipOrNull->toolLocationKeys.contains(t.id)) {
|
||||
const auto& keys = clipOrNull->toolLocationKeys.value(t.id);
|
||||
return core::sampleLocation(keys, localFrame, t.originWorld, core::KeyInterpolation::Linear);
|
||||
}
|
||||
return core::sampleLocation(t.locationKeys, localFrame, t.originWorld, core::KeyInterpolation::Linear);
|
||||
}
|
||||
|
||||
QPointF sampledCenterForCamera(const core::Project::Camera& c,
|
||||
const core::Project::AnimationClip* clipOrNull,
|
||||
int localFrame) {
|
||||
if (clipOrNull && clipOrNull->cameraLocationKeys.contains(c.id)) {
|
||||
const auto& keys = clipOrNull->cameraLocationKeys.value(c.id);
|
||||
return core::sampleLocation(keys, localFrame, c.centerWorld, core::KeyInterpolation::Linear);
|
||||
}
|
||||
return core::sampleLocation(c.locationKeys, localFrame, c.centerWorld, core::KeyInterpolation::Linear);
|
||||
}
|
||||
|
||||
double sampledViewScaleForCamera(const core::Project::Camera& c,
|
||||
const core::Project::AnimationClip* clipOrNull,
|
||||
int localFrame) {
|
||||
if (clipOrNull && clipOrNull->cameraScaleKeys.contains(c.id)) {
|
||||
const auto& keys = clipOrNull->cameraScaleKeys.value(c.id);
|
||||
if (!keys.isEmpty()) {
|
||||
return core::sampleUserScale(keys, localFrame, c.viewScale, core::KeyInterpolation::Linear);
|
||||
}
|
||||
}
|
||||
return core::sampleUserScale(c.scaleKeys, localFrame, c.viewScale, core::KeyInterpolation::Linear);
|
||||
}
|
||||
|
||||
QPointF sampledRelativeForTool(const core::Project::Tool& t,
|
||||
const core::Project::AnimationClip* clipOrNull,
|
||||
int localFrame) {
|
||||
if (clipOrNull && clipOrNull->toolLocationKeys.contains(t.id)) {
|
||||
const auto& keys = clipOrNull->toolLocationKeys.value(t.id);
|
||||
return core::sampleLocation(keys, localFrame, t.parentOffsetWorld, core::KeyInterpolation::Linear);
|
||||
}
|
||||
return core::sampleLocation(t.locationKeys, localFrame, t.parentOffsetWorld, core::KeyInterpolation::Linear);
|
||||
}
|
||||
|
||||
struct VisKey {
|
||||
int frame = 0;
|
||||
bool value = true;
|
||||
@@ -144,82 +84,10 @@ double opacityFromBoolKeys(const QVector<core::Project::ToolKeyframeBool>& keysR
|
||||
return state ? 1.0 : 0.0;
|
||||
}
|
||||
|
||||
struct StripEvalCtx {
|
||||
const core::Project::AnimationScheme* scheme = nullptr;
|
||||
const core::Project::NlaStrip* strip = nullptr;
|
||||
const core::Project::AnimationClip* clip = nullptr;
|
||||
int slot = 0;
|
||||
int localFrame = 0; // 0..kClipFixedFrames-1
|
||||
};
|
||||
|
||||
static const core::Project::NlaStrip* findStripById(const core::Project::AnimationScheme& scheme, const QString& id) {
|
||||
if (id.isEmpty()) return nullptr;
|
||||
for (const auto& tr : scheme.tracks) {
|
||||
for (const auto& st : tr.strips) {
|
||||
if (st.id == id) return &st;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static bool trackIsEffectivelyMuted(const core::Project::AnimationScheme& scheme, const core::Project::NlaTrack& t) {
|
||||
// 若有任意 solo=true,则只有 solo 的 track 生效(且仍受自身 muted 控制)
|
||||
bool anySolo = false;
|
||||
for (const auto& tr : scheme.tracks) {
|
||||
if (tr.solo) {
|
||||
anySolo = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (anySolo && !t.solo) {
|
||||
return true;
|
||||
}
|
||||
return t.muted;
|
||||
}
|
||||
|
||||
static const core::Project::NlaStrip* pickStripAtSlot(const core::Project::AnimationScheme& scheme, int slot) {
|
||||
const core::Project::NlaStrip* chosen = nullptr;
|
||||
for (const auto& tr : scheme.tracks) {
|
||||
if (trackIsEffectivelyMuted(scheme, tr)) continue;
|
||||
for (const auto& st : tr.strips) {
|
||||
if (!st.enabled || st.muted) continue;
|
||||
const int a = st.startSlot;
|
||||
const int b = st.startSlot + std::max(1, st.slotLen);
|
||||
if (slot >= a && slot < b) {
|
||||
chosen = &st; // 轨道顺序靠后的覆盖靠前的(更接近“上层”)
|
||||
}
|
||||
}
|
||||
}
|
||||
return chosen;
|
||||
}
|
||||
|
||||
static StripEvalCtx resolveStripCtx(const core::Project& project, int globalFrame) {
|
||||
StripEvalCtx ctx;
|
||||
const auto* scheme = project.activeSchemeOrNull();
|
||||
if (!scheme) {
|
||||
ctx.localFrame = std::max(0, globalFrame);
|
||||
return ctx;
|
||||
}
|
||||
ctx.scheme = scheme;
|
||||
const int g = std::max(0, globalFrame);
|
||||
ctx.slot = g / core::Project::kClipFixedFrames;
|
||||
ctx.localFrame = g % core::Project::kClipFixedFrames;
|
||||
|
||||
const core::Project::NlaStrip* st = findStripById(*scheme, project.selectedStripId());
|
||||
// 若选中条带不覆盖当前 slot,则退回自动挑选
|
||||
if (!st || ctx.slot < st->startSlot || ctx.slot >= (st->startSlot + std::max(1, st->slotLen)) || !st->enabled || st->muted) {
|
||||
st = pickStripAtSlot(*scheme, ctx.slot);
|
||||
}
|
||||
ctx.strip = st;
|
||||
if (st) {
|
||||
ctx.clip = project.findClipById(st->clipId);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ResolvedProjectFrame evaluateAtFrame(const core::Project& project, int frame, int fadeFrames) {
|
||||
ResolvedProjectFrame evaluateAtFrame(const core::Project& project, int frame, int fadeFrames,
|
||||
const QString& animationId) {
|
||||
ResolvedProjectFrame out;
|
||||
const auto& ents = project.entities();
|
||||
const auto& tools = project.tools();
|
||||
@@ -228,9 +96,15 @@ ResolvedProjectFrame evaluateAtFrame(const core::Project& project, int frame, in
|
||||
out.tools.reserve(tools.size());
|
||||
out.cameras.reserve(cams.size());
|
||||
|
||||
const StripEvalCtx ctx = resolveStripCtx(project, frame);
|
||||
const int localFrame = ctx.localFrame;
|
||||
const core::Project::AnimationClip* clip = ctx.clip;
|
||||
const int localFrame = std::max(0, frame);
|
||||
const core::Project::Animation* activeAnimation = nullptr;
|
||||
if (animationId.isEmpty()) {
|
||||
activeAnimation = project.activeAnimationOrNull();
|
||||
} else {
|
||||
activeAnimation = project.findAnimationById(animationId);
|
||||
}
|
||||
const core::AnimationSample animSample =
|
||||
activeAnimation ? core::evaluateAnimation(*activeAnimation, frame) : core::AnimationSample{};
|
||||
|
||||
QHash<QString, NodeRef> index;
|
||||
index.reserve(ents.size() + tools.size());
|
||||
@@ -262,8 +136,8 @@ ResolvedProjectFrame evaluateAtFrame(const core::Project& project, int frame, in
|
||||
// cycle:降级为自身采样 origin
|
||||
const NodeRef r = index.value(id);
|
||||
QPointF o;
|
||||
if (r.kind == NodeRef::Kind::Entity) o = sampledOriginForEntity(ents[r.index], clip, localFrame);
|
||||
else o = sampledOriginForTool(tools[r.index], clip, localFrame);
|
||||
if (r.kind == NodeRef::Kind::Entity) o = ents[r.index].originWorld;
|
||||
else o = tools[r.index].originWorld;
|
||||
resolvedOrigin.insert(id, o);
|
||||
return o;
|
||||
}
|
||||
@@ -275,20 +149,23 @@ ResolvedProjectFrame evaluateAtFrame(const core::Project& project, int frame, in
|
||||
if (r.kind == NodeRef::Kind::Entity) {
|
||||
const auto& e = ents[r.index];
|
||||
parentId = e.parentId;
|
||||
selfSampled = sampledOriginForEntity(e, clip, localFrame);
|
||||
selfSampled = e.parentId.isEmpty() ? e.originWorld : e.parentOffsetWorld;
|
||||
if (animSample.entityPosition.contains(e.id)) {
|
||||
selfSampled = animSample.entityPosition.value(e.id);
|
||||
}
|
||||
} else {
|
||||
const auto& t = tools[r.index];
|
||||
parentId = t.parentId;
|
||||
selfSampled = sampledOriginForTool(t, clip, localFrame);
|
||||
selfSampled = t.parentId.isEmpty() ? t.originWorld : t.parentOffsetWorld;
|
||||
if (animSample.toolPosition.contains(t.id)) {
|
||||
selfSampled = animSample.toolPosition.value(t.id);
|
||||
}
|
||||
}
|
||||
|
||||
QPointF outO = selfSampled;
|
||||
if (!parentId.isEmpty() && index.contains(parentId)) {
|
||||
const QPointF po = resolve(parentId);
|
||||
const QPointF rel = (r.kind == NodeRef::Kind::Entity)
|
||||
? sampledRelativeForEntity(ents[r.index], clip, localFrame)
|
||||
: sampledRelativeForTool(tools[r.index], clip, localFrame);
|
||||
outO = po + rel;
|
||||
outO = po + selfSampled;
|
||||
}
|
||||
|
||||
resolving.insert(id, false);
|
||||
@@ -308,50 +185,69 @@ ResolvedProjectFrame evaluateAtFrame(const core::Project& project, int frame, in
|
||||
for (int i = 0; i < ents.size(); ++i) {
|
||||
core::Project::Entity e = ents[i];
|
||||
const QPointF base = e.originWorld;
|
||||
const QPointF ro = (!e.id.isEmpty()) ? resolve(e.id) : sampledOriginForEntity(e, clip, localFrame);
|
||||
const QPointF ro = (!e.id.isEmpty()) ? resolve(e.id) : e.originWorld;
|
||||
const QPointF delta = ro - base;
|
||||
e.originWorld = ro;
|
||||
e.imageTopLeftWorld += delta;
|
||||
|
||||
// Clip channels: userScale / imagePath(迁移后仍能逐帧显示)
|
||||
if (clip && clip->entityUserScaleKeys.contains(e.id)) {
|
||||
const auto& keys = clip->entityUserScaleKeys.value(e.id);
|
||||
e.userScale = core::sampleUserScale(keys, localFrame, e.userScale, core::KeyInterpolation::Linear);
|
||||
if (animSample.entityUserScale.contains(e.id)) {
|
||||
e.userScale = animSample.entityUserScale.value(e.id);
|
||||
}
|
||||
if (clip && clip->entityImageFrames.contains(e.id)) {
|
||||
const auto& frames = clip->entityImageFrames.value(e.id);
|
||||
e.imagePath = core::sampleImagePath(frames, localFrame, e.imagePath);
|
||||
if (animSample.entityDepthScale01.contains(e.id)) {
|
||||
const double v01 = std::clamp(animSample.entityDepthScale01.value(e.id), 0.0, 1.0);
|
||||
e.depth = int(std::lround(v01 * 255.0));
|
||||
}
|
||||
e.runtimeImagePng.clear();
|
||||
if (animSample.entitySpritePng.contains(e.id)) {
|
||||
const QByteArray png = animSample.entitySpritePng.value(e.id);
|
||||
if (!png.isEmpty()) {
|
||||
e.runtimeImagePng = png;
|
||||
}
|
||||
}
|
||||
if (e.runtimeImagePng.isEmpty() && !e.defaultImagePng.isEmpty()) {
|
||||
e.runtimeImagePng = e.defaultImagePng;
|
||||
}
|
||||
|
||||
QVector<core::Project::ToolKeyframeBool> visKeys = e.visibilityKeys;
|
||||
if (clip && clip->entityVisibilityKeys.contains(e.id)) {
|
||||
visKeys = clip->entityVisibilityKeys.value(e.id);
|
||||
if (animSample.entityPriority.contains(e.id)) {
|
||||
const double p = animSample.entityPriority.value(e.id);
|
||||
e.priority = std::clamp(int(std::lround(p)), -1000000, 1000000);
|
||||
}
|
||||
const double op = opacityWithDefault(visKeys, e.visible);
|
||||
out.entities.push_back(ResolvedEntity{e, op});
|
||||
|
||||
const double animOp = animSample.entityVisibility.contains(e.id)
|
||||
? (animSample.entityVisibility.value(e.id) ? 1.0 : 0.0)
|
||||
: (e.visible ? 1.0 : 0.0);
|
||||
out.entities.push_back(ResolvedEntity{e, animOp});
|
||||
}
|
||||
|
||||
// Tools:resolved origin + opacity(可见性轨道)
|
||||
for (int i = 0; i < tools.size(); ++i) {
|
||||
core::Project::Tool t = tools[i];
|
||||
const QPointF base = t.originWorld;
|
||||
const QPointF ro = (!t.id.isEmpty()) ? resolve(t.id) : sampledOriginForTool(t, clip, localFrame);
|
||||
const QPointF ro = (!t.id.isEmpty()) ? resolve(t.id) : t.originWorld;
|
||||
const QPointF delta = ro - base;
|
||||
t.originWorld = ro;
|
||||
// parentOffsetWorld 已包含相对关系,不在这里改
|
||||
QVector<core::Project::ToolKeyframeBool> visKeys = t.visibilityKeys;
|
||||
if (clip && clip->toolVisibilityKeys.contains(t.id)) {
|
||||
visKeys = clip->toolVisibilityKeys.value(t.id);
|
||||
}
|
||||
const double op = opacityWithDefault(visKeys, t.visible);
|
||||
(void)delta;
|
||||
out.tools.push_back(ResolvedTool{t, op});
|
||||
if (animSample.toolPriority.contains(t.id)) {
|
||||
const double p = animSample.toolPriority.value(t.id);
|
||||
t.priority = std::clamp(int(std::lround(p)), -1000000, 1000000);
|
||||
}
|
||||
const double animOp = animSample.toolVisibility.contains(t.id)
|
||||
? (animSample.toolVisibility.value(t.id) ? 1.0 : 0.0)
|
||||
: (t.visible ? 1.0 : 0.0);
|
||||
out.tools.push_back(ResolvedTool{t, animOp});
|
||||
}
|
||||
|
||||
for (const auto& c : cams) {
|
||||
core::Project::Camera cam = c;
|
||||
cam.centerWorld = sampledCenterForCamera(c, clip, localFrame);
|
||||
cam.viewScale = sampledViewScaleForCamera(c, clip, localFrame);
|
||||
cam.centerWorld = c.centerWorld;
|
||||
cam.viewScale = c.viewScale;
|
||||
if (animSample.cameraPosition.contains(c.id)) {
|
||||
cam.centerWorld = animSample.cameraPosition.value(c.id);
|
||||
}
|
||||
if (animSample.cameraScale.contains(c.id)) {
|
||||
cam.viewScale = animSample.cameraScale.value(c.id);
|
||||
}
|
||||
out.cameras.push_back(ResolvedCamera{std::move(cam)});
|
||||
}
|
||||
|
||||
|
||||
@@ -29,8 +29,9 @@ struct ResolvedProjectFrame {
|
||||
QVector<ResolvedCamera> cameras;
|
||||
};
|
||||
|
||||
/// 逐帧求值:处理父子跟随与工具可见性淡入淡出。
|
||||
ResolvedProjectFrame evaluateAtFrame(const core::Project& project, int frame, int fadeFrames = 10);
|
||||
/// 逐帧求值:处理父子跟随与工具可见性淡入淡出。animationId 为空时使用 activeAnimationId(含既有回退)。
|
||||
ResolvedProjectFrame evaluateAtFrame(const core::Project& project, int frame, int fadeFrames = 10,
|
||||
const QString& animationId = QString());
|
||||
|
||||
} // namespace core::eval
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
|
||||
#include <QtGlobal>
|
||||
#include <QImageReader>
|
||||
#include <QRect>
|
||||
#include <QSize>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace core {
|
||||
namespace image_decode {
|
||||
|
||||
/// 提高 Qt6 解码像素上限,避免大图被 reader 直接拒绝(单位 MB)
|
||||
inline void prepareLargeImageReader() {
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
|
||||
QImageReader::setAllocationLimit(4096);
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 裁剪对话框等「仅预览」场景:像素上限(libvips 缩略与 Qt 回退共用)
|
||||
[[maybe_unused]] constexpr qint64 kPreviewMaxPixels = 16LL * 1000 * 1000;
|
||||
|
||||
/// 编辑器 / 帧图替换:缩略像素上限(libvips 与 Qt 回退共用)
|
||||
[[maybe_unused]] constexpr qint64 kWorkspaceMaxPixels = 48LL * 1000 * 1000;
|
||||
|
||||
/// 在 read() 前调用:若像素数超过 maxPixels,则 setScaledSize 按比例缩小(不解码全图)
|
||||
inline void downscaleReaderIfExceeds(QImageReader& reader, qint64 maxPixels) {
|
||||
const QSize sz = reader.size();
|
||||
if (!sz.isValid() || sz.width() <= 0 || sz.height() <= 0) {
|
||||
return;
|
||||
}
|
||||
const qint64 pixels = qint64(sz.width()) * qint64(sz.height());
|
||||
if (pixels <= maxPixels) {
|
||||
return;
|
||||
}
|
||||
const double s = std::sqrt(double(maxPixels) / double(pixels));
|
||||
const int nw = std::max(1, int(std::lround(sz.width() * s)));
|
||||
const int nh = std::max(1, int(std::lround(sz.height() * s)));
|
||||
reader.setScaledSize(QSize(nw, nh));
|
||||
}
|
||||
|
||||
/// 将 rect 从 fromSize 坐标系线性映射到 toSize(例如预览图上的选区 → 原图逻辑像素)
|
||||
inline QRect mapRectBetweenSizes(const QRect& rect, const QSize& fromSize, const QSize& toSize) {
|
||||
if (!rect.isValid() || fromSize.width() <= 0 || fromSize.height() <= 0 || toSize.width() <= 0 ||
|
||||
toSize.height() <= 0) {
|
||||
return {};
|
||||
}
|
||||
const qreal sx = qreal(toSize.width()) / qreal(fromSize.width());
|
||||
const qreal sy = qreal(toSize.height()) / qreal(fromSize.height());
|
||||
const int x0 = int(std::floor(qreal(rect.left()) * sx));
|
||||
const int y0 = int(std::floor(qreal(rect.top()) * sy));
|
||||
const int x1 = int(std::ceil(qreal(rect.right() + 1) * sx));
|
||||
const int y1 = int(std::ceil(qreal(rect.bottom() + 1) * sy));
|
||||
QRect out(x0, y0, x1 - x0, y1 - y0);
|
||||
return out.normalized().intersected(QRect(0, 0, toSize.width(), toSize.height()));
|
||||
}
|
||||
|
||||
} // namespace image_decode
|
||||
} // namespace core
|
||||
@@ -0,0 +1,99 @@
|
||||
#include "image/ImageFileLoader.h"
|
||||
|
||||
#include "image/ImageDecodeConfig.h"
|
||||
#include "large_image/VipsBackend.h"
|
||||
|
||||
#include <QImageReader>
|
||||
|
||||
namespace core {
|
||||
namespace image_file {
|
||||
|
||||
bool probeImagePixelSize(const QString& absolutePath, QSize* outSize) {
|
||||
if (!outSize || absolutePath.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (VipsBackend::isAvailable() && VipsBackend::probeSize(absolutePath, outSize)) {
|
||||
return outSize->isValid() && outSize->width() > 0 && outSize->height() > 0;
|
||||
}
|
||||
core::image_decode::prepareLargeImageReader();
|
||||
QImageReader reader(absolutePath);
|
||||
reader.setAutoTransform(true);
|
||||
*outSize = reader.size();
|
||||
return outSize->isValid() && outSize->width() > 0 && outSize->height() > 0;
|
||||
}
|
||||
|
||||
QImage loadImageLimited(const QString& absolutePath, qint64 maxPixelBudget) {
|
||||
if (absolutePath.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
QImage viaVips;
|
||||
if (VipsBackend::isAvailable() &&
|
||||
VipsBackend::loadThumbnailQImage(absolutePath, maxPixelBudget, &viaVips) && !viaVips.isNull()) {
|
||||
return viaVips;
|
||||
}
|
||||
core::image_decode::prepareLargeImageReader();
|
||||
QImageReader reader(absolutePath);
|
||||
reader.setAutoTransform(true);
|
||||
core::image_decode::downscaleReaderIfExceeds(reader, maxPixelBudget);
|
||||
return reader.read();
|
||||
}
|
||||
|
||||
bool loadRegionToQImage(const QString& absolutePath, const QRect& rectLogical, int maxOutputWidth, int maxOutputHeight,
|
||||
QImage* out) {
|
||||
if (!out || absolutePath.isEmpty() || maxOutputWidth < 32 || maxOutputHeight < 32) {
|
||||
return false;
|
||||
}
|
||||
out->detach();
|
||||
*out = QImage();
|
||||
if (VipsBackend::isAvailable() &&
|
||||
VipsBackend::loadRegionToQImage(absolutePath, rectLogical, maxOutputWidth, maxOutputHeight, out) &&
|
||||
!out->isNull()) {
|
||||
return true;
|
||||
}
|
||||
QSize logical;
|
||||
if (!probeImagePixelSize(absolutePath, &logical)) {
|
||||
return false;
|
||||
}
|
||||
const QRect r = rectLogical.intersected(QRect(0, 0, logical.width(), logical.height()));
|
||||
if (r.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
QImage full = loadImageLimited(absolutePath, core::image_decode::kWorkspaceMaxPixels);
|
||||
if (full.isNull()) {
|
||||
return false;
|
||||
}
|
||||
const QRect r2 = core::image_decode::mapRectBetweenSizes(r, logical, full.size());
|
||||
QImage piece = full.copy(r2);
|
||||
if (piece.isNull()) {
|
||||
return false;
|
||||
}
|
||||
// 与 vips 路径保持一致:输出永不超过 maxOutput*,避免后续绘制阶段二次缩放导致明显“发糊”。
|
||||
if (piece.width() > maxOutputWidth || piece.height() > maxOutputHeight) {
|
||||
piece = piece.scaled(QSize(maxOutputWidth, maxOutputHeight), Qt::KeepAspectRatio, Qt::SmoothTransformation);
|
||||
}
|
||||
*out = piece;
|
||||
return !out->isNull();
|
||||
}
|
||||
|
||||
bool loadRegionToQImageExact(const QString& absolutePath, const QRect& rectLogical, QImage* out) {
|
||||
if (!out || absolutePath.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
out->detach();
|
||||
*out = QImage();
|
||||
QSize logical;
|
||||
if (!probeImagePixelSize(absolutePath, &logical)) {
|
||||
return false;
|
||||
}
|
||||
const QRect r = rectLogical.intersected(QRect(0, 0, logical.width(), logical.height()));
|
||||
if (r.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
// 1:1 输出:maxOutput 设为 region 本身大小,避免 vips/Qt 管线自动缩放导致坐标不一致。
|
||||
const int w = std::max(1, r.width());
|
||||
const int h = std::max(1, r.height());
|
||||
return loadRegionToQImage(absolutePath, r, w, h, out);
|
||||
}
|
||||
|
||||
} // namespace image_file
|
||||
} // namespace core
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include <QImage>
|
||||
#include <QRect>
|
||||
#include <QString>
|
||||
#include <QSize>
|
||||
|
||||
#include <QtGlobal>
|
||||
|
||||
namespace core {
|
||||
namespace image_file {
|
||||
|
||||
/// 仅读元数据(宽高),不解码像素;优先 libvips,否则 Qt QImageReader::size。
|
||||
[[nodiscard]] bool probeImagePixelSize(const QString& absolutePath, QSize* outSize);
|
||||
|
||||
/// 统一大图解码管线:libvips(可用)按像素预算缩小后再解码为 QImage,否则 Qt。
|
||||
[[nodiscard]] QImage loadImageLimited(const QString& absolutePath, qint64 maxPixelBudget);
|
||||
|
||||
/// 仅解码源图中矩形区域(逻辑像素坐标);超过 maxOutput* 则缩放该区域。
|
||||
/// libvips 可用时走随机访问裁剪;否则整图降采样回退后再拷贝矩形。
|
||||
[[nodiscard]] bool loadRegionToQImage(const QString& absolutePath, const QRect& rectLogical, int maxOutputWidth,
|
||||
int maxOutputHeight, QImage* out);
|
||||
|
||||
/// 仅解码源图中矩形区域(逻辑像素坐标),并保证输出为 1:1 像素(不缩放)。
|
||||
/// 用于抠图/像素对齐等需要严格坐标一致的场景。
|
||||
[[nodiscard]] bool loadRegionToQImageExact(const QString& absolutePath, const QRect& rectLogical, QImage* out);
|
||||
|
||||
} // namespace image_file
|
||||
} // namespace core
|
||||
@@ -0,0 +1,271 @@
|
||||
// 必须在任何 Qt 头(会定义宏 signals)之前包含 vips/glib
|
||||
#ifdef LT_HAVE_VIPS
|
||||
# include <vips/vips.h>
|
||||
#endif
|
||||
|
||||
#include "large_image/VipsBackend.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace core {
|
||||
|
||||
#ifdef LT_HAVE_VIPS
|
||||
|
||||
namespace {
|
||||
|
||||
bool gReady = false;
|
||||
|
||||
QByteArray encodedPath(const QString& path) {
|
||||
return QFile::encodeName(path);
|
||||
}
|
||||
|
||||
bool saveVipsImageToPngFile(VipsImage* img, const QString& destPath) {
|
||||
if (!img) {
|
||||
return false;
|
||||
}
|
||||
const QByteArray dest = encodedPath(destPath);
|
||||
if (vips_pngsave(img, dest.constData(), "compression", 3, nullptr) != 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool VipsBackend::init(const char* argv0) {
|
||||
if (gReady) {
|
||||
return true;
|
||||
}
|
||||
if (vips_init(argv0) != 0) {
|
||||
return false;
|
||||
}
|
||||
gReady = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void VipsBackend::shutdown() {
|
||||
if (!gReady) {
|
||||
return;
|
||||
}
|
||||
vips_shutdown();
|
||||
gReady = false;
|
||||
}
|
||||
|
||||
bool VipsBackend::isAvailable() {
|
||||
return gReady;
|
||||
}
|
||||
|
||||
bool VipsBackend::probeSize(const QString& path, QSize* out) {
|
||||
if (!gReady || !out) {
|
||||
return false;
|
||||
}
|
||||
const QByteArray enc = encodedPath(path);
|
||||
if (enc.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
VipsImage* in = vips_image_new_from_file(enc.constData(), "access", VIPS_ACCESS_SEQUENTIAL, nullptr);
|
||||
if (!in) {
|
||||
return false;
|
||||
}
|
||||
const int w = vips_image_get_width(in);
|
||||
const int h = vips_image_get_height(in);
|
||||
g_object_unref(in);
|
||||
if (w <= 0 || h <= 0) {
|
||||
return false;
|
||||
}
|
||||
*out = QSize(w, h);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VipsBackend::loadThumbnailQImage(const QString& path, qint64 maxPixels, QImage* out) {
|
||||
if (!gReady || !out || maxPixels < 1024) {
|
||||
return false;
|
||||
}
|
||||
const QByteArray enc = encodedPath(path);
|
||||
if (enc.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QSize sz;
|
||||
if (!probeSize(path, &sz)) {
|
||||
return false;
|
||||
}
|
||||
const qint64 pixels = qint64(sz.width()) * qint64(sz.height());
|
||||
if (pixels <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
VipsImage* thumb = nullptr;
|
||||
if (pixels <= maxPixels) {
|
||||
thumb = vips_image_new_from_file(enc.constData(), "access", VIPS_ACCESS_SEQUENTIAL, nullptr);
|
||||
if (!thumb) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const int targetW =
|
||||
std::max(1, int(std::floor(std::sqrt(double(maxPixels) * double(sz.width()) / double(sz.height())))));
|
||||
if (vips_thumbnail(enc.constData(), &thumb, targetW, nullptr) != 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void* buf = nullptr;
|
||||
size_t len = 0;
|
||||
if (vips_pngsave_buffer(thumb, &buf, &len, "compression", 3, nullptr) != 0) {
|
||||
g_object_unref(thumb);
|
||||
return false;
|
||||
}
|
||||
g_object_unref(thumb);
|
||||
|
||||
const bool ok = out->loadFromData(static_cast<const uchar*>(buf), static_cast<int>(len), "PNG");
|
||||
g_free(buf);
|
||||
return ok && !out->isNull();
|
||||
}
|
||||
|
||||
bool VipsBackend::loadRegionToQImage(const QString& sourcePath, const QRect& rectInSource, int maxOutputWidth,
|
||||
int maxOutputHeight, QImage* out) {
|
||||
if (!gReady || !out || maxOutputWidth < 32 || maxOutputHeight < 32) {
|
||||
return false;
|
||||
}
|
||||
out->detach();
|
||||
*out = QImage();
|
||||
|
||||
const QByteArray src = encodedPath(sourcePath);
|
||||
if (src.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
VipsImage* in = vips_image_new_from_file(src.constData(), "access", VIPS_ACCESS_RANDOM, nullptr);
|
||||
if (!in) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const int iw = vips_image_get_width(in);
|
||||
const int ih = vips_image_get_height(in);
|
||||
const QRect bounds(0, 0, iw, ih);
|
||||
QRect r = rectInSource.normalized().intersected(bounds);
|
||||
if (r.width() <= 0 || r.height() <= 0) {
|
||||
g_object_unref(in);
|
||||
return false;
|
||||
}
|
||||
|
||||
VipsImage* cropped = nullptr;
|
||||
if (vips_crop(in, &cropped, r.left(), r.top(), r.width(), r.height(), nullptr) != 0) {
|
||||
g_object_unref(in);
|
||||
return false;
|
||||
}
|
||||
g_object_unref(in);
|
||||
|
||||
const int cw = r.width();
|
||||
const int ch = r.height();
|
||||
VipsImage* toEncode = cropped;
|
||||
|
||||
// 视口解码:输出尺寸不应超过 maxOutput*,否则后续绘制阶段再缩小会引入明显的线性滤波“发糊”。
|
||||
// 这里始终把区域降采样到不超过 maxOutput*(保持宽高比),让 vips 在解码阶段完成高质量缩放。
|
||||
double scale = 1.0;
|
||||
if (cw > maxOutputWidth || ch > maxOutputHeight) {
|
||||
const double sx = static_cast<double>(maxOutputWidth) / static_cast<double>(cw);
|
||||
const double sy = static_cast<double>(maxOutputHeight) / static_cast<double>(ch);
|
||||
scale = std::min({sx, sy, 1.0});
|
||||
}
|
||||
if (scale < 0.9999) {
|
||||
VipsImage* resized = nullptr;
|
||||
if (vips_resize(cropped, &resized, scale, "kernel", VIPS_KERNEL_LANCZOS3, nullptr) != 0) {
|
||||
g_object_unref(cropped);
|
||||
return false;
|
||||
}
|
||||
g_object_unref(cropped);
|
||||
toEncode = resized;
|
||||
}
|
||||
|
||||
void* buf = nullptr;
|
||||
size_t len = 0;
|
||||
if (vips_pngsave_buffer(toEncode, &buf, &len, "compression", 3, nullptr) != 0) {
|
||||
g_object_unref(toEncode);
|
||||
return false;
|
||||
}
|
||||
g_object_unref(toEncode);
|
||||
|
||||
const bool ok = out->loadFromData(static_cast<const uchar*>(buf), static_cast<int>(len), "PNG");
|
||||
g_free(buf);
|
||||
return ok && !out->isNull();
|
||||
}
|
||||
|
||||
bool VipsBackend::saveRectToPng(const QString& sourcePath, const QRect& rectInSource, const QString& destPngPath) {
|
||||
if (!gReady) {
|
||||
return false;
|
||||
}
|
||||
const QByteArray src = encodedPath(sourcePath);
|
||||
if (src.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
VipsImage* in = vips_image_new_from_file(src.constData(), "access", VIPS_ACCESS_RANDOM, nullptr);
|
||||
if (!in) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const int iw = vips_image_get_width(in);
|
||||
const int ih = vips_image_get_height(in);
|
||||
const QRect bounds(0, 0, iw, ih);
|
||||
QRect r = rectInSource.normalized().intersected(bounds);
|
||||
if (r.width() <= 0 || r.height() <= 0) {
|
||||
g_object_unref(in);
|
||||
return false;
|
||||
}
|
||||
|
||||
VipsImage* toSave = in;
|
||||
VipsImage* cropped = nullptr;
|
||||
if (r != bounds) {
|
||||
if (vips_crop(in, &cropped, r.left(), r.top(), r.width(), r.height(), nullptr) != 0) {
|
||||
g_object_unref(in);
|
||||
return false;
|
||||
}
|
||||
toSave = cropped;
|
||||
}
|
||||
|
||||
const bool ok = saveVipsImageToPngFile(toSave, destPngPath);
|
||||
if (cropped) {
|
||||
g_object_unref(cropped);
|
||||
}
|
||||
g_object_unref(in);
|
||||
return ok;
|
||||
}
|
||||
|
||||
#else // !LT_HAVE_VIPS
|
||||
|
||||
bool VipsBackend::init(const char*) {
|
||||
return false;
|
||||
}
|
||||
|
||||
void VipsBackend::shutdown() {}
|
||||
|
||||
bool VipsBackend::isAvailable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool VipsBackend::probeSize(const QString&, QSize*) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool VipsBackend::loadThumbnailQImage(const QString&, qint64, QImage*) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool VipsBackend::saveRectToPng(const QString&, const QRect&, const QString&) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool VipsBackend::loadRegionToQImage(const QString&, const QRect&, int, int, QImage*) {
|
||||
return false;
|
||||
}
|
||||
|
||||
#endif // LT_HAVE_VIPS
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
#include <QtGlobal>
|
||||
|
||||
#include <QImage>
|
||||
#include <QRect>
|
||||
#include <QString>
|
||||
|
||||
namespace core {
|
||||
|
||||
/// 基于 libvips 的大图 I/O:随机访问裁剪、流式写 PNG、缩略预览,避免整图进内存。
|
||||
/// 未编译 libvips 时 API 返回 false,由调用方回退到 Qt。
|
||||
class VipsBackend {
|
||||
public:
|
||||
/// 须在 QApplication 创建后调用一次,argv0 建议为 argv[0]。
|
||||
static bool init(const char* argv0);
|
||||
static void shutdown();
|
||||
|
||||
static bool isAvailable();
|
||||
|
||||
/// 打开文件读取宽高(轻量,不解码全图)
|
||||
static bool probeSize(const QString& path, QSize* out);
|
||||
|
||||
/// 生成预览图(总像素约不超过 maxPixels),用于裁剪对话框 / 画布
|
||||
static bool loadThumbnailQImage(const QString& path, qint64 maxPixels, QImage* out);
|
||||
|
||||
/// 将原图中矩形区域(像素坐标,含边界)无损写入 PNG;区域为整图时直接流式转存。
|
||||
/// 适合 GB 级 PNG,不构建整幅 QImage。
|
||||
static bool saveRectToPng(const QString& sourcePath, const QRect& rectInSource, const QString& destPngPath);
|
||||
|
||||
/// 裁剪原图矩形区域;若超过 maxOutput* 则按比例缩小(不降采样整图,只处理该区域)。
|
||||
static bool loadRegionToQImage(const QString& sourcePath, const QRect& rectInSource, int maxOutputWidth,
|
||||
int maxOutputHeight, QImage* out);
|
||||
|
||||
private:
|
||||
VipsBackend() = default;
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
#include <functional>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonValue>
|
||||
#include <QRect>
|
||||
|
||||
namespace core::library {
|
||||
|
||||
@@ -70,6 +72,15 @@ QJsonObject entityToJson(const core::Project::Entity& e) {
|
||||
o.insert(QStringLiteral("blackholeId"), e.blackholeId);
|
||||
o.insert(QStringLiteral("blackholeVisible"), e.blackholeVisible);
|
||||
o.insert(QStringLiteral("blackholeResolvedBy"), e.blackholeResolvedBy);
|
||||
o.insert(QStringLiteral("blackholeOverlayPath"), e.blackholeOverlayPath);
|
||||
{
|
||||
QJsonObject r;
|
||||
r.insert(QStringLiteral("x"), e.blackholeOverlayRect.x());
|
||||
r.insert(QStringLiteral("y"), e.blackholeOverlayRect.y());
|
||||
r.insert(QStringLiteral("w"), e.blackholeOverlayRect.width());
|
||||
r.insert(QStringLiteral("h"), e.blackholeOverlayRect.height());
|
||||
o.insert(QStringLiteral("blackholeOverlayRect"), r);
|
||||
}
|
||||
o.insert(QStringLiteral("originWorld"), pointToJson(e.originWorld));
|
||||
o.insert(QStringLiteral("depth"), e.depth);
|
||||
o.insert(QStringLiteral("imagePath"), e.imagePath);
|
||||
@@ -81,7 +92,6 @@ QJsonObject entityToJson(const core::Project::Entity& e) {
|
||||
o.insert(QStringLiteral("parentOffsetWorld"), pointToJson(e.parentOffsetWorld));
|
||||
|
||||
o.insert(QStringLiteral("entityPayloadPath"), e.entityPayloadPath);
|
||||
o.insert(QStringLiteral("legacyAnimSidecarPath"), e.legacyAnimSidecarPath);
|
||||
|
||||
o.insert(QStringLiteral("locationKeys"),
|
||||
vecToJson<core::Project::Entity::KeyframeVec2>(
|
||||
@@ -161,6 +171,13 @@ bool entityFromJson(const QJsonObject& o, core::Project::Entity& out) {
|
||||
}
|
||||
e.blackholeVisible = o.value(QStringLiteral("blackholeVisible")).toBool(true);
|
||||
e.blackholeResolvedBy = o.value(QStringLiteral("blackholeResolvedBy")).toString();
|
||||
e.blackholeOverlayPath = o.value(QStringLiteral("blackholeOverlayPath")).toString();
|
||||
{
|
||||
const QJsonObject r = o.value(QStringLiteral("blackholeOverlayRect")).toObject();
|
||||
e.blackholeOverlayRect =
|
||||
QRect(r.value(QStringLiteral("x")).toInt(0), r.value(QStringLiteral("y")).toInt(0),
|
||||
r.value(QStringLiteral("w")).toInt(0), r.value(QStringLiteral("h")).toInt(0));
|
||||
}
|
||||
{
|
||||
QPointF p;
|
||||
if (!pointFromJson(o.value(QStringLiteral("originWorld")), p)) {
|
||||
@@ -189,7 +206,6 @@ bool entityFromJson(const QJsonObject& o, core::Project::Entity& out) {
|
||||
e.parentOffsetWorld = p;
|
||||
}
|
||||
e.entityPayloadPath = o.value(QStringLiteral("entityPayloadPath")).toString();
|
||||
e.legacyAnimSidecarPath = o.value(QStringLiteral("legacyAnimSidecarPath")).toString();
|
||||
|
||||
auto parseKeyframesVec2 = [&](const QString& key, QVector<core::Project::Entity::KeyframeVec2>& dst) -> bool {
|
||||
dst.clear();
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
#include <QTimer>
|
||||
#include <QUrl>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace core {
|
||||
|
||||
ModelServerClient::ModelServerClient(QObject* parent)
|
||||
@@ -90,6 +92,7 @@ QNetworkReply* ModelServerClient::segmentSamPromptAsync(
|
||||
QNetworkReply* ModelServerClient::inpaintAsync(
|
||||
const QByteArray& cropRgbPngBytes,
|
||||
const QByteArray& maskPngBytes,
|
||||
const QString& modelName,
|
||||
const QString& prompt,
|
||||
const QString& negativePrompt,
|
||||
double strength,
|
||||
@@ -119,6 +122,9 @@ QNetworkReply* ModelServerClient::inpaintAsync(
|
||||
QJsonObject payload;
|
||||
payload.insert(QStringLiteral("image_b64"), QString::fromLatin1(cropRgbPngBytes.toBase64()));
|
||||
payload.insert(QStringLiteral("mask_b64"), QString::fromLatin1(maskPngBytes.toBase64()));
|
||||
if (!modelName.trimmed().isEmpty()) {
|
||||
payload.insert(QStringLiteral("model_name"), modelName.trimmed());
|
||||
}
|
||||
payload.insert(QStringLiteral("prompt"), prompt);
|
||||
payload.insert(QStringLiteral("negative_prompt"), negativePrompt);
|
||||
payload.insert(QStringLiteral("strength"), strength);
|
||||
@@ -128,6 +134,62 @@ QNetworkReply* ModelServerClient::inpaintAsync(
|
||||
return m_nam->post(req, body);
|
||||
}
|
||||
|
||||
QNetworkReply* ModelServerClient::animateCharacterSequenceAsync(
|
||||
const QByteArray& characterPngBytes,
|
||||
const QString& prompt,
|
||||
const QString& negativePrompt,
|
||||
const QString& backgroundColor,
|
||||
int backgroundTolerance,
|
||||
int frameCount,
|
||||
int numInferenceSteps,
|
||||
double guidanceScale,
|
||||
int maxSide,
|
||||
int seed,
|
||||
QString* outImmediateError
|
||||
) {
|
||||
if (outImmediateError) {
|
||||
outImmediateError->clear();
|
||||
}
|
||||
if (!m_baseUrl.isValid() || m_baseUrl.isEmpty()) {
|
||||
if (outImmediateError) *outImmediateError = QStringLiteral("后端地址无效。");
|
||||
return nullptr;
|
||||
}
|
||||
if (characterPngBytes.isEmpty()) {
|
||||
if (outImmediateError) *outImmediateError = QStringLiteral("角色 PNG 为空。");
|
||||
return nullptr;
|
||||
}
|
||||
if (prompt.trimmed().isEmpty()) {
|
||||
if (outImmediateError) *outImmediateError = QStringLiteral("动画提示词不能为空。");
|
||||
return nullptr;
|
||||
}
|
||||
if (frameCount <= 0) {
|
||||
if (outImmediateError) *outImmediateError = QStringLiteral("帧数必须大于 0。");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const QUrl url = m_baseUrl.resolved(QUrl(QStringLiteral("/animate/character_sequence")));
|
||||
QNetworkRequest req(url);
|
||||
req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
|
||||
|
||||
QJsonObject payload;
|
||||
payload.insert(QStringLiteral("image_b64"), QString::fromLatin1(characterPngBytes.toBase64()));
|
||||
payload.insert(QStringLiteral("model_name"), QStringLiteral("animatediff"));
|
||||
payload.insert(QStringLiteral("prompt"), prompt.trimmed());
|
||||
payload.insert(QStringLiteral("negative_prompt"), negativePrompt);
|
||||
payload.insert(QStringLiteral("background_color"), backgroundColor.trimmed().isEmpty()
|
||||
? QStringLiteral("#00FF00")
|
||||
: backgroundColor.trimmed());
|
||||
payload.insert(QStringLiteral("background_tolerance"), std::clamp(backgroundTolerance, 0, 255));
|
||||
payload.insert(QStringLiteral("video_length"), frameCount);
|
||||
payload.insert(QStringLiteral("num_inference_steps"), std::max(1, numInferenceSteps));
|
||||
payload.insert(QStringLiteral("guidance_scale"), guidanceScale);
|
||||
payload.insert(QStringLiteral("max_side"), std::clamp(maxSide, 128, 2048));
|
||||
payload.insert(QStringLiteral("seed"), seed);
|
||||
|
||||
const QByteArray body = QJsonDocument(payload).toJson(QJsonDocument::Compact);
|
||||
return m_nam->post(req, body);
|
||||
}
|
||||
|
||||
bool ModelServerClient::computeDepthPng8(
|
||||
const QByteArray& imageBytes,
|
||||
QByteArray& outPngBytes,
|
||||
|
||||
@@ -41,12 +41,27 @@ public:
|
||||
QNetworkReply* inpaintAsync(
|
||||
const QByteArray& cropRgbPngBytes,
|
||||
const QByteArray& maskPngBytes,
|
||||
const QString& modelName,
|
||||
const QString& prompt,
|
||||
const QString& negativePrompt,
|
||||
double strength,
|
||||
int maxSide,
|
||||
QString* outImmediateError = nullptr);
|
||||
|
||||
// POST /animate/character_sequence,JSON 响应由调用方解析(success / frames[] / fps / error)。
|
||||
QNetworkReply* animateCharacterSequenceAsync(
|
||||
const QByteArray& characterPngBytes,
|
||||
const QString& prompt,
|
||||
const QString& negativePrompt,
|
||||
const QString& backgroundColor,
|
||||
int backgroundTolerance,
|
||||
int frameCount,
|
||||
int numInferenceSteps,
|
||||
double guidanceScale,
|
||||
int maxSide,
|
||||
int seed,
|
||||
QString* outImmediateError = nullptr);
|
||||
|
||||
private:
|
||||
QNetworkAccessManager* m_nam = nullptr;
|
||||
QUrl m_baseUrl;
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
#include "persistence/AnimationBinary.h"
|
||||
|
||||
#include "persistence/PersistentBinaryObject.h"
|
||||
|
||||
#include <QDataStream>
|
||||
#include <QFile>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace core {
|
||||
namespace {
|
||||
|
||||
qint32 toInt(Project::AnimationTargetKind v) {
|
||||
return static_cast<qint32>(v);
|
||||
}
|
||||
|
||||
qint32 toInt(Project::AnimationProperty v) {
|
||||
return static_cast<qint32>(v);
|
||||
}
|
||||
|
||||
qint32 toInt(Project::AnimationValueType v) {
|
||||
return static_cast<qint32>(v);
|
||||
}
|
||||
|
||||
qint32 toInt(Project::AnimationInterpolation v) {
|
||||
return static_cast<qint32>(v);
|
||||
}
|
||||
|
||||
Project::AnimationTargetKind targetKindFromInt(qint32 v) {
|
||||
if (v == toInt(Project::AnimationTargetKind::Tool)) return Project::AnimationTargetKind::Tool;
|
||||
if (v == toInt(Project::AnimationTargetKind::Camera)) return Project::AnimationTargetKind::Camera;
|
||||
return Project::AnimationTargetKind::Entity;
|
||||
}
|
||||
|
||||
Project::AnimationProperty propertyFromInt(qint32 v) {
|
||||
switch (v) {
|
||||
case 1: return Project::AnimationProperty::UserScale;
|
||||
case 2: return Project::AnimationProperty::Sprite;
|
||||
case 3: return Project::AnimationProperty::Visibility;
|
||||
case 4: return Project::AnimationProperty::DepthScale;
|
||||
case 5: return Project::AnimationProperty::CameraScale;
|
||||
default: return Project::AnimationProperty::Position;
|
||||
}
|
||||
}
|
||||
|
||||
Project::AnimationValueType valueTypeFromInt(qint32 v) {
|
||||
switch (v) {
|
||||
case 1: return Project::AnimationValueType::Double;
|
||||
case 2: return Project::AnimationValueType::Bool;
|
||||
case 3: return Project::AnimationValueType::ImagePath;
|
||||
case 4: return Project::AnimationValueType::PngBytes;
|
||||
default: return Project::AnimationValueType::Vec2;
|
||||
}
|
||||
}
|
||||
|
||||
Project::AnimationInterpolation interpolationFromInt(qint32 v) {
|
||||
return v == 0 ? Project::AnimationInterpolation::Hold : Project::AnimationInterpolation::Linear;
|
||||
}
|
||||
|
||||
void sortAndDedupe(QVector<Project::AnimationKey>& keys) {
|
||||
std::sort(keys.begin(), keys.end(), [](const auto& a, const auto& b) {
|
||||
return a.frame < b.frame;
|
||||
});
|
||||
QVector<Project::AnimationKey> out;
|
||||
out.reserve(keys.size());
|
||||
for (const auto& k : keys) {
|
||||
if (!out.isEmpty() && out.last().frame == k.frame) {
|
||||
out.last() = k;
|
||||
} else {
|
||||
out.push_back(k);
|
||||
}
|
||||
}
|
||||
keys = std::move(out);
|
||||
}
|
||||
|
||||
class AnimationRecord final : public PersistentBinaryObject {
|
||||
public:
|
||||
explicit AnimationRecord(const Project::Animation& animation) : m_src(&animation), m_dst(nullptr) {}
|
||||
explicit AnimationRecord(Project::Animation& animation) : m_src(nullptr), m_dst(&animation) {}
|
||||
|
||||
quint32 recordMagic() const override { return AnimationBinary::kMagicAnimation; }
|
||||
quint32 recordFormatVersion() const override { return AnimationBinary::kAnimationVersion; }
|
||||
|
||||
void writeBody(QDataStream& ds) const override {
|
||||
const Project::Animation& a = *m_src;
|
||||
ds << a.id << a.name << a.payloadPath;
|
||||
ds << qint32(a.fps) << qint32(a.lengthFrames) << bool(a.loop);
|
||||
ds << qint32(a.tracks.size());
|
||||
for (const auto& t : a.tracks) {
|
||||
ds << t.id << toInt(t.targetKind) << t.targetId << toInt(t.property)
|
||||
<< toInt(t.valueType) << toInt(t.interpolation);
|
||||
ds << qint32(t.keys.size());
|
||||
for (const auto& k : t.keys) {
|
||||
ds << qint32(k.frame);
|
||||
switch (t.valueType) {
|
||||
case Project::AnimationValueType::Vec2:
|
||||
ds << double(k.vec2Value.x()) << double(k.vec2Value.y());
|
||||
break;
|
||||
case Project::AnimationValueType::Double:
|
||||
ds << double(k.numberValue);
|
||||
break;
|
||||
case Project::AnimationValueType::Bool:
|
||||
ds << bool(k.boolValue);
|
||||
break;
|
||||
case Project::AnimationValueType::ImagePath:
|
||||
ds << k.stringValue;
|
||||
break;
|
||||
case Project::AnimationValueType::PngBytes:
|
||||
ds << k.bytesValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool readBody(QDataStream& ds) override {
|
||||
Project::Animation a;
|
||||
qint32 fps = 30;
|
||||
qint32 length = Project::kClipFixedFrames;
|
||||
bool loop = true;
|
||||
ds >> a.id >> a.name >> a.payloadPath;
|
||||
ds >> fps >> length >> loop;
|
||||
if (ds.status() != QDataStream::Ok || a.id.isEmpty()) return false;
|
||||
a.fps = std::max(1, int(fps));
|
||||
a.lengthFrames = std::max(1, int(length));
|
||||
a.loop = loop;
|
||||
|
||||
qint32 nTracks = 0;
|
||||
ds >> nTracks;
|
||||
if (ds.status() != QDataStream::Ok || nTracks < 0 || nTracks > 100000) return false;
|
||||
a.tracks.reserve(nTracks);
|
||||
for (qint32 i = 0; i < nTracks; ++i) {
|
||||
Project::AnimationTrack t;
|
||||
qint32 kind = 0;
|
||||
qint32 prop = 0;
|
||||
qint32 valueType = 0;
|
||||
qint32 interpolation = 1;
|
||||
ds >> t.id >> kind >> t.targetId >> prop >> valueType >> interpolation;
|
||||
if (ds.status() != QDataStream::Ok || t.targetId.isEmpty()) return false;
|
||||
t.targetKind = targetKindFromInt(kind);
|
||||
t.property = propertyFromInt(prop);
|
||||
t.valueType = valueTypeFromInt(valueType);
|
||||
t.interpolation = interpolationFromInt(interpolation);
|
||||
|
||||
qint32 nKeys = 0;
|
||||
ds >> nKeys;
|
||||
if (ds.status() != QDataStream::Ok || nKeys < 0 || nKeys > 1000000) return false;
|
||||
t.keys.reserve(nKeys);
|
||||
for (qint32 kidx = 0; kidx < nKeys; ++kidx) {
|
||||
Project::AnimationKey k;
|
||||
qint32 frame = 0;
|
||||
ds >> frame;
|
||||
k.frame = int(frame);
|
||||
switch (t.valueType) {
|
||||
case Project::AnimationValueType::Vec2: {
|
||||
double x = 0.0;
|
||||
double y = 0.0;
|
||||
ds >> x >> y;
|
||||
k.vec2Value = QPointF(x, y);
|
||||
break;
|
||||
}
|
||||
case Project::AnimationValueType::Double:
|
||||
ds >> k.numberValue;
|
||||
break;
|
||||
case Project::AnimationValueType::Bool:
|
||||
ds >> k.boolValue;
|
||||
break;
|
||||
case Project::AnimationValueType::ImagePath:
|
||||
ds >> k.stringValue;
|
||||
break;
|
||||
case Project::AnimationValueType::PngBytes:
|
||||
ds >> k.bytesValue;
|
||||
break;
|
||||
}
|
||||
if (ds.status() != QDataStream::Ok) return false;
|
||||
t.keys.push_back(k);
|
||||
}
|
||||
sortAndDedupe(t.keys);
|
||||
a.tracks.push_back(t);
|
||||
}
|
||||
|
||||
*m_dst = std::move(a);
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
const Project::Animation* m_src;
|
||||
Project::Animation* m_dst;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
bool AnimationBinary::save(const QString& absolutePath, const Project::Animation& animation) {
|
||||
if (absolutePath.isEmpty() || animation.id.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return AnimationRecord(animation).saveToFile(absolutePath);
|
||||
}
|
||||
|
||||
bool AnimationBinary::load(const QString& absolutePath, Project::Animation& animation) {
|
||||
return AnimationRecord(animation).loadFromFile(absolutePath);
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
|
||||
#include "domain/Project.h"
|
||||
|
||||
#include <QString>
|
||||
|
||||
namespace core {
|
||||
|
||||
class AnimationBinary {
|
||||
public:
|
||||
static constexpr quint32 kMagicAnimation = 0x4846414E; // 'HFAN'
|
||||
static constexpr quint32 kAnimationVersion = 2;
|
||||
|
||||
static bool save(const QString& absolutePath, const Project::Animation& animation);
|
||||
static bool load(const QString& absolutePath, Project::Animation& animation);
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,162 @@
|
||||
#include "persistence/AnimationBundleStore.h"
|
||||
|
||||
#include "persistence/JsonFileAtomic.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
|
||||
namespace core::persistence {
|
||||
namespace {
|
||||
|
||||
QJsonObject keyToJson(const Project::AnimationKey& k, Project::AnimationValueType t) {
|
||||
QJsonObject o;
|
||||
o.insert("frame", k.frame);
|
||||
switch (t) {
|
||||
case Project::AnimationValueType::Vec2:
|
||||
o.insert("x", k.vec2Value.x());
|
||||
o.insert("y", k.vec2Value.y());
|
||||
break;
|
||||
case Project::AnimationValueType::Double:
|
||||
o.insert("value", k.numberValue);
|
||||
break;
|
||||
case Project::AnimationValueType::Bool:
|
||||
o.insert("value", k.boolValue);
|
||||
break;
|
||||
case Project::AnimationValueType::ImagePath:
|
||||
o.insert("value", k.stringValue);
|
||||
break;
|
||||
case Project::AnimationValueType::PngBytes:
|
||||
o.insert("value", QString::fromLatin1(k.bytesValue.toBase64()));
|
||||
break;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
bool keyFromJson(const QJsonObject& o, Project::AnimationValueType t, Project::AnimationKey& out) {
|
||||
out = Project::AnimationKey{};
|
||||
out.frame = o.value("frame").toInt(0);
|
||||
switch (t) {
|
||||
case Project::AnimationValueType::Vec2:
|
||||
out.vec2Value = QPointF(o.value("x").toDouble(0.0), o.value("y").toDouble(0.0));
|
||||
break;
|
||||
case Project::AnimationValueType::Double:
|
||||
out.numberValue = o.value("value").toDouble(0.0);
|
||||
break;
|
||||
case Project::AnimationValueType::Bool:
|
||||
out.boolValue = o.value("value").toBool(true);
|
||||
break;
|
||||
case Project::AnimationValueType::ImagePath:
|
||||
out.stringValue = o.value("value").toString();
|
||||
break;
|
||||
case Project::AnimationValueType::PngBytes: {
|
||||
const QByteArray b64 = o.value("value").toString().toLatin1();
|
||||
out.bytesValue = QByteArray::fromBase64(b64);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QString AnimationBundleStore::bundleRelativeDir(const QString& animationId) {
|
||||
if (animationId.isEmpty()) return {};
|
||||
return QStringLiteral("assets/animations/%1").arg(animationId);
|
||||
}
|
||||
|
||||
bool AnimationBundleStore::save(const QString& projectDirAbs, const Project::Animation& animation) {
|
||||
if (projectDirAbs.isEmpty() || animation.id.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
const QString relDir = bundleRelativeDir(animation.id);
|
||||
if (relDir.isEmpty()) return false;
|
||||
const QString absDir = QDir(projectDirAbs).filePath(relDir);
|
||||
QDir().mkpath(absDir);
|
||||
|
||||
QJsonObject root;
|
||||
root.insert("id", animation.id);
|
||||
root.insert("name", animation.name);
|
||||
root.insert("fps", animation.fps);
|
||||
root.insert("lengthFrames", animation.lengthFrames);
|
||||
root.insert("loop", animation.loop);
|
||||
|
||||
QJsonArray tracks;
|
||||
for (const auto& t : animation.tracks) {
|
||||
QJsonObject to;
|
||||
to.insert("id", t.id);
|
||||
to.insert("targetKind", int(t.targetKind));
|
||||
to.insert("targetId", t.targetId);
|
||||
to.insert("property", int(t.property));
|
||||
to.insert("valueType", int(t.valueType));
|
||||
to.insert("interpolation", int(t.interpolation));
|
||||
QJsonArray keys;
|
||||
for (const auto& k : t.keys) {
|
||||
keys.append(keyToJson(k, t.valueType));
|
||||
}
|
||||
to.insert("keys", keys);
|
||||
tracks.append(to);
|
||||
}
|
||||
root.insert("tracks", tracks);
|
||||
|
||||
return writeJsonAtomic(QDir(absDir).filePath(QStringLiteral("anim.json")), root, true);
|
||||
}
|
||||
|
||||
bool AnimationBundleStore::load(const QString& projectDirAbs, Project::Animation& inOutAnimation) {
|
||||
if (projectDirAbs.isEmpty() || inOutAnimation.id.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
const QString relDir = inOutAnimation.payloadPath;
|
||||
if (relDir.isEmpty()) return false;
|
||||
const QString absDir = QDir(projectDirAbs).filePath(relDir);
|
||||
if (!QFileInfo(absDir).exists()) return false;
|
||||
|
||||
QJsonObject root;
|
||||
if (!readJsonObject(QDir(absDir).filePath(QStringLiteral("anim.json")), root)) {
|
||||
return false;
|
||||
}
|
||||
const QString id = root.value("id").toString();
|
||||
if (id.isEmpty() || id != inOutAnimation.id) {
|
||||
return false;
|
||||
}
|
||||
Project::Animation a = inOutAnimation;
|
||||
a.name = root.value("name").toString();
|
||||
a.fps = std::max(1, root.value("fps").toInt(30));
|
||||
a.lengthFrames = std::max(1, root.value("lengthFrames").toInt(Project::kClipFixedFrames));
|
||||
a.loop = root.value("loop").toBool(true);
|
||||
|
||||
a.tracks.clear();
|
||||
const QJsonValue tracksVal = root.value("tracks");
|
||||
if (tracksVal.isArray()) {
|
||||
for (const auto& it : tracksVal.toArray()) {
|
||||
if (!it.isObject()) continue;
|
||||
const QJsonObject to = it.toObject();
|
||||
Project::AnimationTrack t;
|
||||
t.id = to.value("id").toString();
|
||||
t.targetKind = Project::AnimationTargetKind(to.value("targetKind").toInt(0));
|
||||
t.targetId = to.value("targetId").toString();
|
||||
t.property = Project::AnimationProperty(to.value("property").toInt(0));
|
||||
t.valueType = Project::AnimationValueType(to.value("valueType").toInt(0));
|
||||
t.interpolation = Project::AnimationInterpolation(to.value("interpolation").toInt(1));
|
||||
const QJsonValue keysVal = to.value("keys");
|
||||
if (keysVal.isArray()) {
|
||||
for (const auto& kv : keysVal.toArray()) {
|
||||
if (!kv.isObject()) continue;
|
||||
Project::AnimationKey k;
|
||||
keyFromJson(kv.toObject(), t.valueType, k);
|
||||
t.keys.push_back(k);
|
||||
}
|
||||
}
|
||||
if (!t.targetId.isEmpty()) {
|
||||
a.tracks.push_back(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inOutAnimation = std::move(a);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace core::persistence
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include "domain/Project.h"
|
||||
|
||||
#include <QString>
|
||||
|
||||
namespace core::persistence {
|
||||
|
||||
/// 动画 payload 的目录 bundle(v7 起):
|
||||
/// assets/animations/<id>/
|
||||
/// anim.json // 元数据 + 所有 track(后续可拆成按 track 分块)
|
||||
struct AnimationBundleStore {
|
||||
static QString bundleRelativeDir(const QString& animationId);
|
||||
|
||||
static bool save(const QString& projectDirAbs, const Project::Animation& animation);
|
||||
static bool load(const QString& projectDirAbs, Project::Animation& inOutAnimation);
|
||||
};
|
||||
|
||||
} // namespace core::persistence
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
#include "persistence/AsyncProjectWriter.h"
|
||||
|
||||
#include "persistence/AnimationBundleStore.h"
|
||||
#include "persistence/EntityBundleStore.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QEventLoop>
|
||||
#include <QTimer>
|
||||
#include <QtConcurrent/QtConcurrent>
|
||||
|
||||
namespace core::persistence {
|
||||
|
||||
AsyncProjectWriter::AsyncProjectWriter(QObject* parent)
|
||||
: QObject(parent) {
|
||||
m_debounce = new QTimer(this);
|
||||
m_debounce->setSingleShot(true);
|
||||
m_debounce->setInterval(80);
|
||||
connect(m_debounce, &QTimer::timeout, this, [this]() { scheduleDrain(); });
|
||||
}
|
||||
|
||||
void AsyncProjectWriter::setProjectDir(const QString& projectDirAbs) {
|
||||
m_projectDir = projectDirAbs;
|
||||
}
|
||||
|
||||
void AsyncProjectWriter::requestWriteEntity(const Project::Entity& entity) {
|
||||
if (m_projectDir.isEmpty() || entity.id.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
{
|
||||
std::lock_guard lock(m_mutex);
|
||||
m_pendingEntities.insert(entity.id, entity);
|
||||
}
|
||||
if (m_debounce) {
|
||||
m_debounce->start();
|
||||
}
|
||||
}
|
||||
|
||||
void AsyncProjectWriter::requestWriteAnimation(const Project::Animation& animation) {
|
||||
if (m_projectDir.isEmpty() || animation.id.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
{
|
||||
std::lock_guard lock(m_mutex);
|
||||
m_pendingAnimations.insert(animation.id, animation);
|
||||
}
|
||||
if (m_debounce) {
|
||||
m_debounce->start();
|
||||
}
|
||||
}
|
||||
|
||||
void AsyncProjectWriter::scheduleDrain() {
|
||||
const QString dir = m_projectDir;
|
||||
if (dir.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QHash<QString, Project::Entity> ents;
|
||||
QHash<QString, Project::Animation> anims;
|
||||
{
|
||||
std::lock_guard lock(m_mutex);
|
||||
ents = std::move(m_pendingEntities);
|
||||
anims = std::move(m_pendingAnimations);
|
||||
m_pendingEntities.clear();
|
||||
m_pendingAnimations.clear();
|
||||
}
|
||||
if (ents.isEmpty() && anims.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_outstandingDrains.fetch_add(1, std::memory_order_acq_rel);
|
||||
|
||||
(void)QtConcurrent::run([this, dir, ents = std::move(ents), anims = std::move(anims)]() mutable {
|
||||
bool anyFail = false;
|
||||
for (auto it = ents.begin(); it != ents.end(); ++it) {
|
||||
const Project::Entity& e = it.value();
|
||||
if (!EntityBundleStore::save(dir, e)) {
|
||||
anyFail = true;
|
||||
QMetaObject::invokeMethod(this, [this, id = e.id]() { emit entityWriteFailed(id); },
|
||||
Qt::QueuedConnection);
|
||||
}
|
||||
}
|
||||
for (auto it = anims.begin(); it != anims.end(); ++it) {
|
||||
const Project::Animation& a = it.value();
|
||||
if (!AnimationBundleStore::save(dir, a)) {
|
||||
anyFail = true;
|
||||
QMetaObject::invokeMethod(this, [this, id = a.id]() { emit animationWriteFailed(id); },
|
||||
Qt::QueuedConnection);
|
||||
}
|
||||
}
|
||||
Q_UNUSED(anyFail);
|
||||
m_outstandingDrains.fetch_sub(1, std::memory_order_acq_rel);
|
||||
});
|
||||
|
||||
// drain 期间若又有新请求,再开一轮
|
||||
{
|
||||
std::lock_guard lock(m_mutex);
|
||||
if (!m_pendingEntities.isEmpty() || !m_pendingAnimations.isEmpty()) {
|
||||
if (m_debounce) {
|
||||
m_debounce->start();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void AsyncProjectWriter::flushBlocking(int timeoutMs) {
|
||||
if (m_projectDir.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (m_debounce) {
|
||||
m_debounce->stop();
|
||||
}
|
||||
|
||||
QMetaObject::invokeMethod(
|
||||
this,
|
||||
[this]() {
|
||||
scheduleDrain();
|
||||
},
|
||||
Qt::QueuedConnection);
|
||||
|
||||
QTimer killer;
|
||||
killer.setSingleShot(true);
|
||||
killer.start(std::max(0, timeoutMs));
|
||||
while (killer.isActive()) {
|
||||
bool idle = false;
|
||||
{
|
||||
std::lock_guard lock(m_mutex);
|
||||
idle =
|
||||
m_pendingEntities.isEmpty() && m_pendingAnimations.isEmpty() &&
|
||||
m_outstandingDrains.load(std::memory_order_acquire) == 0;
|
||||
}
|
||||
if (idle) {
|
||||
break;
|
||||
}
|
||||
QCoreApplication::processEvents(QEventLoop::AllEvents, 20);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace core::persistence
|
||||
@@ -0,0 +1,51 @@
|
||||
#pragma once
|
||||
|
||||
#include "domain/Project.h"
|
||||
|
||||
#include <QHash>
|
||||
#include <QObject>
|
||||
#include <QSet>
|
||||
#include <QString>
|
||||
#include <QTimer>
|
||||
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
|
||||
namespace core::persistence {
|
||||
|
||||
/// 后台写盘队列:合并/防抖,避免 UI 线程同步写文件。
|
||||
/// 目前以“实体/动画为单位”合并;后续会细化到块级 dirty bits(meta/anim/intro/png...)。
|
||||
class AsyncProjectWriter final : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit AsyncProjectWriter(QObject* parent = nullptr);
|
||||
|
||||
void setProjectDir(const QString& projectDirAbs);
|
||||
|
||||
/// enqueue:线程安全由调用方保证在 UI 线程调用(本类内部用 singleShot 防抖到 writer 线程)。
|
||||
void requestWriteEntity(const Project::Entity& entity);
|
||||
void requestWriteAnimation(const Project::Animation& animation);
|
||||
|
||||
/// 阻塞等待:用于 close/退出时确保落盘完成。
|
||||
void flushBlocking(int timeoutMs = 30000);
|
||||
|
||||
signals:
|
||||
void entityWriteFailed(const QString& entityId);
|
||||
void animationWriteFailed(const QString& animationId);
|
||||
|
||||
private:
|
||||
void scheduleDrain();
|
||||
|
||||
QString m_projectDir;
|
||||
QTimer* m_debounce = nullptr;
|
||||
|
||||
std::mutex m_mutex;
|
||||
// 最新快照:同 id 多次更新只写最后一次
|
||||
QHash<QString, Project::Entity> m_pendingEntities;
|
||||
QHash<QString, Project::Animation> m_pendingAnimations;
|
||||
|
||||
std::atomic<int> m_outstandingDrains{0};
|
||||
};
|
||||
|
||||
} // namespace core::persistence
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
#include "persistence/EntityBundleStore.h"
|
||||
|
||||
#include "persistence/JsonFileAtomic.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
|
||||
namespace core::persistence {
|
||||
namespace {
|
||||
|
||||
QJsonArray pointsToJson(const QVector<QPointF>& pts) {
|
||||
QJsonArray arr;
|
||||
for (const auto& p : pts) {
|
||||
QJsonObject o;
|
||||
o.insert("x", p.x());
|
||||
o.insert("y", p.y());
|
||||
arr.append(o);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
bool pointsFromJson(const QJsonValue& v, QVector<QPointF>& out) {
|
||||
out.clear();
|
||||
if (!v.isArray()) return true;
|
||||
const QJsonArray arr = v.toArray();
|
||||
out.reserve(arr.size());
|
||||
for (const auto& it : arr) {
|
||||
if (!it.isObject()) continue;
|
||||
const QJsonObject o = it.toObject();
|
||||
out.push_back(QPointF(o.value("x").toDouble(0.0), o.value("y").toDouble(0.0)));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
template <typename KeyT>
|
||||
QJsonArray boolKeysToJson(const QVector<KeyT>& keys) {
|
||||
QJsonArray arr;
|
||||
for (const auto& k : keys) {
|
||||
QJsonObject o;
|
||||
o.insert("frame", k.frame);
|
||||
o.insert("value", k.value);
|
||||
arr.append(o);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
QJsonArray vec2KeysToJson(const QVector<Project::Entity::KeyframeVec2>& keys) {
|
||||
QJsonArray arr;
|
||||
for (const auto& k : keys) {
|
||||
QJsonObject o;
|
||||
o.insert("frame", k.frame);
|
||||
o.insert("x", k.value.x());
|
||||
o.insert("y", k.value.y());
|
||||
arr.append(o);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
QJsonArray floatKeysToJson(const QVector<Project::Entity::KeyframeFloat01>& keys) {
|
||||
QJsonArray arr;
|
||||
for (const auto& k : keys) {
|
||||
QJsonObject o;
|
||||
o.insert("frame", k.frame);
|
||||
o.insert("value", k.value);
|
||||
arr.append(o);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
QJsonArray doubleKeysToJson(const QVector<Project::Entity::KeyframeDouble>& keys) {
|
||||
QJsonArray arr;
|
||||
for (const auto& k : keys) {
|
||||
QJsonObject o;
|
||||
o.insert("frame", k.frame);
|
||||
o.insert("value", k.value);
|
||||
arr.append(o);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
QJsonArray imageFramesToJson(const QVector<Project::Entity::ImageFrame>& keys) {
|
||||
QJsonArray arr;
|
||||
for (const auto& k : keys) {
|
||||
QJsonObject o;
|
||||
o.insert("frame", k.frame);
|
||||
o.insert("path", k.imagePath);
|
||||
arr.append(o);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
template <typename KeyT>
|
||||
bool boolKeysFromJson(const QJsonValue& v, QVector<KeyT>& out) {
|
||||
out.clear();
|
||||
if (!v.isArray()) return true;
|
||||
const QJsonArray arr = v.toArray();
|
||||
out.reserve(arr.size());
|
||||
for (const auto& it : arr) {
|
||||
if (!it.isObject()) continue;
|
||||
const QJsonObject o = it.toObject();
|
||||
KeyT k;
|
||||
k.frame = o.value("frame").toInt(0);
|
||||
k.value = o.value("value").toBool(true);
|
||||
out.push_back(k);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool vec2KeysFromJson(const QJsonValue& v, QVector<Project::Entity::KeyframeVec2>& out) {
|
||||
out.clear();
|
||||
if (!v.isArray()) return true;
|
||||
const QJsonArray arr = v.toArray();
|
||||
out.reserve(arr.size());
|
||||
for (const auto& it : arr) {
|
||||
if (!it.isObject()) continue;
|
||||
const QJsonObject o = it.toObject();
|
||||
Project::Entity::KeyframeVec2 k;
|
||||
k.frame = o.value("frame").toInt(0);
|
||||
k.value = QPointF(o.value("x").toDouble(0.0), o.value("y").toDouble(0.0));
|
||||
out.push_back(k);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool floatKeysFromJson(const QJsonValue& v, QVector<Project::Entity::KeyframeFloat01>& out) {
|
||||
out.clear();
|
||||
if (!v.isArray()) return true;
|
||||
const QJsonArray arr = v.toArray();
|
||||
out.reserve(arr.size());
|
||||
for (const auto& it : arr) {
|
||||
if (!it.isObject()) continue;
|
||||
const QJsonObject o = it.toObject();
|
||||
Project::Entity::KeyframeFloat01 k;
|
||||
k.frame = o.value("frame").toInt(0);
|
||||
k.value = o.value("value").toDouble(0.5);
|
||||
out.push_back(k);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool doubleKeysFromJson(const QJsonValue& v, QVector<Project::Entity::KeyframeDouble>& out) {
|
||||
out.clear();
|
||||
if (!v.isArray()) return true;
|
||||
const QJsonArray arr = v.toArray();
|
||||
out.reserve(arr.size());
|
||||
for (const auto& it : arr) {
|
||||
if (!it.isObject()) continue;
|
||||
const QJsonObject o = it.toObject();
|
||||
Project::Entity::KeyframeDouble k;
|
||||
k.frame = o.value("frame").toInt(0);
|
||||
k.value = o.value("value").toDouble(1.0);
|
||||
out.push_back(k);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool imageFramesFromJson(const QJsonValue& v, QVector<Project::Entity::ImageFrame>& out) {
|
||||
out.clear();
|
||||
if (!v.isArray()) return true;
|
||||
const QJsonArray arr = v.toArray();
|
||||
out.reserve(arr.size());
|
||||
for (const auto& it : arr) {
|
||||
if (!it.isObject()) continue;
|
||||
const QJsonObject o = it.toObject();
|
||||
Project::Entity::ImageFrame k;
|
||||
k.frame = o.value("frame").toInt(0);
|
||||
k.imagePath = o.value("path").toString();
|
||||
out.push_back(k);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
QJsonObject introToJson(const core::EntityIntroContent& intro) {
|
||||
QJsonObject o;
|
||||
o.insert("title", intro.title);
|
||||
o.insert("bodyText", intro.bodyText);
|
||||
o.insert("videoPathRelative", intro.videoPathRelative);
|
||||
QJsonArray imgs;
|
||||
for (const auto& p : intro.imagePathsRelative) imgs.append(p);
|
||||
o.insert("imagePathsRelative", imgs);
|
||||
return o;
|
||||
}
|
||||
|
||||
bool introFromJson(const QJsonObject& o, core::EntityIntroContent& out) {
|
||||
out = core::EntityIntroContent{};
|
||||
out.title = o.value("title").toString();
|
||||
out.bodyText = o.value("bodyText").toString();
|
||||
out.videoPathRelative = o.value("videoPathRelative").toString();
|
||||
const QJsonValue imgsVal = o.value("imagePathsRelative");
|
||||
if (imgsVal.isArray()) {
|
||||
for (const auto& it : imgsVal.toArray()) {
|
||||
const QString p = it.toString();
|
||||
if (!p.isEmpty()) out.imagePathsRelative.push_back(p);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QString EntityBundleStore::bundleRelativeDir(const QString& entityId) {
|
||||
if (entityId.isEmpty()) return {};
|
||||
return QStringLiteral("assets/entities/%1").arg(entityId);
|
||||
}
|
||||
|
||||
bool EntityBundleStore::save(const QString& projectDirAbs, const Project::Entity& entity) {
|
||||
if (projectDirAbs.isEmpty() || entity.id.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
const QString relDir = bundleRelativeDir(entity.id);
|
||||
if (relDir.isEmpty()) return false;
|
||||
const QString absDir = QDir(projectDirAbs).filePath(relDir);
|
||||
QDir().mkpath(absDir);
|
||||
|
||||
// meta.json
|
||||
QJsonObject meta;
|
||||
meta.insert("id", entity.id);
|
||||
meta.insert("displayName", entity.displayName);
|
||||
meta.insert("visible", entity.visible);
|
||||
meta.insert("originX", entity.originWorld.x());
|
||||
meta.insert("originY", entity.originWorld.y());
|
||||
meta.insert("depth", entity.depth);
|
||||
meta.insert("priority", entity.priority);
|
||||
meta.insert("imagePath", entity.imagePath);
|
||||
meta.insert("imageTopLeftX", entity.imageTopLeftWorld.x());
|
||||
meta.insert("imageTopLeftY", entity.imageTopLeftWorld.y());
|
||||
meta.insert("userScale", entity.userScale);
|
||||
meta.insert("distanceScaleCalibMult", entity.distanceScaleCalibMult);
|
||||
meta.insert("ignoreDistanceScale", entity.ignoreDistanceScale);
|
||||
meta.insert("parentId", entity.parentId);
|
||||
meta.insert("parentOffsetX", entity.parentOffsetWorld.x());
|
||||
meta.insert("parentOffsetY", entity.parentOffsetWorld.y());
|
||||
|
||||
meta.insert("polygonLocal", pointsToJson(entity.polygonLocal));
|
||||
meta.insert("cutoutPolygonWorld", pointsToJson(entity.cutoutPolygonWorld));
|
||||
|
||||
meta.insert("blackholeId", entity.blackholeId);
|
||||
meta.insert("blackholeVisible", entity.blackholeVisible);
|
||||
meta.insert("blackholeResolvedBy", entity.blackholeResolvedBy);
|
||||
meta.insert("blackholeOverlayPath", entity.blackholeOverlayPath);
|
||||
meta.insert("blackholeOverlayRectX", entity.blackholeOverlayRect.x());
|
||||
meta.insert("blackholeOverlayRectY", entity.blackholeOverlayRect.y());
|
||||
meta.insert("blackholeOverlayRectW", entity.blackholeOverlayRect.width());
|
||||
meta.insert("blackholeOverlayRectH", entity.blackholeOverlayRect.height());
|
||||
|
||||
if (!writeJsonAtomic(QDir(absDir).filePath(QStringLiteral("meta.json")), meta, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// anim.json
|
||||
QJsonObject anim;
|
||||
anim.insert("locationKeys", vec2KeysToJson(entity.locationKeys));
|
||||
anim.insert("depthScaleKeys", floatKeysToJson(entity.depthScaleKeys));
|
||||
anim.insert("userScaleKeys", doubleKeysToJson(entity.userScaleKeys));
|
||||
anim.insert("visibilityKeys", boolKeysToJson<Project::ToolKeyframeBool>(entity.visibilityKeys));
|
||||
anim.insert("imageFrames", imageFramesToJson(entity.imageFrames));
|
||||
if (!writeJsonAtomic(QDir(absDir).filePath(QStringLiteral("anim.json")), anim, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// intro.json
|
||||
if (!writeJsonAtomic(QDir(absDir).filePath(QStringLiteral("intro.json")), introToJson(entity.intro), true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// default.png (可为空)
|
||||
const QString pngAbs = QDir(absDir).filePath(QStringLiteral("default.png"));
|
||||
if (!entity.defaultImagePng.isEmpty()) {
|
||||
if (!writeBytesAtomic(pngAbs, entity.defaultImagePng)) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// 清空时删掉,避免读到旧图
|
||||
if (QFileInfo::exists(pngAbs)) {
|
||||
QFile::remove(pngAbs);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool EntityBundleStore::load(const QString& projectDirAbs, Project::Entity& inOutEntity) {
|
||||
if (projectDirAbs.isEmpty() || inOutEntity.id.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
const QString relDir = inOutEntity.entityPayloadPath;
|
||||
if (relDir.isEmpty()) return false;
|
||||
const QString absDir = QDir(projectDirAbs).filePath(relDir);
|
||||
if (!QFileInfo(absDir).exists()) return false;
|
||||
|
||||
QJsonObject meta;
|
||||
if (!readJsonObject(QDir(absDir).filePath(QStringLiteral("meta.json")), meta)) {
|
||||
return false;
|
||||
}
|
||||
const QString id = meta.value("id").toString();
|
||||
if (id.isEmpty() || id != inOutEntity.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Project::Entity e = inOutEntity; // 保留 stub:id/payloadPath/visible 等必要字段会被覆盖
|
||||
e.displayName = meta.value("displayName").toString();
|
||||
e.visible = meta.value("visible").toBool(true);
|
||||
e.originWorld = QPointF(meta.value("originX").toDouble(0.0), meta.value("originY").toDouble(0.0));
|
||||
e.depth = meta.value("depth").toInt(0);
|
||||
e.priority = meta.value("priority").toInt(1);
|
||||
e.imagePath = meta.value("imagePath").toString();
|
||||
e.imageTopLeftWorld = QPointF(meta.value("imageTopLeftX").toDouble(0.0), meta.value("imageTopLeftY").toDouble(0.0));
|
||||
e.userScale = meta.value("userScale").toDouble(1.0);
|
||||
e.distanceScaleCalibMult = meta.value("distanceScaleCalibMult").toDouble(0.0);
|
||||
e.ignoreDistanceScale = meta.value("ignoreDistanceScale").toBool(false);
|
||||
e.parentId = meta.value("parentId").toString();
|
||||
e.parentOffsetWorld = QPointF(meta.value("parentOffsetX").toDouble(0.0), meta.value("parentOffsetY").toDouble(0.0));
|
||||
|
||||
pointsFromJson(meta.value("polygonLocal"), e.polygonLocal);
|
||||
pointsFromJson(meta.value("cutoutPolygonWorld"), e.cutoutPolygonWorld);
|
||||
|
||||
e.blackholeId = meta.value("blackholeId").toString();
|
||||
e.blackholeVisible = meta.value("blackholeVisible").toBool(true);
|
||||
e.blackholeResolvedBy = meta.value("blackholeResolvedBy").toString();
|
||||
e.blackholeOverlayPath = meta.value("blackholeOverlayPath").toString();
|
||||
e.blackholeOverlayRect = QRect(meta.value("blackholeOverlayRectX").toInt(0),
|
||||
meta.value("blackholeOverlayRectY").toInt(0),
|
||||
meta.value("blackholeOverlayRectW").toInt(0),
|
||||
meta.value("blackholeOverlayRectH").toInt(0));
|
||||
|
||||
QJsonObject anim;
|
||||
if (!readJsonObject(QDir(absDir).filePath(QStringLiteral("anim.json")), anim)) {
|
||||
return false;
|
||||
}
|
||||
vec2KeysFromJson(anim.value("locationKeys"), e.locationKeys);
|
||||
floatKeysFromJson(anim.value("depthScaleKeys"), e.depthScaleKeys);
|
||||
doubleKeysFromJson(anim.value("userScaleKeys"), e.userScaleKeys);
|
||||
boolKeysFromJson<Project::ToolKeyframeBool>(anim.value("visibilityKeys"), e.visibilityKeys);
|
||||
imageFramesFromJson(anim.value("imageFrames"), e.imageFrames);
|
||||
|
||||
QJsonObject intro;
|
||||
if (!readJsonObject(QDir(absDir).filePath(QStringLiteral("intro.json")), intro)) {
|
||||
return false;
|
||||
}
|
||||
introFromJson(intro, e.intro);
|
||||
|
||||
// default.png
|
||||
const QString pngAbs = QDir(absDir).filePath(QStringLiteral("default.png"));
|
||||
if (QFileInfo::exists(pngAbs)) {
|
||||
QFile f(pngAbs);
|
||||
if (f.open(QIODevice::ReadOnly)) {
|
||||
e.defaultImagePng = f.readAll();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
e.defaultImagePng.clear();
|
||||
}
|
||||
|
||||
inOutEntity = std::move(e);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace core::persistence
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include "domain/Project.h"
|
||||
|
||||
#include <QString>
|
||||
|
||||
namespace core::persistence {
|
||||
|
||||
/// 实体 payload 的目录 bundle 结构(v7 起):
|
||||
/// assets/entities/<id>/
|
||||
/// meta.json // 静态字段(几何/层级/黑洞等)
|
||||
/// anim.json // 关键帧轨道(location/depthScale/userScale/visibility/priority/sprite frames)
|
||||
/// intro.json // intro
|
||||
/// default.png // 默认贴图(可为空)
|
||||
struct EntityBundleStore {
|
||||
static QString bundleRelativeDir(const QString& entityId);
|
||||
|
||||
static bool save(const QString& projectDirAbs, const Project::Entity& entity);
|
||||
static bool load(const QString& projectDirAbs, Project::Entity& inOutEntity);
|
||||
};
|
||||
|
||||
} // namespace core::persistence
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
#include <QDataStream>
|
||||
#include <QFile>
|
||||
#include <QRect>
|
||||
#include <QtGlobal>
|
||||
|
||||
#include <algorithm>
|
||||
@@ -235,7 +236,9 @@ public:
|
||||
const Project::Entity& entity = *m_src;
|
||||
ds << entity.id;
|
||||
ds << qint32(entity.depth);
|
||||
ds << qint32(entity.priority);
|
||||
ds << entity.imagePath;
|
||||
ds << entity.defaultImagePng;
|
||||
ds << double(entity.originWorld.x()) << double(entity.originWorld.y());
|
||||
ds << double(entity.imageTopLeftWorld.x()) << double(entity.imageTopLeftWorld.y());
|
||||
|
||||
@@ -249,7 +252,6 @@ public:
|
||||
ds << double(pt.x()) << double(pt.y());
|
||||
}
|
||||
|
||||
writeAnimationBlock(ds, entity, true);
|
||||
ds << entity.displayName << double(entity.userScale) << double(entity.distanceScaleCalibMult);
|
||||
ds << bool(entity.ignoreDistanceScale);
|
||||
ds << entity.parentId;
|
||||
@@ -267,14 +269,65 @@ public:
|
||||
: entity.blackholeId;
|
||||
ds << holeId;
|
||||
ds << entity.blackholeResolvedBy;
|
||||
ds << entity.blackholeOverlayPath;
|
||||
ds << qint32(entity.blackholeOverlayRect.x()) << qint32(entity.blackholeOverlayRect.y())
|
||||
<< qint32(entity.blackholeOverlayRect.width()) << qint32(entity.blackholeOverlayRect.height());
|
||||
}
|
||||
|
||||
bool readBody(QDataStream& ds) override {
|
||||
Q_ASSERT(m_dst != nullptr);
|
||||
const quint32 fileVer = loadedRecordFormatVersion();
|
||||
Project::Entity tmp;
|
||||
if (!readEntityPayloadV1(ds, tmp, true)) {
|
||||
ds >> tmp.id;
|
||||
qint32 depth = 0;
|
||||
ds >> depth;
|
||||
tmp.depth = static_cast<int>(depth);
|
||||
qint32 pri = 1;
|
||||
if (fileVer >= 12) {
|
||||
ds >> pri;
|
||||
}
|
||||
tmp.priority = int(pri);
|
||||
ds >> tmp.imagePath;
|
||||
if (fileVer >= 13) {
|
||||
ds >> tmp.defaultImagePng;
|
||||
} else {
|
||||
tmp.defaultImagePng.clear();
|
||||
}
|
||||
double ox = 0.0;
|
||||
double oy = 0.0;
|
||||
double itlx = 0.0;
|
||||
double itly = 0.0;
|
||||
ds >> ox >> oy >> itlx >> itly;
|
||||
tmp.originWorld = QPointF(ox, oy);
|
||||
tmp.imageTopLeftWorld = QPointF(itlx, itly);
|
||||
|
||||
qint32 nLocal = 0;
|
||||
ds >> nLocal;
|
||||
if (ds.status() != QDataStream::Ok || nLocal < 0 || nLocal > 1000000) return false;
|
||||
tmp.polygonLocal.reserve(nLocal);
|
||||
for (qint32 i = 0; i < nLocal; ++i) {
|
||||
double x = 0.0;
|
||||
double y = 0.0;
|
||||
ds >> x >> y;
|
||||
if (ds.status() != QDataStream::Ok) return false;
|
||||
tmp.polygonLocal.push_back(QPointF(x, y));
|
||||
}
|
||||
|
||||
qint32 nCut = 0;
|
||||
ds >> nCut;
|
||||
if (ds.status() != QDataStream::Ok || nCut < 0 || nCut > 1000000) return false;
|
||||
tmp.cutoutPolygonWorld.reserve(nCut);
|
||||
for (qint32 i = 0; i < nCut; ++i) {
|
||||
double x = 0.0;
|
||||
double y = 0.0;
|
||||
ds >> x >> y;
|
||||
if (ds.status() != QDataStream::Ok) return false;
|
||||
tmp.cutoutPolygonWorld.push_back(QPointF(x, y));
|
||||
}
|
||||
if (tmp.id.isEmpty() || tmp.polygonLocal.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QString dn;
|
||||
double us = 1.0;
|
||||
double cal = 0.0;
|
||||
@@ -298,27 +351,23 @@ public:
|
||||
tmp.parentOffsetWorld = QPointF(pox, poy);
|
||||
|
||||
// v7:实体可见性关键帧
|
||||
tmp.visibilityKeys.clear();
|
||||
qint32 nVis = 0;
|
||||
ds >> nVis;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
if (ds.status() != QDataStream::Ok || nVis < 0 || nVis > 1000000) {
|
||||
return false;
|
||||
}
|
||||
tmp.visibilityKeys.clear();
|
||||
if (nVis > 0) {
|
||||
tmp.visibilityKeys.reserve(nVis);
|
||||
for (qint32 i = 0; i < nVis; ++i) {
|
||||
qint32 fr = 0;
|
||||
bool val = true;
|
||||
ds >> fr >> val;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
core::Project::ToolKeyframeBool k;
|
||||
k.frame = int(fr);
|
||||
k.value = val;
|
||||
tmp.visibilityKeys.push_back(k);
|
||||
tmp.visibilityKeys.reserve(nVis);
|
||||
for (qint32 i = 0; i < nVis; ++i) {
|
||||
qint32 fr = 0;
|
||||
bool vis = true;
|
||||
ds >> fr >> vis;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
tmp.visibilityKeys.push_back(Project::ToolKeyframeBool{int(fr), vis});
|
||||
}
|
||||
|
||||
if (!readIntroBlock(ds, tmp.intro)) {
|
||||
return false;
|
||||
}
|
||||
@@ -332,6 +381,17 @@ public:
|
||||
tmp.blackholeVisible = holeVisible;
|
||||
tmp.blackholeId = holeId.isEmpty() ? QStringLiteral("blackhole-%1").arg(tmp.id) : holeId;
|
||||
tmp.blackholeResolvedBy = resolvedBy;
|
||||
QString overlayPath;
|
||||
qint32 rectX = 0;
|
||||
qint32 rectY = 0;
|
||||
qint32 ow = 0;
|
||||
qint32 oh = 0;
|
||||
ds >> overlayPath >> rectX >> rectY >> ow >> oh;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
tmp.blackholeOverlayPath = overlayPath;
|
||||
tmp.blackholeOverlayRect = QRect(int(rectX), int(rectY), int(ow), int(oh));
|
||||
*m_dst = std::move(tmp);
|
||||
return true;
|
||||
}
|
||||
@@ -376,126 +436,7 @@ bool EntityPayloadBinary::save(const QString& absolutePath, const Project::Entit
|
||||
}
|
||||
|
||||
bool EntityPayloadBinary::load(const QString& absolutePath, Project::Entity& entity) {
|
||||
QFile f(absolutePath);
|
||||
if (!f.open(QIODevice::ReadOnly)) {
|
||||
return false;
|
||||
}
|
||||
QDataStream ds(&f);
|
||||
ds.setVersion(QDataStream::Qt_5_15);
|
||||
quint32 magic = 0;
|
||||
quint32 ver = 0;
|
||||
ds >> magic >> ver;
|
||||
if (ds.status() != QDataStream::Ok || magic != kMagicPayload) {
|
||||
return false;
|
||||
}
|
||||
if (ver != 1 && ver != 2 && ver != 3 && ver != 4 && ver != 5 && ver != 6 && ver != 7 && ver != 8 && ver != 9) {
|
||||
return false;
|
||||
}
|
||||
Project::Entity tmp;
|
||||
if (!readEntityPayloadV1(ds, tmp, ver >= 3)) {
|
||||
return false;
|
||||
}
|
||||
if (ver >= 2) {
|
||||
QString dn;
|
||||
double us = 1.0;
|
||||
ds >> dn >> us;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
tmp.displayName = dn;
|
||||
tmp.userScale = std::clamp(us, 1e-3, 1e3);
|
||||
if (ver >= 4) {
|
||||
double cal = 0.0;
|
||||
ds >> cal;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
tmp.distanceScaleCalibMult = (cal > 0.0) ? std::clamp(cal, 1e-6, 10.0) : 0.0;
|
||||
}
|
||||
if (ver >= 6) {
|
||||
bool ign = false;
|
||||
QString pid;
|
||||
double pox = 0.0;
|
||||
double poy = 0.0;
|
||||
ds >> ign >> pid >> pox >> poy;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
tmp.ignoreDistanceScale = ign;
|
||||
tmp.parentId = pid;
|
||||
tmp.parentOffsetWorld = QPointF(pox, poy);
|
||||
} else {
|
||||
tmp.ignoreDistanceScale = false;
|
||||
tmp.parentId.clear();
|
||||
tmp.parentOffsetWorld = QPointF();
|
||||
}
|
||||
if (ver >= 7) {
|
||||
qint32 nVis = 0;
|
||||
ds >> nVis;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
tmp.visibilityKeys.clear();
|
||||
if (nVis > 0) {
|
||||
tmp.visibilityKeys.reserve(nVis);
|
||||
for (qint32 i = 0; i < nVis; ++i) {
|
||||
qint32 fr = 0;
|
||||
bool val = true;
|
||||
ds >> fr >> val;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
core::Project::ToolKeyframeBool k;
|
||||
k.frame = int(fr);
|
||||
k.value = val;
|
||||
tmp.visibilityKeys.push_back(k);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tmp.visibilityKeys.clear();
|
||||
}
|
||||
if (ver >= 5) {
|
||||
if (!readIntroBlock(ds, tmp.intro)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (ver >= 8) {
|
||||
bool holeVisible = true;
|
||||
QString holeId;
|
||||
ds >> holeVisible >> holeId;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
tmp.blackholeVisible = holeVisible;
|
||||
tmp.blackholeId = holeId.isEmpty() ? QStringLiteral("blackhole-%1").arg(tmp.id) : holeId;
|
||||
if (ver >= 9) {
|
||||
QString resolvedBy;
|
||||
ds >> resolvedBy;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
tmp.blackholeResolvedBy = resolvedBy;
|
||||
} else {
|
||||
tmp.blackholeResolvedBy = QStringLiteral("pending");
|
||||
}
|
||||
} else {
|
||||
tmp.blackholeVisible = true;
|
||||
tmp.blackholeId = QStringLiteral("blackhole-%1").arg(tmp.id);
|
||||
tmp.blackholeResolvedBy = QStringLiteral("pending");
|
||||
}
|
||||
} else {
|
||||
tmp.displayName.clear();
|
||||
tmp.userScale = 1.0;
|
||||
tmp.ignoreDistanceScale = false;
|
||||
tmp.parentId.clear();
|
||||
tmp.parentOffsetWorld = QPointF();
|
||||
tmp.visibilityKeys.clear();
|
||||
tmp.blackholeVisible = true;
|
||||
tmp.blackholeId = QStringLiteral("blackhole-%1").arg(tmp.id);
|
||||
tmp.blackholeResolvedBy = QStringLiteral("pending");
|
||||
}
|
||||
entity = std::move(tmp);
|
||||
return true;
|
||||
return EntityBinaryRecord(entity).loadFromFile(absolutePath);
|
||||
}
|
||||
|
||||
bool EntityPayloadBinary::loadLegacyAnimFile(const QString& absolutePath, Project::Entity& entity) {
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
namespace core {
|
||||
|
||||
// 实体完整数据(几何 + 贴图路径 + 动画轨道)的二进制格式,与 project.json v2 的 payload 字段对应。
|
||||
// 贴图 PNG 仍单独存放在 assets/entities/,本文件不嵌入像素。
|
||||
// 默认贴图 PNG 存在本文件中(PNG bytes),不兼容旧路径工程。
|
||||
// 具体读写通过继承 PersistentBinaryObject 的适配器类完成(见 EntityPayloadBinary.cpp)。
|
||||
class EntityPayloadBinary {
|
||||
public:
|
||||
static constexpr quint32 kMagicPayload = 0x48464550; // 'HFEP'
|
||||
static constexpr quint32 kPayloadVersion = 9; // v9:追加 blackholeResolvedBy
|
||||
static constexpr quint32 kPayloadVersion = 13; // v13:默认贴图存 PNG bytes
|
||||
|
||||
// 旧版独立动画文件(仍用于打开 v1 项目时合并)
|
||||
static constexpr quint32 kMagicLegacyAnim = 0x48465441; // 'HFTA'
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
#include "persistence/JsonFileAtomic.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
|
||||
namespace core::persistence {
|
||||
|
||||
bool writeBytesAtomic(const QString& absolutePath, const QByteArray& bytes) {
|
||||
if (absolutePath.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
const QString parent = QFileInfo(absolutePath).absolutePath();
|
||||
if (!QFileInfo(parent).exists()) {
|
||||
QDir().mkpath(parent);
|
||||
}
|
||||
const QString tmpAbs = absolutePath + QStringLiteral(".tmp");
|
||||
if (QFileInfo::exists(tmpAbs)) {
|
||||
QFile::remove(tmpAbs);
|
||||
}
|
||||
QFile f(tmpAbs);
|
||||
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||
return false;
|
||||
}
|
||||
const bool ok = f.write(bytes) == bytes.size();
|
||||
f.close();
|
||||
if (!ok || f.error() != QFile::NoError) {
|
||||
QFile::remove(tmpAbs);
|
||||
return false;
|
||||
}
|
||||
QFile::remove(absolutePath);
|
||||
if (!QFile::rename(tmpAbs, absolutePath)) {
|
||||
QFile::remove(tmpAbs);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool writeJsonAtomic(const QString& absolutePath, const QJsonObject& obj, bool pretty) {
|
||||
const QJsonDocument doc(obj);
|
||||
const QByteArray bytes = doc.toJson(pretty ? QJsonDocument::Indented : QJsonDocument::Compact);
|
||||
return writeBytesAtomic(absolutePath, bytes);
|
||||
}
|
||||
|
||||
bool readJsonObject(const QString& absolutePath, QJsonObject& out) {
|
||||
out = QJsonObject();
|
||||
QFile f(absolutePath);
|
||||
if (!f.open(QIODevice::ReadOnly)) {
|
||||
return false;
|
||||
}
|
||||
const QByteArray data = f.readAll();
|
||||
QJsonParseError err;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(data, &err);
|
||||
if (err.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
return false;
|
||||
}
|
||||
out = doc.object();
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace core::persistence
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
|
||||
namespace core::persistence {
|
||||
|
||||
/// 原子写 JSON:写入 .tmp 后 rename 覆盖,避免截断导致下次打开“损坏”。
|
||||
bool writeJsonAtomic(const QString& absolutePath, const QJsonObject& obj, bool pretty = true);
|
||||
|
||||
/// 读取 JSON 文件(必须为 object)。失败返回 false。
|
||||
bool readJsonObject(const QString& absolutePath, QJsonObject& out);
|
||||
|
||||
/// 原子写 bytes:写入 .tmp 后 rename 覆盖。
|
||||
bool writeBytesAtomic(const QString& absolutePath, const QByteArray& bytes);
|
||||
|
||||
} // namespace core::persistence
|
||||
|
||||
@@ -48,9 +48,11 @@ bool PersistentBinaryObject::loadFromFile(const QString& absolutePath) {
|
||||
quint32 magic = 0;
|
||||
quint32 version = 0;
|
||||
ds >> magic >> version;
|
||||
if (ds.status() != QDataStream::Ok || magic != recordMagic() || version != recordFormatVersion()) {
|
||||
const quint32 cur = recordFormatVersion();
|
||||
if (ds.status() != QDataStream::Ok || magic != recordMagic() || version < 1 || version > cur) {
|
||||
return false;
|
||||
}
|
||||
m_loadedVersion = version;
|
||||
return readBody(ds);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,12 @@ protected:
|
||||
virtual quint32 recordFormatVersion() const = 0;
|
||||
virtual void writeBody(QDataStream& ds) const = 0;
|
||||
virtual bool readBody(QDataStream& ds) = 0;
|
||||
|
||||
/// 读取文件时解析到的版本号(<= recordFormatVersion)。用于向后兼容读取分支。
|
||||
[[nodiscard]] quint32 loadedRecordFormatVersion() const { return m_loadedVersion; }
|
||||
|
||||
private:
|
||||
quint32 m_loadedVersion = 0;
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,19 +5,24 @@
|
||||
#include <QImage>
|
||||
#include <QJsonObject>
|
||||
#include <QRect>
|
||||
#include <QSet>
|
||||
#include <QTimer>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QVector>
|
||||
#include <QPoint>
|
||||
|
||||
namespace core {
|
||||
namespace persistence {
|
||||
class AsyncProjectWriter;
|
||||
}
|
||||
|
||||
class ProjectWorkspace {
|
||||
public:
|
||||
static constexpr const char* kProjectIndexFileName = "project.json";
|
||||
static constexpr const char* kAssetsDirName = "assets";
|
||||
// 写入 project.json 的 version 字段;仍可读 version 1(内嵌实体 + 可选 .anim)。
|
||||
static constexpr int kProjectIndexFormatVersion = 4;
|
||||
static constexpr int kProjectIndexFormatVersion = 7;
|
||||
|
||||
ProjectWorkspace() = default;
|
||||
|
||||
@@ -58,6 +63,7 @@ public:
|
||||
|
||||
// 仅写盘(project.json + payload 同步)。动画 UI 直接编辑 Project 后可调用此函数持久化。
|
||||
bool save();
|
||||
bool saveAll();
|
||||
|
||||
// 历史操作(最多 30 步),类似 Blender:维护 undo/redo 栈
|
||||
bool canUndo() const;
|
||||
@@ -92,6 +98,7 @@ public:
|
||||
bool setToolFontPx(const QString& id, int fontPx);
|
||||
bool setToolAlign(const QString& id, core::Project::Tool::TextAlign align);
|
||||
bool setToolVisibilityKey(const QString& id, int frame, bool visible);
|
||||
bool setToolPriorityKey(const QString& id, int frame, int priority);
|
||||
bool removeToolVisibilityKey(const QString& id, int frame);
|
||||
bool setToolParent(const QString& id, const QString& parentId, const QPointF& parentOffsetWorld);
|
||||
bool moveToolBy(const QString& id, const QPointF& delta, int currentFrame, bool autoKeyLocation);
|
||||
@@ -113,9 +120,9 @@ public:
|
||||
// 复制背景其他区域填充黑洞(sourceOffsetPx 以黑洞包围盒左上角为基准偏移)
|
||||
bool resolveBlackholeByCopyBackground(const QString& id, const QPoint& sourceOffsetPx,
|
||||
bool hideBlackholeAfterFill);
|
||||
// 使用模型补全后的结果写回背景(patchedBackground 已包含补全贴合后的完整背景图像)
|
||||
bool resolveBlackholeByModelInpaint(const QString& id, const QImage& patchedBackground,
|
||||
bool hideBlackholeAfterFill);
|
||||
// 将模型补全的裁剪块保存为覆盖层并叠加显示,不修改 background 原图
|
||||
bool resolveBlackholeByModelInpaint(const QString& id, const QImage& overlayPatch,
|
||||
const QRect& overlayRectInBackground, bool hideBlackholeAfterFill);
|
||||
bool setEntityVisibilityKey(const QString& id, int frame, bool visible);
|
||||
bool removeEntityVisibilityKey(const QString& id, int frame);
|
||||
bool setEntityDisplayName(const QString& id, const QString& displayName);
|
||||
@@ -126,17 +133,42 @@ public:
|
||||
// 将多边形质心平移到 targetCentroidWorld(整体平移);sTotal 须与画布一致
|
||||
bool moveEntityCentroidTo(const QString& id, int frame, const QPointF& targetCentroidWorld, double sTotal,
|
||||
bool autoKeyLocation);
|
||||
/// 仅适用于有父实体:设置相对父形心的偏移(静态 parentOffsetWorld)。
|
||||
bool setEntityParentOffsetWorld(const QString& id, const QPointF& offsetWorld);
|
||||
// 在保持外形不变的前提下移动枢轴点;sTotal 须与画布一致(距离缩放×整体缩放)
|
||||
bool reanchorEntityPivot(const QString& id, int frame, const QPointF& newPivotWorld, double sTotal);
|
||||
/// 将各实体 origin 与多边形形心对齐(外形不变);打开工程时迁移旧数据。
|
||||
bool normalizeAllEntityOriginsToCentroids(int frame);
|
||||
/// 当前帧下实体多边形形心的世界坐标(与画布深度/距离缩放采样点一致);工具 id 则返回其原点。
|
||||
[[nodiscard]] QPointF entityCentroidWorldAtFrame(const QString& id, int frame) const;
|
||||
bool reorderEntitiesById(const QStringList& idsInOrder);
|
||||
// currentFrame:自动关键帧时写入位置曲线;autoKeyLocation 为 false 时忽略。
|
||||
bool moveEntityBy(const QString& id, const QPointF& delta, int currentFrame, bool autoKeyLocation);
|
||||
bool setEntityLocationKey(const QString& id, int frame, const QPointF& originWorld);
|
||||
bool setEntityDepthScaleKey(const QString& id, int frame, double value01);
|
||||
bool setEntityUserScaleKey(const QString& id, int frame, double userScale);
|
||||
bool setEntityPriorityKey(const QString& id, int frame, int priority);
|
||||
bool setEntityDefaultImage(const QString& id, const QImage& image, QString* outRelPath = nullptr);
|
||||
bool setEntityImageFrame(const QString& id, int frame, const QImage& image, QString* outRelPath = nullptr);
|
||||
// 仅更新 imageFrames 中某帧的图像路径(不读图、不写盘),用于高性能地“切断”Hold 区间
|
||||
bool setEntityImageFramePath(const QString& id, int frame, const QString& relativePath);
|
||||
QVector<Project::Entity::ImageFrame> entitySpriteFrames(const QString& id) const;
|
||||
QString entityImagePathAt(const QString& id, int frame) const;
|
||||
QByteArray entityImagePngAt(const QString& id, int frame) const;
|
||||
bool clearEntityImageFramesInRange(const QString& id, int startFrame, int endFrame);
|
||||
int activeAnimationLengthFrames() const;
|
||||
int activeAnimationFps() const;
|
||||
int animationLengthFramesForId(const QString& animationId) const;
|
||||
int animationFpsForId(const QString& animationId) const;
|
||||
bool animationLoopsForId(const QString& animationId) const;
|
||||
|
||||
bool createAnimationScheme(const QString& displayName, int lengthFrames, bool loop, int fps,
|
||||
QString* outNewId = nullptr);
|
||||
bool updateAnimationSchemeSettings(const QString& id, const QString& displayName, int lengthFrames, bool loop,
|
||||
int fps);
|
||||
|
||||
bool applyPresentationHotspots(const QVector<Project::PresentationHotspot>& hotspots, bool recordHistory,
|
||||
const QString& label);
|
||||
bool removeEntityLocationKey(const QString& id, int frame);
|
||||
bool removeEntityDepthScaleKey(const QString& id, int frame);
|
||||
bool removeEntityUserScaleKey(const QString& id, int frame);
|
||||
@@ -152,8 +184,9 @@ private:
|
||||
bool readIndexJson(const QString& indexPath);
|
||||
|
||||
bool syncEntityPayloadsToDisk();
|
||||
bool syncAnimationPayloadsToDisk();
|
||||
bool hydrateEntityPayloadsFromDisk();
|
||||
void loadV1LegacyAnimationSidecars();
|
||||
bool hydrateAnimationPayloadsFromDisk();
|
||||
bool writeIndexJsonWithoutPayloadSync();
|
||||
bool saveSingleEntityPayload(Project::Entity& entity);
|
||||
|
||||
@@ -163,7 +196,6 @@ private:
|
||||
static QString fileSuffixWithDot(const QString& path);
|
||||
static QString asOptionalRelativeUnderProject(const QString& relativePath);
|
||||
static QJsonObject entityToJson(const Project::Entity& e);
|
||||
static bool entityFromJsonV1(const QJsonObject& o, Project::Entity& out);
|
||||
static bool entityStubFromJsonV2(const QJsonObject& o, Project::Entity& out);
|
||||
static QJsonObject toolToJson(const Project::Tool& t);
|
||||
static bool toolFromJsonV2(const QJsonObject& o, Project::Tool& out);
|
||||
@@ -174,7 +206,16 @@ private:
|
||||
bool ensureDefaultCameraIfMissing(bool* outAdded = nullptr);
|
||||
|
||||
struct Operation {
|
||||
enum class Type { ImportBackground, SetEntities, SetTools, SetCameras, SetProjectTitle, SetProjectFrameRange };
|
||||
enum class Type {
|
||||
ImportBackground,
|
||||
SetEntities,
|
||||
SetTools,
|
||||
SetCameras,
|
||||
SetAnimations,
|
||||
SetPresentationHotspots,
|
||||
SetProjectTitle,
|
||||
SetProjectFrameRange
|
||||
};
|
||||
Type type {Type::ImportBackground};
|
||||
QString label;
|
||||
QString beforeBackgroundPath;
|
||||
@@ -185,6 +226,14 @@ private:
|
||||
QVector<Project::Tool> afterTools;
|
||||
QVector<Project::Camera> beforeCameras;
|
||||
QVector<Project::Camera> afterCameras;
|
||||
QVector<Project::Animation> beforeAnimations;
|
||||
QVector<Project::Animation> afterAnimations;
|
||||
bool animBundlesPresentationHotspots = false;
|
||||
QVector<Project::PresentationHotspot> animSnapshotBeforePresentationHotspots;
|
||||
QVector<Project::PresentationHotspot> animSnapshotAfterPresentationHotspots;
|
||||
|
||||
QVector<Project::PresentationHotspot> beforePresentationHotspots;
|
||||
QVector<Project::PresentationHotspot> afterPresentationHotspots;
|
||||
QString beforeProjectTitle;
|
||||
QString afterProjectTitle;
|
||||
int beforeFrameStart = 0;
|
||||
@@ -200,19 +249,36 @@ private:
|
||||
bool applyEntities(const QVector<Project::Entity>& entities, bool recordHistory, const QString& label);
|
||||
bool applyTools(const QVector<Project::Tool>& tools, bool recordHistory, const QString& label);
|
||||
bool applyCameras(const QVector<Project::Camera>& cameras, bool recordHistory, const QString& label);
|
||||
bool applyAnimations(const QVector<Project::Animation>& animations, bool recordHistory, const QString& label);
|
||||
QString copyIntoAssetsAsBackground(const QString& sourceFilePath, const QRect& cropRectInSourceImage);
|
||||
bool writeDepthMap(const QImage& depth8);
|
||||
bool writeDepthMapBytes(const QByteArray& pngBytes);
|
||||
QString ensureEntitiesDir() const;
|
||||
QString ensureAnimationsDir() const;
|
||||
bool writeEntityImage(const QString& entityId, const QImage& image, QString& outRelPath);
|
||||
bool writeEntityFrameImage(const QString& entityId, int frame, const QImage& image, QString& outRelPath);
|
||||
QString defaultAnimationPayloadPath(const QString& animationId) const;
|
||||
void markIndexDirty();
|
||||
/// snapshot:在尚未写入 m_project 前(例如新实体)必须传入,否则异步 bundle 写会漏队列。
|
||||
void markEntityDirty(const QString& id, const Project::Entity* snapshot = nullptr);
|
||||
void markAnimationDirty(const QString& id);
|
||||
void markAllEntitiesDirty();
|
||||
void markAllAnimationsDirty();
|
||||
void schedulePayloadSync();
|
||||
bool syncPayloadsToDiskOnce();
|
||||
|
||||
private:
|
||||
QString m_projectDir;
|
||||
Project m_project;
|
||||
|
||||
persistence::AsyncProjectWriter* m_asyncWriter = nullptr;
|
||||
|
||||
QVector<Operation> m_undoStack;
|
||||
QVector<Operation> m_redoStack;
|
||||
QSet<QString> m_dirtyEntityIds;
|
||||
QSet<QString> m_dirtyAnimationIds;
|
||||
bool m_indexDirty = false;
|
||||
QTimer* m_payloadSyncTimer = nullptr;
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
|
||||
Reference in New Issue
Block a user