update
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
#include "persistence/AnimationBinary.h"
|
||||
|
||||
#include "persistence/PersistentBinaryObject.h"
|
||||
|
||||
#include <QDataStream>
|
||||
#include <QFile>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace core {
|
||||
namespace {
|
||||
|
||||
qint32 toInt(Project::AnimationTargetKind v) {
|
||||
return static_cast<qint32>(v);
|
||||
}
|
||||
|
||||
qint32 toInt(Project::AnimationProperty v) {
|
||||
return static_cast<qint32>(v);
|
||||
}
|
||||
|
||||
qint32 toInt(Project::AnimationValueType v) {
|
||||
return static_cast<qint32>(v);
|
||||
}
|
||||
|
||||
qint32 toInt(Project::AnimationInterpolation v) {
|
||||
return static_cast<qint32>(v);
|
||||
}
|
||||
|
||||
Project::AnimationTargetKind targetKindFromInt(qint32 v) {
|
||||
if (v == toInt(Project::AnimationTargetKind::Tool)) return Project::AnimationTargetKind::Tool;
|
||||
if (v == toInt(Project::AnimationTargetKind::Camera)) return Project::AnimationTargetKind::Camera;
|
||||
return Project::AnimationTargetKind::Entity;
|
||||
}
|
||||
|
||||
Project::AnimationProperty propertyFromInt(qint32 v) {
|
||||
switch (v) {
|
||||
case 1: return Project::AnimationProperty::UserScale;
|
||||
case 2: return Project::AnimationProperty::Sprite;
|
||||
case 3: return Project::AnimationProperty::Visibility;
|
||||
case 4: return Project::AnimationProperty::DepthScale;
|
||||
case 5: return Project::AnimationProperty::CameraScale;
|
||||
default: return Project::AnimationProperty::Position;
|
||||
}
|
||||
}
|
||||
|
||||
Project::AnimationValueType valueTypeFromInt(qint32 v) {
|
||||
switch (v) {
|
||||
case 1: return Project::AnimationValueType::Double;
|
||||
case 2: return Project::AnimationValueType::Bool;
|
||||
case 3: return Project::AnimationValueType::ImagePath;
|
||||
case 4: return Project::AnimationValueType::PngBytes;
|
||||
default: return Project::AnimationValueType::Vec2;
|
||||
}
|
||||
}
|
||||
|
||||
Project::AnimationInterpolation interpolationFromInt(qint32 v) {
|
||||
return v == 0 ? Project::AnimationInterpolation::Hold : Project::AnimationInterpolation::Linear;
|
||||
}
|
||||
|
||||
void sortAndDedupe(QVector<Project::AnimationKey>& keys) {
|
||||
std::sort(keys.begin(), keys.end(), [](const auto& a, const auto& b) {
|
||||
return a.frame < b.frame;
|
||||
});
|
||||
QVector<Project::AnimationKey> out;
|
||||
out.reserve(keys.size());
|
||||
for (const auto& k : keys) {
|
||||
if (!out.isEmpty() && out.last().frame == k.frame) {
|
||||
out.last() = k;
|
||||
} else {
|
||||
out.push_back(k);
|
||||
}
|
||||
}
|
||||
keys = std::move(out);
|
||||
}
|
||||
|
||||
class AnimationRecord final : public PersistentBinaryObject {
|
||||
public:
|
||||
explicit AnimationRecord(const Project::Animation& animation) : m_src(&animation), m_dst(nullptr) {}
|
||||
explicit AnimationRecord(Project::Animation& animation) : m_src(nullptr), m_dst(&animation) {}
|
||||
|
||||
quint32 recordMagic() const override { return AnimationBinary::kMagicAnimation; }
|
||||
quint32 recordFormatVersion() const override { return AnimationBinary::kAnimationVersion; }
|
||||
|
||||
void writeBody(QDataStream& ds) const override {
|
||||
const Project::Animation& a = *m_src;
|
||||
ds << a.id << a.name << a.payloadPath;
|
||||
ds << qint32(a.fps) << qint32(a.lengthFrames) << bool(a.loop);
|
||||
ds << qint32(a.tracks.size());
|
||||
for (const auto& t : a.tracks) {
|
||||
ds << t.id << toInt(t.targetKind) << t.targetId << toInt(t.property)
|
||||
<< toInt(t.valueType) << toInt(t.interpolation);
|
||||
ds << qint32(t.keys.size());
|
||||
for (const auto& k : t.keys) {
|
||||
ds << qint32(k.frame);
|
||||
switch (t.valueType) {
|
||||
case Project::AnimationValueType::Vec2:
|
||||
ds << double(k.vec2Value.x()) << double(k.vec2Value.y());
|
||||
break;
|
||||
case Project::AnimationValueType::Double:
|
||||
ds << double(k.numberValue);
|
||||
break;
|
||||
case Project::AnimationValueType::Bool:
|
||||
ds << bool(k.boolValue);
|
||||
break;
|
||||
case Project::AnimationValueType::ImagePath:
|
||||
ds << k.stringValue;
|
||||
break;
|
||||
case Project::AnimationValueType::PngBytes:
|
||||
ds << k.bytesValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool readBody(QDataStream& ds) override {
|
||||
Project::Animation a;
|
||||
qint32 fps = 30;
|
||||
qint32 length = Project::kClipFixedFrames;
|
||||
bool loop = true;
|
||||
ds >> a.id >> a.name >> a.payloadPath;
|
||||
ds >> fps >> length >> loop;
|
||||
if (ds.status() != QDataStream::Ok || a.id.isEmpty()) return false;
|
||||
a.fps = std::max(1, int(fps));
|
||||
a.lengthFrames = std::max(1, int(length));
|
||||
a.loop = loop;
|
||||
|
||||
qint32 nTracks = 0;
|
||||
ds >> nTracks;
|
||||
if (ds.status() != QDataStream::Ok || nTracks < 0 || nTracks > 100000) return false;
|
||||
a.tracks.reserve(nTracks);
|
||||
for (qint32 i = 0; i < nTracks; ++i) {
|
||||
Project::AnimationTrack t;
|
||||
qint32 kind = 0;
|
||||
qint32 prop = 0;
|
||||
qint32 valueType = 0;
|
||||
qint32 interpolation = 1;
|
||||
ds >> t.id >> kind >> t.targetId >> prop >> valueType >> interpolation;
|
||||
if (ds.status() != QDataStream::Ok || t.targetId.isEmpty()) return false;
|
||||
t.targetKind = targetKindFromInt(kind);
|
||||
t.property = propertyFromInt(prop);
|
||||
t.valueType = valueTypeFromInt(valueType);
|
||||
t.interpolation = interpolationFromInt(interpolation);
|
||||
|
||||
qint32 nKeys = 0;
|
||||
ds >> nKeys;
|
||||
if (ds.status() != QDataStream::Ok || nKeys < 0 || nKeys > 1000000) return false;
|
||||
t.keys.reserve(nKeys);
|
||||
for (qint32 kidx = 0; kidx < nKeys; ++kidx) {
|
||||
Project::AnimationKey k;
|
||||
qint32 frame = 0;
|
||||
ds >> frame;
|
||||
k.frame = int(frame);
|
||||
switch (t.valueType) {
|
||||
case Project::AnimationValueType::Vec2: {
|
||||
double x = 0.0;
|
||||
double y = 0.0;
|
||||
ds >> x >> y;
|
||||
k.vec2Value = QPointF(x, y);
|
||||
break;
|
||||
}
|
||||
case Project::AnimationValueType::Double:
|
||||
ds >> k.numberValue;
|
||||
break;
|
||||
case Project::AnimationValueType::Bool:
|
||||
ds >> k.boolValue;
|
||||
break;
|
||||
case Project::AnimationValueType::ImagePath:
|
||||
ds >> k.stringValue;
|
||||
break;
|
||||
case Project::AnimationValueType::PngBytes:
|
||||
ds >> k.bytesValue;
|
||||
break;
|
||||
}
|
||||
if (ds.status() != QDataStream::Ok) return false;
|
||||
t.keys.push_back(k);
|
||||
}
|
||||
sortAndDedupe(t.keys);
|
||||
a.tracks.push_back(t);
|
||||
}
|
||||
|
||||
*m_dst = std::move(a);
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
const Project::Animation* m_src;
|
||||
Project::Animation* m_dst;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
bool AnimationBinary::save(const QString& absolutePath, const Project::Animation& animation) {
|
||||
if (absolutePath.isEmpty() || animation.id.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return AnimationRecord(animation).saveToFile(absolutePath);
|
||||
}
|
||||
|
||||
bool AnimationBinary::load(const QString& absolutePath, Project::Animation& animation) {
|
||||
return AnimationRecord(animation).loadFromFile(absolutePath);
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
|
||||
#include "domain/Project.h"
|
||||
|
||||
#include <QString>
|
||||
|
||||
namespace core {
|
||||
|
||||
class AnimationBinary {
|
||||
public:
|
||||
static constexpr quint32 kMagicAnimation = 0x4846414E; // 'HFAN'
|
||||
static constexpr quint32 kAnimationVersion = 2;
|
||||
|
||||
static bool save(const QString& absolutePath, const Project::Animation& animation);
|
||||
static bool load(const QString& absolutePath, Project::Animation& animation);
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,162 @@
|
||||
#include "persistence/AnimationBundleStore.h"
|
||||
|
||||
#include "persistence/JsonFileAtomic.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
|
||||
namespace core::persistence {
|
||||
namespace {
|
||||
|
||||
QJsonObject keyToJson(const Project::AnimationKey& k, Project::AnimationValueType t) {
|
||||
QJsonObject o;
|
||||
o.insert("frame", k.frame);
|
||||
switch (t) {
|
||||
case Project::AnimationValueType::Vec2:
|
||||
o.insert("x", k.vec2Value.x());
|
||||
o.insert("y", k.vec2Value.y());
|
||||
break;
|
||||
case Project::AnimationValueType::Double:
|
||||
o.insert("value", k.numberValue);
|
||||
break;
|
||||
case Project::AnimationValueType::Bool:
|
||||
o.insert("value", k.boolValue);
|
||||
break;
|
||||
case Project::AnimationValueType::ImagePath:
|
||||
o.insert("value", k.stringValue);
|
||||
break;
|
||||
case Project::AnimationValueType::PngBytes:
|
||||
o.insert("value", QString::fromLatin1(k.bytesValue.toBase64()));
|
||||
break;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
bool keyFromJson(const QJsonObject& o, Project::AnimationValueType t, Project::AnimationKey& out) {
|
||||
out = Project::AnimationKey{};
|
||||
out.frame = o.value("frame").toInt(0);
|
||||
switch (t) {
|
||||
case Project::AnimationValueType::Vec2:
|
||||
out.vec2Value = QPointF(o.value("x").toDouble(0.0), o.value("y").toDouble(0.0));
|
||||
break;
|
||||
case Project::AnimationValueType::Double:
|
||||
out.numberValue = o.value("value").toDouble(0.0);
|
||||
break;
|
||||
case Project::AnimationValueType::Bool:
|
||||
out.boolValue = o.value("value").toBool(true);
|
||||
break;
|
||||
case Project::AnimationValueType::ImagePath:
|
||||
out.stringValue = o.value("value").toString();
|
||||
break;
|
||||
case Project::AnimationValueType::PngBytes: {
|
||||
const QByteArray b64 = o.value("value").toString().toLatin1();
|
||||
out.bytesValue = QByteArray::fromBase64(b64);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QString AnimationBundleStore::bundleRelativeDir(const QString& animationId) {
|
||||
if (animationId.isEmpty()) return {};
|
||||
return QStringLiteral("assets/animations/%1").arg(animationId);
|
||||
}
|
||||
|
||||
bool AnimationBundleStore::save(const QString& projectDirAbs, const Project::Animation& animation) {
|
||||
if (projectDirAbs.isEmpty() || animation.id.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
const QString relDir = bundleRelativeDir(animation.id);
|
||||
if (relDir.isEmpty()) return false;
|
||||
const QString absDir = QDir(projectDirAbs).filePath(relDir);
|
||||
QDir().mkpath(absDir);
|
||||
|
||||
QJsonObject root;
|
||||
root.insert("id", animation.id);
|
||||
root.insert("name", animation.name);
|
||||
root.insert("fps", animation.fps);
|
||||
root.insert("lengthFrames", animation.lengthFrames);
|
||||
root.insert("loop", animation.loop);
|
||||
|
||||
QJsonArray tracks;
|
||||
for (const auto& t : animation.tracks) {
|
||||
QJsonObject to;
|
||||
to.insert("id", t.id);
|
||||
to.insert("targetKind", int(t.targetKind));
|
||||
to.insert("targetId", t.targetId);
|
||||
to.insert("property", int(t.property));
|
||||
to.insert("valueType", int(t.valueType));
|
||||
to.insert("interpolation", int(t.interpolation));
|
||||
QJsonArray keys;
|
||||
for (const auto& k : t.keys) {
|
||||
keys.append(keyToJson(k, t.valueType));
|
||||
}
|
||||
to.insert("keys", keys);
|
||||
tracks.append(to);
|
||||
}
|
||||
root.insert("tracks", tracks);
|
||||
|
||||
return writeJsonAtomic(QDir(absDir).filePath(QStringLiteral("anim.json")), root, true);
|
||||
}
|
||||
|
||||
bool AnimationBundleStore::load(const QString& projectDirAbs, Project::Animation& inOutAnimation) {
|
||||
if (projectDirAbs.isEmpty() || inOutAnimation.id.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
const QString relDir = inOutAnimation.payloadPath;
|
||||
if (relDir.isEmpty()) return false;
|
||||
const QString absDir = QDir(projectDirAbs).filePath(relDir);
|
||||
if (!QFileInfo(absDir).exists()) return false;
|
||||
|
||||
QJsonObject root;
|
||||
if (!readJsonObject(QDir(absDir).filePath(QStringLiteral("anim.json")), root)) {
|
||||
return false;
|
||||
}
|
||||
const QString id = root.value("id").toString();
|
||||
if (id.isEmpty() || id != inOutAnimation.id) {
|
||||
return false;
|
||||
}
|
||||
Project::Animation a = inOutAnimation;
|
||||
a.name = root.value("name").toString();
|
||||
a.fps = std::max(1, root.value("fps").toInt(30));
|
||||
a.lengthFrames = std::max(1, root.value("lengthFrames").toInt(Project::kClipFixedFrames));
|
||||
a.loop = root.value("loop").toBool(true);
|
||||
|
||||
a.tracks.clear();
|
||||
const QJsonValue tracksVal = root.value("tracks");
|
||||
if (tracksVal.isArray()) {
|
||||
for (const auto& it : tracksVal.toArray()) {
|
||||
if (!it.isObject()) continue;
|
||||
const QJsonObject to = it.toObject();
|
||||
Project::AnimationTrack t;
|
||||
t.id = to.value("id").toString();
|
||||
t.targetKind = Project::AnimationTargetKind(to.value("targetKind").toInt(0));
|
||||
t.targetId = to.value("targetId").toString();
|
||||
t.property = Project::AnimationProperty(to.value("property").toInt(0));
|
||||
t.valueType = Project::AnimationValueType(to.value("valueType").toInt(0));
|
||||
t.interpolation = Project::AnimationInterpolation(to.value("interpolation").toInt(1));
|
||||
const QJsonValue keysVal = to.value("keys");
|
||||
if (keysVal.isArray()) {
|
||||
for (const auto& kv : keysVal.toArray()) {
|
||||
if (!kv.isObject()) continue;
|
||||
Project::AnimationKey k;
|
||||
keyFromJson(kv.toObject(), t.valueType, k);
|
||||
t.keys.push_back(k);
|
||||
}
|
||||
}
|
||||
if (!t.targetId.isEmpty()) {
|
||||
a.tracks.push_back(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inOutAnimation = std::move(a);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace core::persistence
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include "domain/Project.h"
|
||||
|
||||
#include <QString>
|
||||
|
||||
namespace core::persistence {
|
||||
|
||||
/// 动画 payload 的目录 bundle(v7 起):
|
||||
/// assets/animations/<id>/
|
||||
/// anim.json // 元数据 + 所有 track(后续可拆成按 track 分块)
|
||||
struct AnimationBundleStore {
|
||||
static QString bundleRelativeDir(const QString& animationId);
|
||||
|
||||
static bool save(const QString& projectDirAbs, const Project::Animation& animation);
|
||||
static bool load(const QString& projectDirAbs, Project::Animation& inOutAnimation);
|
||||
};
|
||||
|
||||
} // namespace core::persistence
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
#include "persistence/AsyncProjectWriter.h"
|
||||
|
||||
#include "persistence/AnimationBundleStore.h"
|
||||
#include "persistence/EntityBundleStore.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QEventLoop>
|
||||
#include <QTimer>
|
||||
#include <QtConcurrent/QtConcurrent>
|
||||
|
||||
namespace core::persistence {
|
||||
|
||||
AsyncProjectWriter::AsyncProjectWriter(QObject* parent)
|
||||
: QObject(parent) {
|
||||
m_debounce = new QTimer(this);
|
||||
m_debounce->setSingleShot(true);
|
||||
m_debounce->setInterval(80);
|
||||
connect(m_debounce, &QTimer::timeout, this, [this]() { scheduleDrain(); });
|
||||
}
|
||||
|
||||
void AsyncProjectWriter::setProjectDir(const QString& projectDirAbs) {
|
||||
m_projectDir = projectDirAbs;
|
||||
}
|
||||
|
||||
void AsyncProjectWriter::requestWriteEntity(const Project::Entity& entity) {
|
||||
if (m_projectDir.isEmpty() || entity.id.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
{
|
||||
std::lock_guard lock(m_mutex);
|
||||
m_pendingEntities.insert(entity.id, entity);
|
||||
}
|
||||
if (m_debounce) {
|
||||
m_debounce->start();
|
||||
}
|
||||
}
|
||||
|
||||
void AsyncProjectWriter::requestWriteAnimation(const Project::Animation& animation) {
|
||||
if (m_projectDir.isEmpty() || animation.id.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
{
|
||||
std::lock_guard lock(m_mutex);
|
||||
m_pendingAnimations.insert(animation.id, animation);
|
||||
}
|
||||
if (m_debounce) {
|
||||
m_debounce->start();
|
||||
}
|
||||
}
|
||||
|
||||
void AsyncProjectWriter::scheduleDrain() {
|
||||
const QString dir = m_projectDir;
|
||||
if (dir.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QHash<QString, Project::Entity> ents;
|
||||
QHash<QString, Project::Animation> anims;
|
||||
{
|
||||
std::lock_guard lock(m_mutex);
|
||||
ents = std::move(m_pendingEntities);
|
||||
anims = std::move(m_pendingAnimations);
|
||||
m_pendingEntities.clear();
|
||||
m_pendingAnimations.clear();
|
||||
}
|
||||
if (ents.isEmpty() && anims.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_outstandingDrains.fetch_add(1, std::memory_order_acq_rel);
|
||||
|
||||
(void)QtConcurrent::run([this, dir, ents = std::move(ents), anims = std::move(anims)]() mutable {
|
||||
bool anyFail = false;
|
||||
for (auto it = ents.begin(); it != ents.end(); ++it) {
|
||||
const Project::Entity& e = it.value();
|
||||
if (!EntityBundleStore::save(dir, e)) {
|
||||
anyFail = true;
|
||||
QMetaObject::invokeMethod(this, [this, id = e.id]() { emit entityWriteFailed(id); },
|
||||
Qt::QueuedConnection);
|
||||
}
|
||||
}
|
||||
for (auto it = anims.begin(); it != anims.end(); ++it) {
|
||||
const Project::Animation& a = it.value();
|
||||
if (!AnimationBundleStore::save(dir, a)) {
|
||||
anyFail = true;
|
||||
QMetaObject::invokeMethod(this, [this, id = a.id]() { emit animationWriteFailed(id); },
|
||||
Qt::QueuedConnection);
|
||||
}
|
||||
}
|
||||
Q_UNUSED(anyFail);
|
||||
m_outstandingDrains.fetch_sub(1, std::memory_order_acq_rel);
|
||||
});
|
||||
|
||||
// drain 期间若又有新请求,再开一轮
|
||||
{
|
||||
std::lock_guard lock(m_mutex);
|
||||
if (!m_pendingEntities.isEmpty() || !m_pendingAnimations.isEmpty()) {
|
||||
if (m_debounce) {
|
||||
m_debounce->start();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void AsyncProjectWriter::flushBlocking(int timeoutMs) {
|
||||
if (m_projectDir.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (m_debounce) {
|
||||
m_debounce->stop();
|
||||
}
|
||||
|
||||
QMetaObject::invokeMethod(
|
||||
this,
|
||||
[this]() {
|
||||
scheduleDrain();
|
||||
},
|
||||
Qt::QueuedConnection);
|
||||
|
||||
QTimer killer;
|
||||
killer.setSingleShot(true);
|
||||
killer.start(std::max(0, timeoutMs));
|
||||
while (killer.isActive()) {
|
||||
bool idle = false;
|
||||
{
|
||||
std::lock_guard lock(m_mutex);
|
||||
idle =
|
||||
m_pendingEntities.isEmpty() && m_pendingAnimations.isEmpty() &&
|
||||
m_outstandingDrains.load(std::memory_order_acquire) == 0;
|
||||
}
|
||||
if (idle) {
|
||||
break;
|
||||
}
|
||||
QCoreApplication::processEvents(QEventLoop::AllEvents, 20);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace core::persistence
|
||||
@@ -0,0 +1,51 @@
|
||||
#pragma once
|
||||
|
||||
#include "domain/Project.h"
|
||||
|
||||
#include <QHash>
|
||||
#include <QObject>
|
||||
#include <QSet>
|
||||
#include <QString>
|
||||
#include <QTimer>
|
||||
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
|
||||
namespace core::persistence {
|
||||
|
||||
/// 后台写盘队列:合并/防抖,避免 UI 线程同步写文件。
|
||||
/// 目前以“实体/动画为单位”合并;后续会细化到块级 dirty bits(meta/anim/intro/png...)。
|
||||
class AsyncProjectWriter final : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit AsyncProjectWriter(QObject* parent = nullptr);
|
||||
|
||||
void setProjectDir(const QString& projectDirAbs);
|
||||
|
||||
/// enqueue:线程安全由调用方保证在 UI 线程调用(本类内部用 singleShot 防抖到 writer 线程)。
|
||||
void requestWriteEntity(const Project::Entity& entity);
|
||||
void requestWriteAnimation(const Project::Animation& animation);
|
||||
|
||||
/// 阻塞等待:用于 close/退出时确保落盘完成。
|
||||
void flushBlocking(int timeoutMs = 30000);
|
||||
|
||||
signals:
|
||||
void entityWriteFailed(const QString& entityId);
|
||||
void animationWriteFailed(const QString& animationId);
|
||||
|
||||
private:
|
||||
void scheduleDrain();
|
||||
|
||||
QString m_projectDir;
|
||||
QTimer* m_debounce = nullptr;
|
||||
|
||||
std::mutex m_mutex;
|
||||
// 最新快照:同 id 多次更新只写最后一次
|
||||
QHash<QString, Project::Entity> m_pendingEntities;
|
||||
QHash<QString, Project::Animation> m_pendingAnimations;
|
||||
|
||||
std::atomic<int> m_outstandingDrains{0};
|
||||
};
|
||||
|
||||
} // namespace core::persistence
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
#include "persistence/EntityBundleStore.h"
|
||||
|
||||
#include "persistence/JsonFileAtomic.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
|
||||
namespace core::persistence {
|
||||
namespace {
|
||||
|
||||
QJsonArray pointsToJson(const QVector<QPointF>& pts) {
|
||||
QJsonArray arr;
|
||||
for (const auto& p : pts) {
|
||||
QJsonObject o;
|
||||
o.insert("x", p.x());
|
||||
o.insert("y", p.y());
|
||||
arr.append(o);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
bool pointsFromJson(const QJsonValue& v, QVector<QPointF>& out) {
|
||||
out.clear();
|
||||
if (!v.isArray()) return true;
|
||||
const QJsonArray arr = v.toArray();
|
||||
out.reserve(arr.size());
|
||||
for (const auto& it : arr) {
|
||||
if (!it.isObject()) continue;
|
||||
const QJsonObject o = it.toObject();
|
||||
out.push_back(QPointF(o.value("x").toDouble(0.0), o.value("y").toDouble(0.0)));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
template <typename KeyT>
|
||||
QJsonArray boolKeysToJson(const QVector<KeyT>& keys) {
|
||||
QJsonArray arr;
|
||||
for (const auto& k : keys) {
|
||||
QJsonObject o;
|
||||
o.insert("frame", k.frame);
|
||||
o.insert("value", k.value);
|
||||
arr.append(o);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
QJsonArray vec2KeysToJson(const QVector<Project::Entity::KeyframeVec2>& keys) {
|
||||
QJsonArray arr;
|
||||
for (const auto& k : keys) {
|
||||
QJsonObject o;
|
||||
o.insert("frame", k.frame);
|
||||
o.insert("x", k.value.x());
|
||||
o.insert("y", k.value.y());
|
||||
arr.append(o);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
QJsonArray floatKeysToJson(const QVector<Project::Entity::KeyframeFloat01>& keys) {
|
||||
QJsonArray arr;
|
||||
for (const auto& k : keys) {
|
||||
QJsonObject o;
|
||||
o.insert("frame", k.frame);
|
||||
o.insert("value", k.value);
|
||||
arr.append(o);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
QJsonArray doubleKeysToJson(const QVector<Project::Entity::KeyframeDouble>& keys) {
|
||||
QJsonArray arr;
|
||||
for (const auto& k : keys) {
|
||||
QJsonObject o;
|
||||
o.insert("frame", k.frame);
|
||||
o.insert("value", k.value);
|
||||
arr.append(o);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
QJsonArray imageFramesToJson(const QVector<Project::Entity::ImageFrame>& keys) {
|
||||
QJsonArray arr;
|
||||
for (const auto& k : keys) {
|
||||
QJsonObject o;
|
||||
o.insert("frame", k.frame);
|
||||
o.insert("path", k.imagePath);
|
||||
arr.append(o);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
template <typename KeyT>
|
||||
bool boolKeysFromJson(const QJsonValue& v, QVector<KeyT>& out) {
|
||||
out.clear();
|
||||
if (!v.isArray()) return true;
|
||||
const QJsonArray arr = v.toArray();
|
||||
out.reserve(arr.size());
|
||||
for (const auto& it : arr) {
|
||||
if (!it.isObject()) continue;
|
||||
const QJsonObject o = it.toObject();
|
||||
KeyT k;
|
||||
k.frame = o.value("frame").toInt(0);
|
||||
k.value = o.value("value").toBool(true);
|
||||
out.push_back(k);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool vec2KeysFromJson(const QJsonValue& v, QVector<Project::Entity::KeyframeVec2>& out) {
|
||||
out.clear();
|
||||
if (!v.isArray()) return true;
|
||||
const QJsonArray arr = v.toArray();
|
||||
out.reserve(arr.size());
|
||||
for (const auto& it : arr) {
|
||||
if (!it.isObject()) continue;
|
||||
const QJsonObject o = it.toObject();
|
||||
Project::Entity::KeyframeVec2 k;
|
||||
k.frame = o.value("frame").toInt(0);
|
||||
k.value = QPointF(o.value("x").toDouble(0.0), o.value("y").toDouble(0.0));
|
||||
out.push_back(k);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool floatKeysFromJson(const QJsonValue& v, QVector<Project::Entity::KeyframeFloat01>& out) {
|
||||
out.clear();
|
||||
if (!v.isArray()) return true;
|
||||
const QJsonArray arr = v.toArray();
|
||||
out.reserve(arr.size());
|
||||
for (const auto& it : arr) {
|
||||
if (!it.isObject()) continue;
|
||||
const QJsonObject o = it.toObject();
|
||||
Project::Entity::KeyframeFloat01 k;
|
||||
k.frame = o.value("frame").toInt(0);
|
||||
k.value = o.value("value").toDouble(0.5);
|
||||
out.push_back(k);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool doubleKeysFromJson(const QJsonValue& v, QVector<Project::Entity::KeyframeDouble>& out) {
|
||||
out.clear();
|
||||
if (!v.isArray()) return true;
|
||||
const QJsonArray arr = v.toArray();
|
||||
out.reserve(arr.size());
|
||||
for (const auto& it : arr) {
|
||||
if (!it.isObject()) continue;
|
||||
const QJsonObject o = it.toObject();
|
||||
Project::Entity::KeyframeDouble k;
|
||||
k.frame = o.value("frame").toInt(0);
|
||||
k.value = o.value("value").toDouble(1.0);
|
||||
out.push_back(k);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool imageFramesFromJson(const QJsonValue& v, QVector<Project::Entity::ImageFrame>& out) {
|
||||
out.clear();
|
||||
if (!v.isArray()) return true;
|
||||
const QJsonArray arr = v.toArray();
|
||||
out.reserve(arr.size());
|
||||
for (const auto& it : arr) {
|
||||
if (!it.isObject()) continue;
|
||||
const QJsonObject o = it.toObject();
|
||||
Project::Entity::ImageFrame k;
|
||||
k.frame = o.value("frame").toInt(0);
|
||||
k.imagePath = o.value("path").toString();
|
||||
out.push_back(k);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
QJsonObject introToJson(const core::EntityIntroContent& intro) {
|
||||
QJsonObject o;
|
||||
o.insert("title", intro.title);
|
||||
o.insert("bodyText", intro.bodyText);
|
||||
o.insert("videoPathRelative", intro.videoPathRelative);
|
||||
QJsonArray imgs;
|
||||
for (const auto& p : intro.imagePathsRelative) imgs.append(p);
|
||||
o.insert("imagePathsRelative", imgs);
|
||||
return o;
|
||||
}
|
||||
|
||||
bool introFromJson(const QJsonObject& o, core::EntityIntroContent& out) {
|
||||
out = core::EntityIntroContent{};
|
||||
out.title = o.value("title").toString();
|
||||
out.bodyText = o.value("bodyText").toString();
|
||||
out.videoPathRelative = o.value("videoPathRelative").toString();
|
||||
const QJsonValue imgsVal = o.value("imagePathsRelative");
|
||||
if (imgsVal.isArray()) {
|
||||
for (const auto& it : imgsVal.toArray()) {
|
||||
const QString p = it.toString();
|
||||
if (!p.isEmpty()) out.imagePathsRelative.push_back(p);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QString EntityBundleStore::bundleRelativeDir(const QString& entityId) {
|
||||
if (entityId.isEmpty()) return {};
|
||||
return QStringLiteral("assets/entities/%1").arg(entityId);
|
||||
}
|
||||
|
||||
bool EntityBundleStore::save(const QString& projectDirAbs, const Project::Entity& entity) {
|
||||
if (projectDirAbs.isEmpty() || entity.id.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
const QString relDir = bundleRelativeDir(entity.id);
|
||||
if (relDir.isEmpty()) return false;
|
||||
const QString absDir = QDir(projectDirAbs).filePath(relDir);
|
||||
QDir().mkpath(absDir);
|
||||
|
||||
// meta.json
|
||||
QJsonObject meta;
|
||||
meta.insert("id", entity.id);
|
||||
meta.insert("displayName", entity.displayName);
|
||||
meta.insert("visible", entity.visible);
|
||||
meta.insert("originX", entity.originWorld.x());
|
||||
meta.insert("originY", entity.originWorld.y());
|
||||
meta.insert("depth", entity.depth);
|
||||
meta.insert("priority", entity.priority);
|
||||
meta.insert("imagePath", entity.imagePath);
|
||||
meta.insert("imageTopLeftX", entity.imageTopLeftWorld.x());
|
||||
meta.insert("imageTopLeftY", entity.imageTopLeftWorld.y());
|
||||
meta.insert("userScale", entity.userScale);
|
||||
meta.insert("distanceScaleCalibMult", entity.distanceScaleCalibMult);
|
||||
meta.insert("ignoreDistanceScale", entity.ignoreDistanceScale);
|
||||
meta.insert("parentId", entity.parentId);
|
||||
meta.insert("parentOffsetX", entity.parentOffsetWorld.x());
|
||||
meta.insert("parentOffsetY", entity.parentOffsetWorld.y());
|
||||
|
||||
meta.insert("polygonLocal", pointsToJson(entity.polygonLocal));
|
||||
meta.insert("cutoutPolygonWorld", pointsToJson(entity.cutoutPolygonWorld));
|
||||
|
||||
meta.insert("blackholeId", entity.blackholeId);
|
||||
meta.insert("blackholeVisible", entity.blackholeVisible);
|
||||
meta.insert("blackholeResolvedBy", entity.blackholeResolvedBy);
|
||||
meta.insert("blackholeOverlayPath", entity.blackholeOverlayPath);
|
||||
meta.insert("blackholeOverlayRectX", entity.blackholeOverlayRect.x());
|
||||
meta.insert("blackholeOverlayRectY", entity.blackholeOverlayRect.y());
|
||||
meta.insert("blackholeOverlayRectW", entity.blackholeOverlayRect.width());
|
||||
meta.insert("blackholeOverlayRectH", entity.blackholeOverlayRect.height());
|
||||
|
||||
if (!writeJsonAtomic(QDir(absDir).filePath(QStringLiteral("meta.json")), meta, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// anim.json
|
||||
QJsonObject anim;
|
||||
anim.insert("locationKeys", vec2KeysToJson(entity.locationKeys));
|
||||
anim.insert("depthScaleKeys", floatKeysToJson(entity.depthScaleKeys));
|
||||
anim.insert("userScaleKeys", doubleKeysToJson(entity.userScaleKeys));
|
||||
anim.insert("visibilityKeys", boolKeysToJson<Project::ToolKeyframeBool>(entity.visibilityKeys));
|
||||
anim.insert("imageFrames", imageFramesToJson(entity.imageFrames));
|
||||
if (!writeJsonAtomic(QDir(absDir).filePath(QStringLiteral("anim.json")), anim, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// intro.json
|
||||
if (!writeJsonAtomic(QDir(absDir).filePath(QStringLiteral("intro.json")), introToJson(entity.intro), true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// default.png (可为空)
|
||||
const QString pngAbs = QDir(absDir).filePath(QStringLiteral("default.png"));
|
||||
if (!entity.defaultImagePng.isEmpty()) {
|
||||
if (!writeBytesAtomic(pngAbs, entity.defaultImagePng)) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// 清空时删掉,避免读到旧图
|
||||
if (QFileInfo::exists(pngAbs)) {
|
||||
QFile::remove(pngAbs);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool EntityBundleStore::load(const QString& projectDirAbs, Project::Entity& inOutEntity) {
|
||||
if (projectDirAbs.isEmpty() || inOutEntity.id.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
const QString relDir = inOutEntity.entityPayloadPath;
|
||||
if (relDir.isEmpty()) return false;
|
||||
const QString absDir = QDir(projectDirAbs).filePath(relDir);
|
||||
if (!QFileInfo(absDir).exists()) return false;
|
||||
|
||||
QJsonObject meta;
|
||||
if (!readJsonObject(QDir(absDir).filePath(QStringLiteral("meta.json")), meta)) {
|
||||
return false;
|
||||
}
|
||||
const QString id = meta.value("id").toString();
|
||||
if (id.isEmpty() || id != inOutEntity.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Project::Entity e = inOutEntity; // 保留 stub:id/payloadPath/visible 等必要字段会被覆盖
|
||||
e.displayName = meta.value("displayName").toString();
|
||||
e.visible = meta.value("visible").toBool(true);
|
||||
e.originWorld = QPointF(meta.value("originX").toDouble(0.0), meta.value("originY").toDouble(0.0));
|
||||
e.depth = meta.value("depth").toInt(0);
|
||||
e.priority = meta.value("priority").toInt(1);
|
||||
e.imagePath = meta.value("imagePath").toString();
|
||||
e.imageTopLeftWorld = QPointF(meta.value("imageTopLeftX").toDouble(0.0), meta.value("imageTopLeftY").toDouble(0.0));
|
||||
e.userScale = meta.value("userScale").toDouble(1.0);
|
||||
e.distanceScaleCalibMult = meta.value("distanceScaleCalibMult").toDouble(0.0);
|
||||
e.ignoreDistanceScale = meta.value("ignoreDistanceScale").toBool(false);
|
||||
e.parentId = meta.value("parentId").toString();
|
||||
e.parentOffsetWorld = QPointF(meta.value("parentOffsetX").toDouble(0.0), meta.value("parentOffsetY").toDouble(0.0));
|
||||
|
||||
pointsFromJson(meta.value("polygonLocal"), e.polygonLocal);
|
||||
pointsFromJson(meta.value("cutoutPolygonWorld"), e.cutoutPolygonWorld);
|
||||
|
||||
e.blackholeId = meta.value("blackholeId").toString();
|
||||
e.blackholeVisible = meta.value("blackholeVisible").toBool(true);
|
||||
e.blackholeResolvedBy = meta.value("blackholeResolvedBy").toString();
|
||||
e.blackholeOverlayPath = meta.value("blackholeOverlayPath").toString();
|
||||
e.blackholeOverlayRect = QRect(meta.value("blackholeOverlayRectX").toInt(0),
|
||||
meta.value("blackholeOverlayRectY").toInt(0),
|
||||
meta.value("blackholeOverlayRectW").toInt(0),
|
||||
meta.value("blackholeOverlayRectH").toInt(0));
|
||||
|
||||
QJsonObject anim;
|
||||
if (!readJsonObject(QDir(absDir).filePath(QStringLiteral("anim.json")), anim)) {
|
||||
return false;
|
||||
}
|
||||
vec2KeysFromJson(anim.value("locationKeys"), e.locationKeys);
|
||||
floatKeysFromJson(anim.value("depthScaleKeys"), e.depthScaleKeys);
|
||||
doubleKeysFromJson(anim.value("userScaleKeys"), e.userScaleKeys);
|
||||
boolKeysFromJson<Project::ToolKeyframeBool>(anim.value("visibilityKeys"), e.visibilityKeys);
|
||||
imageFramesFromJson(anim.value("imageFrames"), e.imageFrames);
|
||||
|
||||
QJsonObject intro;
|
||||
if (!readJsonObject(QDir(absDir).filePath(QStringLiteral("intro.json")), intro)) {
|
||||
return false;
|
||||
}
|
||||
introFromJson(intro, e.intro);
|
||||
|
||||
// default.png
|
||||
const QString pngAbs = QDir(absDir).filePath(QStringLiteral("default.png"));
|
||||
if (QFileInfo::exists(pngAbs)) {
|
||||
QFile f(pngAbs);
|
||||
if (f.open(QIODevice::ReadOnly)) {
|
||||
e.defaultImagePng = f.readAll();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
e.defaultImagePng.clear();
|
||||
}
|
||||
|
||||
inOutEntity = std::move(e);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace core::persistence
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include "domain/Project.h"
|
||||
|
||||
#include <QString>
|
||||
|
||||
namespace core::persistence {
|
||||
|
||||
/// 实体 payload 的目录 bundle 结构(v7 起):
|
||||
/// assets/entities/<id>/
|
||||
/// meta.json // 静态字段(几何/层级/黑洞等)
|
||||
/// anim.json // 关键帧轨道(location/depthScale/userScale/visibility/priority/sprite frames)
|
||||
/// intro.json // intro
|
||||
/// default.png // 默认贴图(可为空)
|
||||
struct EntityBundleStore {
|
||||
static QString bundleRelativeDir(const QString& entityId);
|
||||
|
||||
static bool save(const QString& projectDirAbs, const Project::Entity& entity);
|
||||
static bool load(const QString& projectDirAbs, Project::Entity& inOutEntity);
|
||||
};
|
||||
|
||||
} // namespace core::persistence
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
#include <QDataStream>
|
||||
#include <QFile>
|
||||
#include <QRect>
|
||||
#include <QtGlobal>
|
||||
|
||||
#include <algorithm>
|
||||
@@ -235,7 +236,9 @@ public:
|
||||
const Project::Entity& entity = *m_src;
|
||||
ds << entity.id;
|
||||
ds << qint32(entity.depth);
|
||||
ds << qint32(entity.priority);
|
||||
ds << entity.imagePath;
|
||||
ds << entity.defaultImagePng;
|
||||
ds << double(entity.originWorld.x()) << double(entity.originWorld.y());
|
||||
ds << double(entity.imageTopLeftWorld.x()) << double(entity.imageTopLeftWorld.y());
|
||||
|
||||
@@ -249,7 +252,6 @@ public:
|
||||
ds << double(pt.x()) << double(pt.y());
|
||||
}
|
||||
|
||||
writeAnimationBlock(ds, entity, true);
|
||||
ds << entity.displayName << double(entity.userScale) << double(entity.distanceScaleCalibMult);
|
||||
ds << bool(entity.ignoreDistanceScale);
|
||||
ds << entity.parentId;
|
||||
@@ -267,14 +269,65 @@ public:
|
||||
: entity.blackholeId;
|
||||
ds << holeId;
|
||||
ds << entity.blackholeResolvedBy;
|
||||
ds << entity.blackholeOverlayPath;
|
||||
ds << qint32(entity.blackholeOverlayRect.x()) << qint32(entity.blackholeOverlayRect.y())
|
||||
<< qint32(entity.blackholeOverlayRect.width()) << qint32(entity.blackholeOverlayRect.height());
|
||||
}
|
||||
|
||||
bool readBody(QDataStream& ds) override {
|
||||
Q_ASSERT(m_dst != nullptr);
|
||||
const quint32 fileVer = loadedRecordFormatVersion();
|
||||
Project::Entity tmp;
|
||||
if (!readEntityPayloadV1(ds, tmp, true)) {
|
||||
ds >> tmp.id;
|
||||
qint32 depth = 0;
|
||||
ds >> depth;
|
||||
tmp.depth = static_cast<int>(depth);
|
||||
qint32 pri = 1;
|
||||
if (fileVer >= 12) {
|
||||
ds >> pri;
|
||||
}
|
||||
tmp.priority = int(pri);
|
||||
ds >> tmp.imagePath;
|
||||
if (fileVer >= 13) {
|
||||
ds >> tmp.defaultImagePng;
|
||||
} else {
|
||||
tmp.defaultImagePng.clear();
|
||||
}
|
||||
double ox = 0.0;
|
||||
double oy = 0.0;
|
||||
double itlx = 0.0;
|
||||
double itly = 0.0;
|
||||
ds >> ox >> oy >> itlx >> itly;
|
||||
tmp.originWorld = QPointF(ox, oy);
|
||||
tmp.imageTopLeftWorld = QPointF(itlx, itly);
|
||||
|
||||
qint32 nLocal = 0;
|
||||
ds >> nLocal;
|
||||
if (ds.status() != QDataStream::Ok || nLocal < 0 || nLocal > 1000000) return false;
|
||||
tmp.polygonLocal.reserve(nLocal);
|
||||
for (qint32 i = 0; i < nLocal; ++i) {
|
||||
double x = 0.0;
|
||||
double y = 0.0;
|
||||
ds >> x >> y;
|
||||
if (ds.status() != QDataStream::Ok) return false;
|
||||
tmp.polygonLocal.push_back(QPointF(x, y));
|
||||
}
|
||||
|
||||
qint32 nCut = 0;
|
||||
ds >> nCut;
|
||||
if (ds.status() != QDataStream::Ok || nCut < 0 || nCut > 1000000) return false;
|
||||
tmp.cutoutPolygonWorld.reserve(nCut);
|
||||
for (qint32 i = 0; i < nCut; ++i) {
|
||||
double x = 0.0;
|
||||
double y = 0.0;
|
||||
ds >> x >> y;
|
||||
if (ds.status() != QDataStream::Ok) return false;
|
||||
tmp.cutoutPolygonWorld.push_back(QPointF(x, y));
|
||||
}
|
||||
if (tmp.id.isEmpty() || tmp.polygonLocal.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QString dn;
|
||||
double us = 1.0;
|
||||
double cal = 0.0;
|
||||
@@ -298,27 +351,23 @@ public:
|
||||
tmp.parentOffsetWorld = QPointF(pox, poy);
|
||||
|
||||
// v7:实体可见性关键帧
|
||||
tmp.visibilityKeys.clear();
|
||||
qint32 nVis = 0;
|
||||
ds >> nVis;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
if (ds.status() != QDataStream::Ok || nVis < 0 || nVis > 1000000) {
|
||||
return false;
|
||||
}
|
||||
tmp.visibilityKeys.clear();
|
||||
if (nVis > 0) {
|
||||
tmp.visibilityKeys.reserve(nVis);
|
||||
for (qint32 i = 0; i < nVis; ++i) {
|
||||
qint32 fr = 0;
|
||||
bool val = true;
|
||||
ds >> fr >> val;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
core::Project::ToolKeyframeBool k;
|
||||
k.frame = int(fr);
|
||||
k.value = val;
|
||||
tmp.visibilityKeys.push_back(k);
|
||||
tmp.visibilityKeys.reserve(nVis);
|
||||
for (qint32 i = 0; i < nVis; ++i) {
|
||||
qint32 fr = 0;
|
||||
bool vis = true;
|
||||
ds >> fr >> vis;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
tmp.visibilityKeys.push_back(Project::ToolKeyframeBool{int(fr), vis});
|
||||
}
|
||||
|
||||
if (!readIntroBlock(ds, tmp.intro)) {
|
||||
return false;
|
||||
}
|
||||
@@ -332,6 +381,17 @@ public:
|
||||
tmp.blackholeVisible = holeVisible;
|
||||
tmp.blackholeId = holeId.isEmpty() ? QStringLiteral("blackhole-%1").arg(tmp.id) : holeId;
|
||||
tmp.blackholeResolvedBy = resolvedBy;
|
||||
QString overlayPath;
|
||||
qint32 rectX = 0;
|
||||
qint32 rectY = 0;
|
||||
qint32 ow = 0;
|
||||
qint32 oh = 0;
|
||||
ds >> overlayPath >> rectX >> rectY >> ow >> oh;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
tmp.blackholeOverlayPath = overlayPath;
|
||||
tmp.blackholeOverlayRect = QRect(int(rectX), int(rectY), int(ow), int(oh));
|
||||
*m_dst = std::move(tmp);
|
||||
return true;
|
||||
}
|
||||
@@ -376,126 +436,7 @@ bool EntityPayloadBinary::save(const QString& absolutePath, const Project::Entit
|
||||
}
|
||||
|
||||
bool EntityPayloadBinary::load(const QString& absolutePath, Project::Entity& entity) {
|
||||
QFile f(absolutePath);
|
||||
if (!f.open(QIODevice::ReadOnly)) {
|
||||
return false;
|
||||
}
|
||||
QDataStream ds(&f);
|
||||
ds.setVersion(QDataStream::Qt_5_15);
|
||||
quint32 magic = 0;
|
||||
quint32 ver = 0;
|
||||
ds >> magic >> ver;
|
||||
if (ds.status() != QDataStream::Ok || magic != kMagicPayload) {
|
||||
return false;
|
||||
}
|
||||
if (ver != 1 && ver != 2 && ver != 3 && ver != 4 && ver != 5 && ver != 6 && ver != 7 && ver != 8 && ver != 9) {
|
||||
return false;
|
||||
}
|
||||
Project::Entity tmp;
|
||||
if (!readEntityPayloadV1(ds, tmp, ver >= 3)) {
|
||||
return false;
|
||||
}
|
||||
if (ver >= 2) {
|
||||
QString dn;
|
||||
double us = 1.0;
|
||||
ds >> dn >> us;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
tmp.displayName = dn;
|
||||
tmp.userScale = std::clamp(us, 1e-3, 1e3);
|
||||
if (ver >= 4) {
|
||||
double cal = 0.0;
|
||||
ds >> cal;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
tmp.distanceScaleCalibMult = (cal > 0.0) ? std::clamp(cal, 1e-6, 10.0) : 0.0;
|
||||
}
|
||||
if (ver >= 6) {
|
||||
bool ign = false;
|
||||
QString pid;
|
||||
double pox = 0.0;
|
||||
double poy = 0.0;
|
||||
ds >> ign >> pid >> pox >> poy;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
tmp.ignoreDistanceScale = ign;
|
||||
tmp.parentId = pid;
|
||||
tmp.parentOffsetWorld = QPointF(pox, poy);
|
||||
} else {
|
||||
tmp.ignoreDistanceScale = false;
|
||||
tmp.parentId.clear();
|
||||
tmp.parentOffsetWorld = QPointF();
|
||||
}
|
||||
if (ver >= 7) {
|
||||
qint32 nVis = 0;
|
||||
ds >> nVis;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
tmp.visibilityKeys.clear();
|
||||
if (nVis > 0) {
|
||||
tmp.visibilityKeys.reserve(nVis);
|
||||
for (qint32 i = 0; i < nVis; ++i) {
|
||||
qint32 fr = 0;
|
||||
bool val = true;
|
||||
ds >> fr >> val;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
core::Project::ToolKeyframeBool k;
|
||||
k.frame = int(fr);
|
||||
k.value = val;
|
||||
tmp.visibilityKeys.push_back(k);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tmp.visibilityKeys.clear();
|
||||
}
|
||||
if (ver >= 5) {
|
||||
if (!readIntroBlock(ds, tmp.intro)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (ver >= 8) {
|
||||
bool holeVisible = true;
|
||||
QString holeId;
|
||||
ds >> holeVisible >> holeId;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
tmp.blackholeVisible = holeVisible;
|
||||
tmp.blackholeId = holeId.isEmpty() ? QStringLiteral("blackhole-%1").arg(tmp.id) : holeId;
|
||||
if (ver >= 9) {
|
||||
QString resolvedBy;
|
||||
ds >> resolvedBy;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
tmp.blackholeResolvedBy = resolvedBy;
|
||||
} else {
|
||||
tmp.blackholeResolvedBy = QStringLiteral("pending");
|
||||
}
|
||||
} else {
|
||||
tmp.blackholeVisible = true;
|
||||
tmp.blackholeId = QStringLiteral("blackhole-%1").arg(tmp.id);
|
||||
tmp.blackholeResolvedBy = QStringLiteral("pending");
|
||||
}
|
||||
} else {
|
||||
tmp.displayName.clear();
|
||||
tmp.userScale = 1.0;
|
||||
tmp.ignoreDistanceScale = false;
|
||||
tmp.parentId.clear();
|
||||
tmp.parentOffsetWorld = QPointF();
|
||||
tmp.visibilityKeys.clear();
|
||||
tmp.blackholeVisible = true;
|
||||
tmp.blackholeId = QStringLiteral("blackhole-%1").arg(tmp.id);
|
||||
tmp.blackholeResolvedBy = QStringLiteral("pending");
|
||||
}
|
||||
entity = std::move(tmp);
|
||||
return true;
|
||||
return EntityBinaryRecord(entity).loadFromFile(absolutePath);
|
||||
}
|
||||
|
||||
bool EntityPayloadBinary::loadLegacyAnimFile(const QString& absolutePath, Project::Entity& entity) {
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
namespace core {
|
||||
|
||||
// 实体完整数据(几何 + 贴图路径 + 动画轨道)的二进制格式,与 project.json v2 的 payload 字段对应。
|
||||
// 贴图 PNG 仍单独存放在 assets/entities/,本文件不嵌入像素。
|
||||
// 默认贴图 PNG 存在本文件中(PNG bytes),不兼容旧路径工程。
|
||||
// 具体读写通过继承 PersistentBinaryObject 的适配器类完成(见 EntityPayloadBinary.cpp)。
|
||||
class EntityPayloadBinary {
|
||||
public:
|
||||
static constexpr quint32 kMagicPayload = 0x48464550; // 'HFEP'
|
||||
static constexpr quint32 kPayloadVersion = 9; // v9:追加 blackholeResolvedBy
|
||||
static constexpr quint32 kPayloadVersion = 13; // v13:默认贴图存 PNG bytes
|
||||
|
||||
// 旧版独立动画文件(仍用于打开 v1 项目时合并)
|
||||
static constexpr quint32 kMagicLegacyAnim = 0x48465441; // 'HFTA'
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
#include "persistence/JsonFileAtomic.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
|
||||
namespace core::persistence {
|
||||
|
||||
bool writeBytesAtomic(const QString& absolutePath, const QByteArray& bytes) {
|
||||
if (absolutePath.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
const QString parent = QFileInfo(absolutePath).absolutePath();
|
||||
if (!QFileInfo(parent).exists()) {
|
||||
QDir().mkpath(parent);
|
||||
}
|
||||
const QString tmpAbs = absolutePath + QStringLiteral(".tmp");
|
||||
if (QFileInfo::exists(tmpAbs)) {
|
||||
QFile::remove(tmpAbs);
|
||||
}
|
||||
QFile f(tmpAbs);
|
||||
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||
return false;
|
||||
}
|
||||
const bool ok = f.write(bytes) == bytes.size();
|
||||
f.close();
|
||||
if (!ok || f.error() != QFile::NoError) {
|
||||
QFile::remove(tmpAbs);
|
||||
return false;
|
||||
}
|
||||
QFile::remove(absolutePath);
|
||||
if (!QFile::rename(tmpAbs, absolutePath)) {
|
||||
QFile::remove(tmpAbs);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool writeJsonAtomic(const QString& absolutePath, const QJsonObject& obj, bool pretty) {
|
||||
const QJsonDocument doc(obj);
|
||||
const QByteArray bytes = doc.toJson(pretty ? QJsonDocument::Indented : QJsonDocument::Compact);
|
||||
return writeBytesAtomic(absolutePath, bytes);
|
||||
}
|
||||
|
||||
bool readJsonObject(const QString& absolutePath, QJsonObject& out) {
|
||||
out = QJsonObject();
|
||||
QFile f(absolutePath);
|
||||
if (!f.open(QIODevice::ReadOnly)) {
|
||||
return false;
|
||||
}
|
||||
const QByteArray data = f.readAll();
|
||||
QJsonParseError err;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(data, &err);
|
||||
if (err.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
return false;
|
||||
}
|
||||
out = doc.object();
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace core::persistence
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
|
||||
namespace core::persistence {
|
||||
|
||||
/// 原子写 JSON:写入 .tmp 后 rename 覆盖,避免截断导致下次打开“损坏”。
|
||||
bool writeJsonAtomic(const QString& absolutePath, const QJsonObject& obj, bool pretty = true);
|
||||
|
||||
/// 读取 JSON 文件(必须为 object)。失败返回 false。
|
||||
bool readJsonObject(const QString& absolutePath, QJsonObject& out);
|
||||
|
||||
/// 原子写 bytes:写入 .tmp 后 rename 覆盖。
|
||||
bool writeBytesAtomic(const QString& absolutePath, const QByteArray& bytes);
|
||||
|
||||
} // namespace core::persistence
|
||||
|
||||
@@ -48,9 +48,11 @@ bool PersistentBinaryObject::loadFromFile(const QString& absolutePath) {
|
||||
quint32 magic = 0;
|
||||
quint32 version = 0;
|
||||
ds >> magic >> version;
|
||||
if (ds.status() != QDataStream::Ok || magic != recordMagic() || version != recordFormatVersion()) {
|
||||
const quint32 cur = recordFormatVersion();
|
||||
if (ds.status() != QDataStream::Ok || magic != recordMagic() || version < 1 || version > cur) {
|
||||
return false;
|
||||
}
|
||||
m_loadedVersion = version;
|
||||
return readBody(ds);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,12 @@ protected:
|
||||
virtual quint32 recordFormatVersion() const = 0;
|
||||
virtual void writeBody(QDataStream& ds) const = 0;
|
||||
virtual bool readBody(QDataStream& ds) = 0;
|
||||
|
||||
/// 读取文件时解析到的版本号(<= recordFormatVersion)。用于向后兼容读取分支。
|
||||
[[nodiscard]] quint32 loadedRecordFormatVersion() const { return m_loadedVersion; }
|
||||
|
||||
private:
|
||||
quint32 m_loadedVersion = 0;
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
|
||||
Reference in New Issue
Block a user