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