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
+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