Files
hfut-bishe/client/core/workspace/ProjectWorkspace.cpp
T
2026-05-15 00:46:37 +08:00

4370 lines
149 KiB
C++

/*
*/
#include "workspace/ProjectWorkspace.h"
#include "image/ImageDecodeConfig.h"
#include "image/ImageFileLoader.h"
#include "large_image/VipsBackend.h"
#include "animation/AnimationEvaluator.h"
#include "animation/AnimationSampling.h"
#include "eval/ProjectEvaluator.h"
#include "persistence/AnimationBinary.h"
#include "persistence/EntityPayloadBinary.h"
#include "persistence/AnimationBundleStore.h"
#include "persistence/EntityBundleStore.h"
#include "persistence/JsonFileAtomic.h"
#include "persistence/AsyncProjectWriter.h"
#include "depth/DepthService.h"
#include "net/ModelServerClient.h"
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonArray>
#include <QJsonObject>
#include <QJsonValue>
#include <QBuffer>
#include <QImage>
#include <QImageWriter>
#include <QImageReader>
#include <QUrl>
#include <QHash>
#include <QSet>
#include <QPainter>
#include <QPainterPath>
#include <QTransform>
#include <QPolygonF>
#include <QRect>
#include <cmath>
#include <algorithm>
namespace core {
namespace {
static bool isNoneAnimationActive(const Project& project) {
return project.activeAnimationId() == QStringLiteral("none");
}
static bool presentationHotspotsEqual(const QVector<Project::PresentationHotspot>& a,
const QVector<Project::PresentationHotspot>& b) {
if (a.size() != b.size())
return false;
for (int i = 0; i < a.size(); ++i) {
if (a[i].id != b[i].id || a[i].targetAnimationId != b[i].targetAnimationId)
return false;
if (!qFuzzyCompare(static_cast<double>(a[i].centerWorld.x()),
static_cast<double>(b[i].centerWorld.x())) ||
!qFuzzyCompare(static_cast<double>(a[i].centerWorld.y()),
static_cast<double>(b[i].centerWorld.y()))) {
return false;
}
}
return true;
}
static QString firstNonNoneAnimationId(const Project& p) {
for (const auto& a : p.animations()) {
if (a.id != QStringLiteral("none"))
return a.id;
}
return {};
}
/// 移除热点里指向不存在动画方案的 targetAnimationId(回退为第一项非 none,若无则清空)
static void sanitizePresentationHotspotTargets(Project& p) {
const QString fb = firstNonNoneAnimationId(p);
QVector<Project::PresentationHotspot> hs = p.presentationHotspots();
bool changed = false;
for (auto& h : hs) {
if (h.targetAnimationId.isEmpty() || h.targetAnimationId == QStringLiteral("none"))
continue;
if (!p.findAnimationById(h.targetAnimationId)) {
h.targetAnimationId = fb;
changed = true;
}
}
if (changed)
p.setPresentationHotspots(hs);
}
QString sanitizedEntityIdForAsset(const QString& id) {
QString s = id;
const QString bad = QStringLiteral("\\/:*?\"<>|");
for (QChar c : bad) {
s.replace(c, QLatin1Char('_'));
}
return s.isEmpty() ? QStringLiteral("entity") : s;
}
bool writeBlackholeOverlayPng(const QString& projectDir, const QString& entityId,
const QString& previousOverlayRel, const QImage& patch,
const QRect& rectBg, QString* outRelativePath) {
if (projectDir.isEmpty() || patch.isNull() || !rectBg.isValid() || rectBg.width() <= 0 ||
rectBg.height() <= 0 || !outRelativePath) {
return false;
}
QImage p = patch;
if (p.size() != rectBg.size()) {
p = p.scaled(rectBg.size(), Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
}
if (p.format() != QImage::Format_ARGB32_Premultiplied) {
p = p.convertToFormat(QImage::Format_ARGB32_Premultiplied);
}
const QString rel =
QStringLiteral("assets/blackhole_overlays/overlay-%1.png").arg(sanitizedEntityIdForAsset(entityId));
const QString abs = QDir(projectDir).filePath(rel);
QDir().mkpath(QFileInfo(abs).absolutePath());
if (!previousOverlayRel.isEmpty() && previousOverlayRel != rel) {
QFile::remove(QDir(projectDir).filePath(previousOverlayRel));
}
QImageWriter writer(abs);
writer.setFormat(QByteArrayLiteral("png"));
writer.setCompression(1);
if (!writer.write(p)) {
return false;
}
*outRelativePath = rel;
return true;
}
bool entityStaticEquals(const Project::Entity& a, const Project::Entity& b) {
return a.id == b.id &&
a.displayName == b.displayName &&
a.visible == b.visible &&
a.polygonLocal == b.polygonLocal &&
a.cutoutPolygonWorld == b.cutoutPolygonWorld &&
a.blackholeId == b.blackholeId &&
a.blackholeVisible == b.blackholeVisible &&
a.blackholeResolvedBy == b.blackholeResolvedBy &&
a.blackholeOverlayPath == b.blackholeOverlayPath &&
a.blackholeOverlayRect == b.blackholeOverlayRect &&
a.originWorld == b.originWorld &&
a.depth == b.depth &&
a.priority == b.priority &&
a.imagePath == b.imagePath &&
a.imageTopLeftWorld == b.imageTopLeftWorld &&
qFuzzyCompare(a.userScale + 1.0, b.userScale + 1.0) &&
qFuzzyCompare(a.distanceScaleCalibMult + 1.0, b.distanceScaleCalibMult + 1.0) &&
a.ignoreDistanceScale == b.ignoreDistanceScale &&
a.parentId == b.parentId &&
a.parentOffsetWorld == b.parentOffsetWorld &&
a.entityPayloadPath == b.entityPayloadPath &&
a.intro.title == b.intro.title &&
a.intro.bodyText == b.intro.bodyText &&
a.intro.imagePathsRelative == b.intro.imagePathsRelative &&
a.intro.videoPathRelative == b.intro.videoPathRelative;
}
//
void removeBlackholeOverlayFile(const QString& projectDir, const QString& rel) {
if (projectDir.isEmpty() || rel.isEmpty()) {
return;
}
QFile::remove(QDir(projectDir).filePath(rel));
}
QPointF polygonCentroidFromWorldPoints(const QVector<QPointF>& poly) {
if (poly.size() < 3) {
return poly.isEmpty() ? QPointF() : poly.front();
}
double a2 = 0.0;
double cx6a = 0.0;
double cy6a = 0.0;
for (int i = 0; i < poly.size(); ++i) {
const QPointF p0 = poly[i];
const QPointF p1 = poly[(i + 1) % poly.size()];
const double cross = static_cast<double>(p0.x()) * static_cast<double>(p1.y()) -
static_cast<double>(p1.x()) * static_cast<double>(p0.y());
a2 += cross;
cx6a += (static_cast<double>(p0.x()) + static_cast<double>(p1.x())) * cross;
cy6a += (static_cast<double>(p0.y()) + static_cast<double>(p1.y())) * cross;
}
if (std::abs(a2) < 1e-6) {
double minX = poly[0].x();
double minY = poly[0].y();
double maxX = minX;
double maxY = minY;
for (const QPointF& p : poly) {
minX = std::min(minX, static_cast<double>(p.x()));
minY = std::min(minY, static_cast<double>(p.y()));
maxX = std::max(maxX, static_cast<double>(p.x()));
maxY = std::max(maxY, static_cast<double>(p.y()));
}
return QPointF(0.5 * (minX + maxX), 0.5 * (minY + maxY));
}
const double inv6a = 1.0 / (3.0 * a2);
return QPointF(cx6a * inv6a, cy6a * inv6a);
}
QPointF resolvedOriginAtFrame(const Project& project, const QString& id, int frame) {
if (id.isEmpty()) {
return QPointF();
}
const auto rf = core::eval::evaluateAtFrame(project, frame, 10);
for (const auto& re : rf.entities) {
if (re.entity.id == id) {
return re.entity.originWorld;
}
}
for (const auto& rt : rf.tools) {
if (rt.tool.id == id) {
return rt.tool.originWorld;
}
}
return QPointF();
}
double depthToScale01Ws(int depthZ) {
const int d = std::clamp(depthZ, 0, 255);
return static_cast<double>(d) / 255.0;
}
double distanceScaleFromDepth01Ws(double depth01, double calibMult) {
const double d = std::clamp(depth01, 0.0, 1.0);
const double raw = 0.5 + d * 1.0;
if (calibMult > 0.0) {
return raw / std::max(calibMult, 1e-6);
}
return raw;
}
// scale = distanceScale * userScale
double combinedVisualScaleForEntity(const Project::Entity& e, int frame) {
const double ds01 = depthToScale01Ws(e.depth);
const double userScaleAnimated =
core::sampleUserScale(e.userScaleKeys, frame, e.userScale, core::KeyInterpolation::Linear);
const double distScale =
e.ignoreDistanceScale ? 1.0 : distanceScaleFromDepth01Ws(ds01, e.distanceScaleCalibMult);
return distScale * std::max(1e-6, userScaleAnimated);
}
QPointF entityPolygonCentroidWorld(const Project& project, const Project::Entity& e, int frame, double sTotal) {
const QPointF O = resolvedOriginAtFrame(project, e.id, frame);
QVector<QPointF> w;
w.reserve(e.polygonLocal.size());
for (const QPointF& lp : e.polygonLocal) {
w.push_back(O + lp * sTotal);
}
return polygonCentroidFromWorldPoints(w);
}
namespace {
/// 将采样原点与多边形形心对齐;保持世界几何与贴图不变(与 reanchorEntityPivot 同类变换)。
/// 有父级时跳过:基准量分布在 parentOffset/轨道上,需单独处理链式更新。
bool snapEntityOriginToCentroidInPlace(const Project& project, Project::Entity& e, int frame, double sTotal) {
if (!e.parentId.isEmpty()) {
return false;
}
if (e.polygonLocal.size() < 3 || sTotal <= 1e-9) {
return false;
}
const QPointF O_anim = resolvedOriginAtFrame(project, e.id, frame);
QVector<QPointF> polyWorld;
polyWorld.reserve(e.polygonLocal.size());
for (const QPointF& lp : e.polygonLocal) {
polyWorld.push_back(O_anim + lp * sTotal);
}
const QPointF C = polygonCentroidFromWorldPoints(polyWorld);
if (QLineF(O_anim, C).length() < 0.25) {
return false;
}
const QPointF I_disp = O_anim + (e.imageTopLeftWorld - e.originWorld) * sTotal;
const QPointF d = C - O_anim;
QVector<QPointF> newLocal;
newLocal.reserve(polyWorld.size());
for (const QPointF& p : polyWorld) {
newLocal.push_back((p - C) / sTotal);
}
for (auto& k : e.locationKeys) {
k.value += d;
}
e.originWorld += d;
e.polygonLocal = std::move(newLocal);
e.imageTopLeftWorld = e.originWorld + (I_disp - C) / sTotal;
return true;
}
} // namespace
QString ensureDir(const QString& path) {
QDir dir(path);
if (dir.exists()) {
return dir.absolutePath();
}
if (dir.mkpath(".")) {
return dir.absolutePath();
}
return {};
}
QString normalizedProjectDir(const QString& projectDir) {
QFileInfo fi(projectDir);
return QDir(fi.absoluteFilePath()).absolutePath();
}
QString sanitizeFolderName(QString name) {
name = name.trimmed();
if (name.isEmpty()) {
return QStringLiteral("project");
}
// 简单做法:把明显不适合作为文件夹名的字符替换为下划线
static const QString badChars = QStringLiteral("\\/:*?\"<>|");
for (const QChar& ch : badChars) {
name.replace(ch, QChar('_'));
}
return name;
}
QString pickUniqueSubdirPath(const QString& parentDir, const QString& baseName) {
const auto cleanedBase = sanitizeFolderName(baseName);
QDir parent(parentDir);
if (!parent.exists()) {
return {};
}
const QString first = parent.filePath(cleanedBase);
if (!QFileInfo::exists(first)) {
return first;
}
for (int i = 1; i < 10000; ++i) {
const QString cand = parent.filePath(QStringLiteral("%1_%2").arg(cleanedBase).arg(i));
if (!QFileInfo::exists(cand)) {
return cand;
}
}
return {};
}
QRect clampRectToImage(const QRect& rect, const QSize& size) {
QRect r = rect.normalized();
if (r.isNull() || r.width() <= 0 || r.height() <= 0) {
return {};
}
r.setLeft(std::max(0, r.left()));
r.setTop(std::max(0, r.top()));
r.setRight(std::min(size.width() - 1, r.right()));
r.setBottom(std::min(size.height() - 1, r.bottom()));
if (r.width() <= 0 || r.height() <= 0) {
return {};
}
return r;
}
static void upsertBoolKey(QVector<Project::ToolKeyframeBool>& keys, int frame, bool value) {
for (auto& k : keys) {
if (k.frame == frame) {
k.value = value;
return;
}
}
Project::ToolKeyframeBool k;
k.frame = frame;
k.value = value;
keys.push_back(k);
}
static QString animationTrackId(Project::AnimationTargetKind kind,
const QString& targetId,
Project::AnimationProperty property) {
auto kindName = [](Project::AnimationTargetKind k) {
switch (k) {
case Project::AnimationTargetKind::Entity: return QStringLiteral("entity");
case Project::AnimationTargetKind::Tool: return QStringLiteral("tool");
case Project::AnimationTargetKind::Camera: return QStringLiteral("camera");
}
return QStringLiteral("entity");
};
auto propName = [](Project::AnimationProperty p) {
switch (p) {
case Project::AnimationProperty::Position: return QStringLiteral("position");
case Project::AnimationProperty::UserScale: return QStringLiteral("userScale");
case Project::AnimationProperty::Sprite: return QStringLiteral("sprite");
case Project::AnimationProperty::Visibility: return QStringLiteral("visibility");
case Project::AnimationProperty::DepthScale: return QStringLiteral("depthScale");
case Project::AnimationProperty::CameraScale: return QStringLiteral("cameraScale");
case Project::AnimationProperty::Priority: return QStringLiteral("priority");
}
return QStringLiteral("property");
};
return kindName(kind) + QStringLiteral(":") + targetId + QStringLiteral(":") + propName(property);
}
static Project::Animation* activeAnimationOrCreate(Project& project) {
if (Project::Animation* existing = project.activeAnimationOrNull()) {
return existing;
}
// 默认动画:无(静态),仅 1 帧。其存在用于“动画系统外”的静态编辑基底。
Project::Animation none;
none.id = QStringLiteral("none");
none.name = QStringLiteral("无");
none.fps = std::max(1, project.fps());
none.lengthFrames = 1;
none.loop = false;
project.setAnimations({none});
project.setActiveAnimationId(none.id);
return project.activeAnimationOrNull();
}
static Project::AnimationTrack* findOrCreateTrack(Project::Animation& anim,
Project::AnimationTargetKind kind,
const QString& targetId,
Project::AnimationProperty property,
Project::AnimationValueType valueType,
Project::AnimationInterpolation interpolation) {
for (auto& t : anim.tracks) {
if (t.targetKind == kind && t.targetId == targetId && t.property == property) {
t.valueType = valueType;
t.interpolation = interpolation;
return &t;
}
}
Project::AnimationTrack t;
t.id = animationTrackId(kind, targetId, property);
t.targetKind = kind;
t.targetId = targetId;
t.property = property;
t.valueType = valueType;
t.interpolation = interpolation;
anim.tracks.push_back(t);
return &anim.tracks.last();
}
static void upsertAnimKey(Project::AnimationTrack& track, int frame, const QPointF& value) {
for (auto& k : track.keys) {
if (k.frame == frame) {
k.vec2Value = value;
return;
}
}
Project::AnimationKey k;
k.frame = frame;
k.vec2Value = value;
track.keys.push_back(k);
}
static void upsertAnimKey(Project::AnimationTrack& track, int frame, double value) {
for (auto& k : track.keys) {
if (k.frame == frame) {
k.numberValue = value;
return;
}
}
Project::AnimationKey k;
k.frame = frame;
k.numberValue = value;
track.keys.push_back(k);
}
static void upsertAnimKey(Project::AnimationTrack& track, int frame, bool value) {
for (auto& k : track.keys) {
if (k.frame == frame) {
k.boolValue = value;
return;
}
}
Project::AnimationKey k;
k.frame = frame;
k.boolValue = value;
track.keys.push_back(k);
}
static void upsertAnimKey(Project::AnimationTrack& track, int frame, const QString& value) {
for (auto& k : track.keys) {
if (k.frame == frame) {
k.stringValue = value;
return;
}
}
Project::AnimationKey k;
k.frame = frame;
k.stringValue = value;
track.keys.push_back(k);
}
static void upsertAnimKey(Project::AnimationTrack& track, int frame, const QByteArray& value) {
for (auto& k : track.keys) {
if (k.frame == frame) {
k.bytesValue = value;
return;
}
}
Project::AnimationKey k;
k.frame = frame;
k.bytesValue = value;
track.keys.push_back(k);
}
static void pruneEmptyAnimationTracks(Project::Animation& anim) {
anim.tracks.erase(
std::remove_if(anim.tracks.begin(),
anim.tracks.end(),
[](const Project::AnimationTrack& track) {
return track.targetId.isEmpty() || track.keys.isEmpty();
}),
anim.tracks.end());
}
/// 平移轨道里存储的偏移:根实体为世界坐标增量,子实体为相对父坐标增量(父未动时与世界增量一致)。
static bool shiftEntityPositionAnimKeys(QVector<Project::Animation>& anims, const QString& entityId, const QPointF& delta) {
bool any = false;
for (auto& anim : anims) {
for (auto& t : anim.tracks) {
if (t.targetKind != Project::AnimationTargetKind::Entity || t.targetId != entityId) {
continue;
}
if (t.property != Project::AnimationProperty::Position) {
continue;
}
if (t.valueType != Project::AnimationValueType::Vec2 || t.keys.isEmpty()) {
continue;
}
for (auto& k : t.keys) {
k.vec2Value += delta;
}
any = true;
}
}
return any;
}
} // namespace
QString ProjectWorkspace::indexFilePath() const {
if (m_projectDir.isEmpty()) {
return {};
}
return QDir(m_projectDir).filePath(QString::fromUtf8(kProjectIndexFileName));
}
QString ProjectWorkspace::assetsDirPath() const {
if (m_projectDir.isEmpty()) {
return {};
}
return QDir(m_projectDir).filePath(QString::fromUtf8(kAssetsDirName));
}
QString ProjectWorkspace::ensureAnimationsDir() const {
const auto assets = assetsDirPath();
if (assets.isEmpty()) {
return {};
}
return ensureDir(QDir(assets).filePath(QStringLiteral("animations")));
}
QString ProjectWorkspace::defaultAnimationPayloadPath(const QString& animationId) const {
const QString id = sanitizedEntityIdForAsset(animationId.isEmpty() ? QStringLiteral("animation") : animationId);
return core::persistence::AnimationBundleStore::bundleRelativeDir(id);
}
void ProjectWorkspace::markIndexDirty() {
m_indexDirty = true;
}
void ProjectWorkspace::markEntityDirty(const QString& id, const Project::Entity* snapshot) {
if (!id.isEmpty()) {
m_dirtyEntityIds.insert(id);
}
// 后台线程落盘 bundle(与 project.json 解耦);显式 save/close 仍会 flush + 写索引。
if (m_projectDir.isEmpty() || id.isEmpty()) {
return;
}
if (!m_asyncWriter) {
m_asyncWriter = new core::persistence::AsyncProjectWriter();
m_asyncWriter->setProjectDir(m_projectDir);
}
if (snapshot && snapshot->id == id) {
m_asyncWriter->requestWriteEntity(*snapshot);
return;
}
for (const auto& e : m_project.entities()) {
if (e.id == id) {
m_asyncWriter->requestWriteEntity(e);
break;
}
}
}
void ProjectWorkspace::markAnimationDirty(const QString& id) {
if (!id.isEmpty()) {
m_dirtyAnimationIds.insert(id);
}
if (m_projectDir.isEmpty() || id.isEmpty()) {
return;
}
if (!m_asyncWriter) {
m_asyncWriter = new core::persistence::AsyncProjectWriter();
m_asyncWriter->setProjectDir(m_projectDir);
}
for (const auto& a : m_project.animations()) {
if (a.id == id) {
m_asyncWriter->requestWriteAnimation(a);
break;
}
}
}
void ProjectWorkspace::markAllEntitiesDirty() {
for (const auto& e : m_project.entities()) {
markEntityDirty(e.id);
}
}
void ProjectWorkspace::markAllAnimationsDirty() {
for (const auto& a : m_project.animations()) {
markAnimationDirty(a.id);
}
}
void ProjectWorkspace::schedulePayloadSync() {
if (m_projectDir.isEmpty()) {
return;
}
if (!m_payloadSyncTimer) {
m_payloadSyncTimer = new QTimer();
m_payloadSyncTimer->setSingleShot(true);
QObject::connect(m_payloadSyncTimer, &QTimer::timeout, [this]() {
(void)syncPayloadsToDiskOnce();
});
}
m_payloadSyncTimer->start(400);
}
bool ProjectWorkspace::syncPayloadsToDiskOnce() {
if (m_projectDir.isEmpty()) {
return false;
}
const bool okEnt = syncEntityPayloadsToDisk();
const bool okAnim = syncAnimationPayloadsToDisk();
if (!okEnt || !okAnim) {
return false;
}
if (m_asyncWriter) {
m_asyncWriter->flushBlocking(60000);
}
// payload sync 可能修正 payloadPath/entityPayloadPath,需要再写一次 index
return writeIndexJsonWithoutPayloadSync();
}
QString ProjectWorkspace::backgroundAbsolutePath() const {
if (m_projectDir.isEmpty() || m_project.backgroundImagePath().isEmpty()) {
return {};
}
return QDir(m_projectDir).filePath(m_project.backgroundImagePath());
}
bool ProjectWorkspace::setBackgroundVisible(bool on) {
if (m_projectDir.isEmpty()) {
return false;
}
if (m_project.backgroundVisible() == on) {
return true;
}
m_project.setBackgroundVisible(on);
if (!writeIndexJson()) {
m_project.setBackgroundVisible(!on);
return false;
}
return true;
}
bool ProjectWorkspace::hasDepth() const {
if (m_projectDir.isEmpty()) {
return false;
}
if (!m_project.depthComputed() || m_project.depthMapPath().isEmpty()) {
return false;
}
const auto abs = depthAbsolutePath();
return !abs.isEmpty() && QFileInfo::exists(abs);
}
QString ProjectWorkspace::depthAbsolutePath() const {
if (m_projectDir.isEmpty() || m_project.depthMapPath().isEmpty()) {
return {};
}
return QDir(m_projectDir).filePath(m_project.depthMapPath());
}
bool ProjectWorkspace::setProjectTitle(const QString& title) {
if (m_projectDir.isEmpty()) {
return false;
}
const QString t = title.trimmed();
if (t.isEmpty()) {
return false;
}
if (t == m_project.name()) {
return true;
}
const QString before = m_project.name();
m_project.setName(t);
if (!writeIndexJson()) {
m_project.setName(before);
return false;
}
Operation op;
op.type = Operation::Type::SetProjectTitle;
op.label = QStringLiteral("重命名项目");
op.beforeProjectTitle = before;
op.afterProjectTitle = t;
pushOperation(op);
m_redoStack.clear();
return true;
}
bool ProjectWorkspace::createNew(const QString& projectDir, const QString& name,
const QString& backgroundImageSourcePath) {
return createNew(projectDir, name, backgroundImageSourcePath, QRect());
}
bool ProjectWorkspace::createNew(const QString& projectDir, const QString& name,
const QString& backgroundImageSourcePath,
const QRect& cropRectInSourceImage) {
// 约束:新建项目必须选择背景;裁剪可选(为空时取整张图)
if (backgroundImageSourcePath.isEmpty()) {
return false;
}
// 这里的 projectDir 实际是“父目录”,我们在其下创建新的项目文件夹
const auto parentAbs = normalizedProjectDir(projectDir);
if (parentAbs.isEmpty()) {
return false;
}
const auto newProjectDir = pickUniqueSubdirPath(parentAbs, name);
if (newProjectDir.isEmpty()) {
return false;
}
m_projectDir = normalizedProjectDir(newProjectDir);
if (m_projectDir.isEmpty()) {
return false;
}
if (!m_asyncWriter) {
m_asyncWriter = new core::persistence::AsyncProjectWriter();
}
m_asyncWriter->setProjectDir(m_projectDir);
const auto rootOk = ensureDir(m_projectDir);
if (rootOk.isEmpty()) {
m_projectDir.clear();
return false;
}
const auto assetsOk = ensureDir(assetsDirPath());
if (assetsOk.isEmpty()) {
m_projectDir.clear();
return false;
}
m_project.setName(name);
m_project.setBackgroundImagePath(QString());
m_undoStack.clear();
m_redoStack.clear();
if (!importBackgroundImage(backgroundImageSourcePath, cropRectInSourceImage)) {
m_projectDir.clear();
m_project = Project();
m_undoStack.clear();
m_redoStack.clear();
return false;
}
// 默认只创建“无”(静态)动画作为基底:仅 1 帧
Project::Animation none;
none.id = QStringLiteral("none");
none.name = QStringLiteral("无");
none.payloadPath = core::persistence::AnimationBundleStore::bundleRelativeDir(none.id);
none.fps = std::max(1, m_project.fps());
none.lengthFrames = 1;
none.loop = false;
m_project.setAnimations({none});
m_project.setActiveAnimationId(none.id);
markAnimationDirty(none.id);
markIndexDirty();
if (!ensureDefaultCameraIfMissing(nullptr)) {
return false;
}
markAllEntitiesDirty();
return writeIndexJson();
}
bool ProjectWorkspace::ensureDefaultCameraIfMissing(bool* outAdded) {
if (m_projectDir.isEmpty()) {
return false;
}
if (!m_project.cameras().isEmpty()) {
if (outAdded) {
*outAdded = false;
}
return true;
}
Project::Camera c;
c.id = QStringLiteral("camera-default");
c.displayName = QStringLiteral("主摄像机");
c.visible = true;
const QString bgAbs = backgroundAbsolutePath();
QSize sz;
if (!bgAbs.isEmpty() && QFileInfo::exists(bgAbs)) {
if (!image_file::probeImagePixelSize(bgAbs, &sz)) {
sz = QSize();
}
}
if (!sz.isValid() || sz.width() < 1 || sz.height() < 1) {
c.centerWorld = QPointF(512.0, 384.0);
c.viewScale = 1.0;
} else {
c.centerWorld = QPointF(sz.width() * 0.5, sz.height() * 0.5);
// 参考视口下「适配整张背景」的像素/世界单位比,与画布 zoomToFit 语义一致
constexpr double kRefViewportW = 1600.0;
constexpr double kRefViewportH = 900.0;
const double sw = kRefViewportW / static_cast<double>(sz.width());
const double sh = kRefViewportH / static_cast<double>(sz.height());
c.viewScale = std::clamp(std::min(sw, sh), 1e-6, 1e3);
}
m_project.setCameras({c});
if (m_project.activeCameraId().isEmpty()) {
m_project.setActiveCameraId(c.id);
}
if (outAdded) {
*outAdded = true;
}
return true;
}
bool ProjectWorkspace::openExisting(const QString& projectDir) {
const auto dir = normalizedProjectDir(projectDir);
const auto indexPath = QDir(dir).filePath(QString::fromUtf8(kProjectIndexFileName));
if (!QFileInfo::exists(indexPath)) {
return false;
}
// 重要:readIndexJson(v2) 会尝试从磁盘 hydrate 实体 payload,需要 m_projectDir 已就绪
const QString prevDir = m_projectDir;
const Project prevProject = m_project;
m_projectDir = dir;
if (!m_asyncWriter) {
m_asyncWriter = new core::persistence::AsyncProjectWriter();
}
m_asyncWriter->setProjectDir(m_projectDir);
if (!readIndexJson(indexPath)) {
m_projectDir = prevDir;
m_project = prevProject;
return false;
}
// 不做任何自动迁移/兼容:工程必须是当前版本(v7:目录 bundle payload + index)。
// readIndexJson 会写入 m_project;这里补齐历史初始化
ensureDir(assetsDirPath());
m_undoStack.clear();
m_redoStack.clear();
bool cameraAdded = false;
if (!ensureDefaultCameraIfMissing(&cameraAdded)) {
return false;
}
if (cameraAdded && !writeIndexJson()) {
return false;
}
return true;
}
void ProjectWorkspace::close() {
if (m_asyncWriter) {
m_asyncWriter->flushBlocking(15000);
}
m_projectDir.clear();
m_project = Project();
m_undoStack.clear();
m_redoStack.clear();
}
bool ProjectWorkspace::save() {
if (m_projectDir.isEmpty()) {
return false;
}
return writeIndexJson();
}
bool ProjectWorkspace::saveAll() {
if (m_projectDir.isEmpty()) {
return false;
}
markAllEntitiesDirty();
markAllAnimationsDirty();
markIndexDirty();
return writeIndexJson();
}
bool ProjectWorkspace::canUndo() const {
return !m_undoStack.isEmpty();
}
bool ProjectWorkspace::canRedo() const {
return !m_redoStack.isEmpty();
}
bool ProjectWorkspace::undo() {
if (!canUndo() || m_projectDir.isEmpty()) {
return false;
}
const auto op = m_undoStack.takeLast();
Operation redoOp = op;
redoOp.beforeBackgroundPath = op.beforeBackgroundPath;
redoOp.afterBackgroundPath = op.afterBackgroundPath;
if (op.type == Operation::Type::ImportBackground) {
if (!applyBackgroundPath(op.beforeBackgroundPath, false, QString())) {
m_undoStack.push_back(op);
return false;
}
} else if (op.type == Operation::Type::SetEntities) {
if (!applyEntities(op.beforeEntities, false, QString())) {
m_undoStack.push_back(op);
return false;
}
} else if (op.type == Operation::Type::SetTools) {
if (!applyTools(op.beforeTools, false, QString())) {
m_undoStack.push_back(op);
return false;
}
} else if (op.type == Operation::Type::SetCameras) {
if (!applyCameras(op.beforeCameras, false, QString())) {
m_undoStack.push_back(op);
return false;
}
} else if (op.type == Operation::Type::SetAnimations) {
const auto snapAnims = m_project.animations();
const auto snapHs = m_project.presentationHotspots();
m_project.setAnimations(op.beforeAnimations);
if (op.animBundlesPresentationHotspots) {
m_project.setPresentationHotspots(op.animSnapshotBeforePresentationHotspots);
}
markIndexDirty();
markAllAnimationsDirty();
if (!writeIndexJsonWithoutPayloadSync()) {
m_project.setAnimations(snapAnims);
m_project.setPresentationHotspots(snapHs);
m_undoStack.push_back(op);
return false;
}
} else if (op.type == Operation::Type::SetPresentationHotspots) {
const auto cur = m_project.presentationHotspots();
m_project.setPresentationHotspots(op.beforePresentationHotspots);
markIndexDirty();
if (!writeIndexJsonWithoutPayloadSync()) {
m_project.setPresentationHotspots(cur);
m_undoStack.push_back(op);
return false;
}
} else if (op.type == Operation::Type::SetProjectTitle) {
m_project.setName(op.beforeProjectTitle);
if (!writeIndexJson()) {
m_project.setName(op.afterProjectTitle);
m_undoStack.push_back(op);
return false;
}
} else if (op.type == Operation::Type::SetProjectFrameRange) {
m_project.setFrameStart(std::max(0, op.beforeFrameStart));
m_project.setFrameEnd(std::max(m_project.frameStart(), op.beforeFrameEnd));
if (!writeIndexJson()) {
m_project.setFrameStart(std::max(0, op.afterFrameStart));
m_project.setFrameEnd(std::max(m_project.frameStart(), op.afterFrameEnd));
m_undoStack.push_back(op);
return false;
}
}
m_redoStack.push_back(redoOp);
return true;
}
bool ProjectWorkspace::redo() {
if (!canRedo() || m_projectDir.isEmpty()) {
return false;
}
const auto op = m_redoStack.takeLast();
Operation undoOp = op;
if (op.type == Operation::Type::ImportBackground) {
if (!applyBackgroundPath(op.afterBackgroundPath, false, QString())) {
m_redoStack.push_back(op);
return false;
}
} else if (op.type == Operation::Type::SetEntities) {
if (!applyEntities(op.afterEntities, false, QString())) {
m_redoStack.push_back(op);
return false;
}
} else if (op.type == Operation::Type::SetTools) {
if (!applyTools(op.afterTools, false, QString())) {
m_redoStack.push_back(op);
return false;
}
} else if (op.type == Operation::Type::SetCameras) {
if (!applyCameras(op.afterCameras, false, QString())) {
m_redoStack.push_back(op);
return false;
}
} else if (op.type == Operation::Type::SetAnimations) {
const auto snapAnims = m_project.animations();
const auto snapHs = m_project.presentationHotspots();
m_project.setAnimations(op.afterAnimations);
if (op.animBundlesPresentationHotspots) {
m_project.setPresentationHotspots(op.animSnapshotAfterPresentationHotspots);
}
markIndexDirty();
markAllAnimationsDirty();
if (!writeIndexJsonWithoutPayloadSync()) {
m_project.setAnimations(snapAnims);
m_project.setPresentationHotspots(snapHs);
m_redoStack.push_back(op);
return false;
}
} else if (op.type == Operation::Type::SetPresentationHotspots) {
const auto cur = m_project.presentationHotspots();
m_project.setPresentationHotspots(op.afterPresentationHotspots);
markIndexDirty();
if (!writeIndexJsonWithoutPayloadSync()) {
m_project.setPresentationHotspots(cur);
m_redoStack.push_back(op);
return false;
}
} else if (op.type == Operation::Type::SetProjectTitle) {
m_project.setName(op.afterProjectTitle);
if (!writeIndexJson()) {
m_project.setName(op.beforeProjectTitle);
m_redoStack.push_back(op);
return false;
}
} else if (op.type == Operation::Type::SetProjectFrameRange) {
m_project.setFrameStart(std::max(0, op.afterFrameStart));
m_project.setFrameEnd(std::max(m_project.frameStart(), op.afterFrameEnd));
if (!writeIndexJson()) {
m_project.setFrameStart(std::max(0, op.beforeFrameStart));
m_project.setFrameEnd(std::max(m_project.frameStart(), op.beforeFrameEnd));
m_redoStack.push_back(op);
return false;
}
}
m_undoStack.push_back(undoOp);
return true;
}
bool ProjectWorkspace::setProjectFrameRange(int start, int end) {
if (m_projectDir.isEmpty()) {
return false;
}
const int s = std::max(0, start);
const int e = std::max(end, s);
if (m_project.frameStart() == s && m_project.frameEnd() == e) {
return true;
}
const int beforeS = m_project.frameStart();
const int beforeE = m_project.frameEnd();
m_project.setFrameStart(s);
m_project.setFrameEnd(e);
if (!writeIndexJson()) {
m_project.setFrameStart(beforeS);
m_project.setFrameEnd(beforeE);
return false;
}
Operation op;
op.type = Operation::Type::SetProjectFrameRange;
op.label = QStringLiteral("时间轴范围");
op.beforeFrameStart = beforeS;
op.beforeFrameEnd = beforeE;
op.afterFrameStart = s;
op.afterFrameEnd = e;
pushOperation(op);
m_redoStack.clear();
return true;
}
bool ProjectWorkspace::ensureProjectFrameEndAtLeast(int end, bool recordHistory) {
if (m_projectDir.isEmpty()) {
return false;
}
const int s = std::max(0, m_project.frameStart());
const int want = std::max(end, s);
if (want <= m_project.frameEnd()) {
return true;
}
// 按“页/片段长度”扩展:避免 scrub 时每帧都写盘,并使时间轴增长呈分段语义
// 约定:默认一页 600 帧(历史工程默认 frameEnd=600),后续可做成可配置。
constexpr int kPageLen = 600;
const int beforeE = m_project.frameEnd();
const int spanFromStart = std::max(0, want - s);
const int pages = (spanFromStart + kPageLen - 1) / kPageLen; // ceil
const int newEnd = s + pages * kPageLen;
m_project.setFrameEnd(std::max(newEnd, want));
if (!writeIndexJson()) {
m_project.setFrameEnd(beforeE);
return false;
}
if (recordHistory) {
Operation op;
op.type = Operation::Type::SetProjectFrameRange;
op.label = QStringLiteral("扩展时间轴");
op.beforeFrameStart = m_project.frameStart();
op.afterFrameStart = m_project.frameStart();
op.beforeFrameEnd = beforeE;
op.afterFrameEnd = m_project.frameEnd();
pushOperation(op);
m_redoStack.clear();
}
return true;
}
QStringList ProjectWorkspace::historyLabelsNewestFirst() const {
QStringList out;
out.reserve(m_undoStack.size());
for (auto it = m_undoStack.crbegin(); it != m_undoStack.crend(); ++it) {
out.push_back(it->label);
}
return out;
}
bool ProjectWorkspace::importBackgroundImage(const QString& backgroundImageSourcePath) {
return importBackgroundImage(backgroundImageSourcePath, QRect());
}
bool ProjectWorkspace::importBackgroundImage(const QString& backgroundImageSourcePath,
const QRect& cropRectInSourceImage) {
if (m_projectDir.isEmpty()) {
return false;
}
// 约束:项目创建成功后不允许再更换/裁剪背景
if (!m_project.backgroundImagePath().isEmpty()) {
return false;
}
// 背景变化会使深度失效:这里先直接清空深度状态(后续若允许更换背景,再完善历史记录)
m_project.setDepthComputed(false);
m_project.setDepthMapPath(QString());
const auto rel = copyIntoAssetsAsBackground(backgroundImageSourcePath, cropRectInSourceImage);
if (rel.isEmpty()) {
return false;
}
const auto label = cropRectInSourceImage.isNull() ? QStringLiteral("导入背景")
: QStringLiteral("导入背景(裁剪)");
return applyBackgroundPath(rel, true, label);
}
bool ProjectWorkspace::writeIndexJson() {
// 显式保存:入队 payload → flush → 原子写索引,避免队列未刷完就与索引不一致。
if (!m_projectDir.isEmpty() && !syncEntityPayloadsToDisk()) {
return false;
}
if (!m_projectDir.isEmpty() && !syncAnimationPayloadsToDisk()) {
return false;
}
if (m_asyncWriter) {
m_asyncWriter->flushBlocking(60000);
}
return writeIndexJsonWithoutPayloadSync();
}
bool ProjectWorkspace::writeIndexJsonWithoutPayloadSync() {
const auto root = projectToJson(m_project);
QJsonDocument doc(root);
const QString destAbs = indexFilePath();
if (!core::persistence::writeBytesAtomic(destAbs, doc.toJson(QJsonDocument::Indented))) {
return false;
}
m_indexDirty = false;
return true;
}
bool ProjectWorkspace::readIndexJson(const QString& indexPath) {
QFile f(indexPath);
if (!f.open(QIODevice::ReadOnly)) {
return false;
}
const auto data = f.readAll();
QJsonParseError err;
const auto doc = QJsonDocument::fromJson(data, &err);
if (err.error != QJsonParseError::NoError || !doc.isObject()) {
return false;
}
Project p;
int fileVer = 0;
if (!projectFromJson(doc.object(), p, &fileVer)) {
return false;
}
m_project = p;
if (!hydrateEntityPayloadsFromDisk()) {
return false;
}
if (!hydrateAnimationPayloadsFromDisk()) {
return false;
}
m_dirtyEntityIds.clear();
m_dirtyAnimationIds.clear();
m_indexDirty = false;
return true;
}
QJsonObject ProjectWorkspace::projectToJson(const Project& project) {
QJsonObject root;
root.insert("format", "hfut-bishe-project");
root.insert("version", kProjectIndexFormatVersion);
root.insert("name", project.name());
root.insert("savedAt", QDateTime::currentDateTimeUtc().toString(Qt::ISODate));
root.insert("backgroundImagePath", project.backgroundImagePath());
root.insert("backgroundVisible", project.backgroundVisible());
root.insert("depthComputed", project.depthComputed());
root.insert("depthMapPath", project.depthMapPath());
root.insert("frameStart", project.frameStart());
root.insert("frameEnd", project.frameEnd());
root.insert("fps", project.fps());
QJsonArray ents;
for (const auto& e : project.entities()) {
ents.append(entityToJson(e));
}
root.insert("entities", ents);
QJsonArray tools;
for (const auto& t : project.tools()) {
tools.append(toolToJson(t));
}
root.insert("tools", tools);
QJsonArray cams;
for (const auto& c : project.cameras()) {
cams.append(cameraToJson(c));
}
root.insert("cameras", cams);
root.insert("activeCameraId", project.activeCameraId());
root.insert("activeAnimationId", project.activeAnimationId());
QJsonArray animations;
for (const auto& a : project.animations()) {
QJsonObject ao;
ao.insert("id", a.id);
ao.insert("name", a.name);
ao.insert("path", a.payloadPath);
ao.insert("fps", a.fps);
ao.insert("lengthFrames", a.lengthFrames);
ao.insert("loop", a.loop);
animations.append(ao);
}
root.insert("animations", animations);
QJsonArray hots;
for (const auto& h : project.presentationHotspots()) {
QJsonObject ho;
ho.insert(QStringLiteral("id"), h.id);
ho.insert(QStringLiteral("cx"), h.centerWorld.x());
ho.insert(QStringLiteral("cy"), h.centerWorld.y());
ho.insert(QStringLiteral("targetAnimationId"), h.targetAnimationId);
hots.append(ho);
}
root.insert(QStringLiteral("presentationHotspots"), hots);
return root;
}
bool ProjectWorkspace::projectFromJson(const QJsonObject& root, Project& outProject, int* outFileVersion) {
if (root.value("format").toString() != QStringLiteral("hfut-bishe-project")) {
return false;
}
const int version = root.value("version").toInt();
if (version != 7) {
return false;
}
if (outFileVersion) {
*outFileVersion = version;
}
outProject.setName(root.value("name").toString());
outProject.setBackgroundImagePath(asRelativeUnderProject(root.value("backgroundImagePath").toString()));
outProject.setBackgroundVisible(root.value("backgroundVisible").toBool(true));
outProject.setDepthComputed(root.value("depthComputed").toBool(false));
outProject.setDepthMapPath(asOptionalRelativeUnderProject(root.value("depthMapPath").toString()));
outProject.setFrameStart(root.value("frameStart").toInt(0));
outProject.setFrameEnd(root.value("frameEnd").toInt(600));
outProject.setFps(root.value("fps").toInt(60));
if (outProject.frameEnd() < outProject.frameStart()) {
outProject.setFrameEnd(outProject.frameStart());
}
QVector<Project::Entity> entities;
const auto entsVal = root.value("entities");
if (entsVal.isArray()) {
const QJsonArray arr = entsVal.toArray();
entities.reserve(arr.size());
for (const auto& v : arr) {
if (!v.isObject()) {
continue;
}
Project::Entity e;
if (!entityStubFromJsonV2(v.toObject(), e)) {
return false;
}
entities.push_back(e);
}
}
outProject.setEntities(entities);
QVector<Project::Tool> tools;
const auto toolsVal = root.value("tools");
if (toolsVal.isArray()) {
const QJsonArray arr = toolsVal.toArray();
tools.reserve(arr.size());
for (const auto& v : arr) {
if (!v.isObject()) continue;
Project::Tool t;
if (toolFromJsonV2(v.toObject(), t)) {
tools.push_back(t);
}
}
}
outProject.setTools(tools);
QVector<Project::Camera> cameras;
const auto camsVal = root.value("cameras");
if (camsVal.isArray()) {
const QJsonArray arr = camsVal.toArray();
cameras.reserve(arr.size());
for (const auto& v : arr) {
if (!v.isObject()) continue;
Project::Camera c;
if (cameraFromJsonV4(v.toObject(), c)) {
cameras.push_back(c);
}
}
}
outProject.setCameras(cameras);
outProject.setActiveCameraId(root.value("activeCameraId").toString());
QVector<Project::Animation> animations;
const auto animVal = root.value("animations");
if (animVal.isArray()) {
for (const auto& av : animVal.toArray()) {
if (!av.isObject()) continue;
const QJsonObject ao = av.toObject();
Project::Animation a;
a.id = ao.value("id").toString();
a.name = ao.value("name").toString();
a.payloadPath = asRelativeUnderProject(ao.value("path").toString());
a.fps = std::max(1, ao.value("fps").toInt(30));
a.lengthFrames = std::max(1, ao.value("lengthFrames").toInt(Project::kClipFixedFrames));
a.loop = ao.value("loop").toBool(true);
if (a.id.isEmpty() || a.payloadPath.isEmpty()) {
return false;
}
animations.push_back(a);
}
}
outProject.setAnimations(animations);
outProject.setActiveAnimationId(root.value("activeAnimationId").toString());
QVector<Project::PresentationHotspot> hotspots;
const auto hotVal = root.value(QStringLiteral("presentationHotspots"));
if (hotVal.isArray()) {
const QJsonArray arr = hotVal.toArray();
hotspots.reserve(arr.size());
for (const auto& v : arr) {
if (!v.isObject())
continue;
const QJsonObject ho = v.toObject();
Project::PresentationHotspot ph;
ph.id = ho.value(QStringLiteral("id")).toString();
ph.centerWorld =
QPointF(ho.value(QStringLiteral("cx")).toDouble(), ho.value(QStringLiteral("cy")).toDouble());
ph.targetAnimationId = ho.value(QStringLiteral("targetAnimationId")).toString();
if (!ph.id.isEmpty()) {
hotspots.push_back(ph);
}
}
}
outProject.setPresentationHotspots(hotspots);
return true;
}
QString ProjectWorkspace::asRelativeUnderProject(const QString& relativePath) {
if (relativePath.isEmpty()) {
return {};
}
QString p = relativePath;
while (p.startsWith("./")) {
p = p.mid(2);
}
if (QDir::isAbsolutePath(p)) {
// 不允许绝对路径写入索引,避免项目不可迁移
return {};
}
return QDir::cleanPath(p);
}
QString ProjectWorkspace::asOptionalRelativeUnderProject(const QString& relativePath) {
if (relativePath.isEmpty()) {
return {};
}
return asRelativeUnderProject(relativePath);
}
QJsonObject ProjectWorkspace::entityToJson(const Project::Entity& e) {
QJsonObject o;
o.insert("id", e.id);
o.insert("payload", e.entityPayloadPath);
o.insert("visible", e.visible);
return o;
}
QJsonObject ProjectWorkspace::toolToJson(const Project::Tool& t) {
QJsonObject o;
o.insert("id", t.id);
o.insert("displayName", t.displayName);
o.insert("visible", t.visible);
o.insert("parentId", t.parentId);
o.insert("parentOffsetX", t.parentOffsetWorld.x());
o.insert("parentOffsetY", t.parentOffsetWorld.y());
o.insert("originX", t.originWorld.x());
o.insert("originY", t.originWorld.y());
o.insert("priority", t.priority);
o.insert("type", (t.type == Project::Tool::Type::Bubble) ? QStringLiteral("bubble") : QStringLiteral("bubble"));
o.insert("text", t.text);
o.insert("fontPx", t.fontPx);
const QString align =
(t.align == Project::Tool::TextAlign::Left) ? QStringLiteral("left")
: (t.align == Project::Tool::TextAlign::Right) ? QStringLiteral("right")
: QStringLiteral("center");
o.insert("align", align);
o.insert("pointerT", t.bubblePointerT01);
QJsonArray vis;
for (const auto& k : t.visibilityKeys) {
QJsonObject ko;
ko.insert("frame", k.frame);
ko.insert("value", k.value);
vis.append(ko);
}
o.insert("visibilityKeys", vis);
QJsonArray loc;
for (const auto& k : t.locationKeys) {
QJsonObject ko;
ko.insert("frame", k.frame);
ko.insert("x", k.value.x());
ko.insert("y", k.value.y());
loc.append(ko);
}
o.insert("locationKeys", loc);
return o;
}
bool ProjectWorkspace::toolFromJsonV2(const QJsonObject& o, Project::Tool& out) {
Project::Tool t;
t.id = o.value("id").toString();
if (t.id.isEmpty()) {
return false;
}
t.displayName = o.value("displayName").toString();
t.visible = o.value("visible").toBool(true);
t.parentId = o.value("parentId").toString();
t.parentOffsetWorld = QPointF(o.value("parentOffsetX").toDouble(0.0), o.value("parentOffsetY").toDouble(0.0));
t.originWorld = QPointF(o.value("originX").toDouble(0.0), o.value("originY").toDouble(0.0));
t.priority = o.value("priority").toInt(10);
const QString type = o.value("type").toString();
t.type = (type == QStringLiteral("bubble")) ? Project::Tool::Type::Bubble : Project::Tool::Type::Bubble;
t.text = o.value("text").toString();
t.fontPx = std::clamp(o.value("fontPx").toInt(18), 8, 120);
const QString align = o.value("align").toString(QStringLiteral("center"));
if (align == QStringLiteral("left")) t.align = Project::Tool::TextAlign::Left;
else if (align == QStringLiteral("right")) t.align = Project::Tool::TextAlign::Right;
else t.align = Project::Tool::TextAlign::Center;
if (o.contains(QStringLiteral("pointerT"))) {
t.bubblePointerT01 = std::clamp(o.value(QStringLiteral("pointerT")).toDouble(0.5), 0.0, 1.0);
} else {
const QString ptr = o.value(QStringLiteral("pointer")).toString();
if (ptr == QStringLiteral("left")) t.bubblePointerT01 = 0.12;
else if (ptr == QStringLiteral("right")) t.bubblePointerT01 = 0.88;
else t.bubblePointerT01 = 0.5;
}
t.visibilityKeys.clear();
const auto visVal = o.value("visibilityKeys");
if (visVal.isArray()) {
const QJsonArray arr = visVal.toArray();
t.visibilityKeys.reserve(arr.size());
for (const auto& v : arr) {
if (!v.isObject()) continue;
const auto ko = v.toObject();
Project::ToolKeyframeBool k;
k.frame = ko.value("frame").toInt(0);
k.value = ko.value("value").toBool(true);
t.visibilityKeys.push_back(k);
}
}
t.locationKeys.clear();
const auto locVal = o.value("locationKeys");
if (locVal.isArray()) {
const QJsonArray arr = locVal.toArray();
t.locationKeys.reserve(arr.size());
for (const auto& v : arr) {
if (!v.isObject()) continue;
const auto ko = v.toObject();
Project::Entity::KeyframeVec2 k;
k.frame = ko.value("frame").toInt(0);
k.value = QPointF(ko.value("x").toDouble(0.0), ko.value("y").toDouble(0.0));
t.locationKeys.push_back(k);
}
}
out = std::move(t);
return true;
}
QJsonObject ProjectWorkspace::cameraToJson(const Project::Camera& c) {
QJsonObject o;
o.insert("id", c.id);
o.insert("displayName", c.displayName);
o.insert("visible", c.visible);
o.insert("centerX", c.centerWorld.x());
o.insert("centerY", c.centerWorld.y());
o.insert("viewScale", c.viewScale);
QJsonArray loc;
for (const auto& k : c.locationKeys) {
QJsonObject ko;
ko.insert("frame", k.frame);
ko.insert("x", k.value.x());
ko.insert("y", k.value.y());
loc.append(ko);
}
o.insert("locationKeys", loc);
QJsonArray sc;
for (const auto& k : c.scaleKeys) {
QJsonObject ko;
ko.insert("frame", k.frame);
ko.insert("value", k.value);
sc.append(ko);
}
o.insert("scaleKeys", sc);
return o;
}
bool ProjectWorkspace::cameraFromJsonV4(const QJsonObject& o, Project::Camera& out) {
Project::Camera c;
c.id = o.value("id").toString();
if (c.id.isEmpty()) {
return false;
}
c.displayName = o.value("displayName").toString();
c.visible = o.value("visible").toBool(true);
c.centerWorld = QPointF(o.value("centerX").toDouble(0.0), o.value("centerY").toDouble(0.0));
c.viewScale = std::clamp(o.value("viewScale").toDouble(1.0), 1e-6, 1e3);
c.locationKeys.clear();
const auto locVal = o.value("locationKeys");
if (locVal.isArray()) {
const QJsonArray arr = locVal.toArray();
c.locationKeys.reserve(arr.size());
for (const auto& v : arr) {
if (!v.isObject()) continue;
const auto ko = v.toObject();
Project::Entity::KeyframeVec2 k;
k.frame = ko.value("frame").toInt(0);
k.value = QPointF(ko.value("x").toDouble(0.0), ko.value("y").toDouble(0.0));
c.locationKeys.push_back(k);
}
}
c.scaleKeys.clear();
const auto scVal = o.value("scaleKeys");
if (scVal.isArray()) {
const QJsonArray arr = scVal.toArray();
c.scaleKeys.reserve(arr.size());
for (const auto& v : arr) {
if (!v.isObject()) continue;
const auto ko = v.toObject();
Project::Entity::KeyframeDouble k;
k.frame = ko.value("frame").toInt(0);
k.value = std::clamp(ko.value("value").toDouble(1.0), 1e-6, 1e3);
c.scaleKeys.push_back(k);
}
}
out = std::move(c);
return true;
}
bool ProjectWorkspace::entityStubFromJsonV2(const QJsonObject& o, Project::Entity& out) {
out = Project::Entity{};
out.id = o.value("id").toString();
out.entityPayloadPath = asOptionalRelativeUnderProject(o.value("payload").toString());
out.visible = o.value("visible").toBool(true);
out.blackholeVisible = true;
out.blackholeId = out.id.isEmpty() ? QString() : QStringLiteral("blackhole-%1").arg(out.id);
out.blackholeResolvedBy = QStringLiteral("pending");
if (out.id.isEmpty() || out.entityPayloadPath.isEmpty()) {
return false;
}
return true;
}
// 不再支持 v1 entity JSON(无兼容/无迁移)。
QString ProjectWorkspace::fileSuffixWithDot(const QString& path) {
QFileInfo fi(path);
const auto suf = fi.suffix();
if (suf.isEmpty()) {
return {};
}
return "." + suf;
}
void ProjectWorkspace::pushOperation(const Operation& op) {
m_undoStack.push_back(op);
if (m_undoStack.size() > kMaxHistorySteps) {
m_undoStack.remove(0, m_undoStack.size() - kMaxHistorySteps);
}
}
bool ProjectWorkspace::applyBackgroundPath(const QString& relativePath,
bool recordHistory,
const QString& label) {
const auto rel = asRelativeUnderProject(relativePath);
if (relativePath.isEmpty()) {
// 允许清空背景
if (recordHistory) {
Operation op;
op.type = Operation::Type::ImportBackground;
op.label = label;
op.beforeBackgroundPath = m_project.backgroundImagePath();
op.afterBackgroundPath = QString();
pushOperation(op);
m_redoStack.clear();
}
m_project.setBackgroundImagePath(QString());
return writeIndexJson();
}
if (rel.isEmpty()) {
return false;
}
const auto before = m_project.backgroundImagePath();
m_project.setBackgroundImagePath(rel);
markIndexDirty();
if (!writeIndexJson()) {
m_project.setBackgroundImagePath(before);
return false;
}
if (recordHistory) {
Operation op;
op.type = Operation::Type::ImportBackground;
op.label = label;
op.beforeBackgroundPath = before;
op.afterBackgroundPath = rel;
pushOperation(op);
m_redoStack.clear();
}
return true;
}
bool ProjectWorkspace::applyEntities(const QVector<Project::Entity>& entities,
bool recordHistory,
const QString& label) {
const auto before = m_project.entities();
QHash<QString, Project::Entity> beforeById;
beforeById.reserve(before.size());
for (const auto& e : before) {
beforeById.insert(e.id, e);
}
for (const auto& e : entities) {
if (!beforeById.contains(e.id) || !entityStaticEquals(beforeById.value(e.id), e)) {
markEntityDirty(e.id, &e);
}
}
if (before.size() != entities.size()) {
markIndexDirty();
}
m_project.setEntities(entities);
if (!writeIndexJsonWithoutPayloadSync()) {
m_project.setEntities(before);
return false;
}
if (recordHistory) {
Operation op;
op.type = Operation::Type::SetEntities;
op.label = label;
op.beforeEntities = before;
op.afterEntities = entities;
pushOperation(op);
m_redoStack.clear();
}
return true;
}
bool ProjectWorkspace::applyTools(const QVector<Project::Tool>& tools,
bool recordHistory,
const QString& label) {
const auto before = m_project.tools();
m_project.setTools(tools);
markIndexDirty();
if (!writeIndexJsonWithoutPayloadSync()) {
m_project.setTools(before);
return false;
}
if (recordHistory) {
Operation op;
op.type = Operation::Type::SetTools;
op.label = label;
op.beforeTools = before;
op.afterTools = tools;
pushOperation(op);
m_redoStack.clear();
}
return true;
}
bool ProjectWorkspace::applyCameras(const QVector<Project::Camera>& cameras,
bool recordHistory,
const QString& label) {
const auto before = m_project.cameras();
m_project.setCameras(cameras);
markIndexDirty();
if (!writeIndexJsonWithoutPayloadSync()) {
m_project.setCameras(before);
return false;
}
if (recordHistory) {
Operation op;
op.type = Operation::Type::SetCameras;
op.label = label;
op.beforeCameras = before;
op.afterCameras = cameras;
pushOperation(op);
m_redoStack.clear();
}
return true;
}
bool ProjectWorkspace::applyAnimations(const QVector<Project::Animation>& animations,
bool recordHistory,
const QString& label) {
const auto beforeAnims = m_project.animations();
const auto beforeHs = m_project.presentationHotspots();
m_project.setAnimations(animations);
sanitizePresentationHotspotTargets(m_project);
const auto afterHs = m_project.presentationHotspots();
const bool hotspotsTouched = !presentationHotspotsEqual(beforeHs, afterHs);
markIndexDirty();
markAllAnimationsDirty();
if (!writeIndexJsonWithoutPayloadSync()) {
m_project.setAnimations(beforeAnims);
m_project.setPresentationHotspots(beforeHs);
return false;
}
if (recordHistory) {
Operation op;
op.type = Operation::Type::SetAnimations;
op.label = label;
op.beforeAnimations = beforeAnims;
op.afterAnimations = animations;
op.animBundlesPresentationHotspots = hotspotsTouched;
if (hotspotsTouched) {
op.animSnapshotBeforePresentationHotspots = beforeHs;
op.animSnapshotAfterPresentationHotspots = afterHs;
}
pushOperation(op);
m_redoStack.clear();
}
return true;
}
bool ProjectWorkspace::applyPresentationHotspots(const QVector<Project::PresentationHotspot>& hotspots,
bool recordHistory,
const QString& label) {
if (!isOpen())
return false;
const auto before = m_project.presentationHotspots();
m_project.setPresentationHotspots(hotspots);
markIndexDirty();
if (!writeIndexJsonWithoutPayloadSync()) {
m_project.setPresentationHotspots(before);
return false;
}
if (recordHistory) {
Operation op;
op.type = Operation::Type::SetPresentationHotspots;
op.label = label;
op.beforePresentationHotspots = before;
op.afterPresentationHotspots = hotspots;
pushOperation(op);
m_redoStack.clear();
}
return true;
}
QString ProjectWorkspace::ensureEntitiesDir() const {
const auto assets = assetsDirPath();
if (assets.isEmpty()) {
return {};
}
const auto dir = QDir(assets).filePath(QStringLiteral("entities"));
return ensureDir(dir);
}
bool ProjectWorkspace::syncEntityPayloadsToDisk() {
if (ensureEntitiesDir().isEmpty()) {
return false;
}
QVector<Project::Entity> ents = m_project.entities();
bool pathsChanged = false;
if (!m_asyncWriter && !m_projectDir.isEmpty()) {
m_asyncWriter = new core::persistence::AsyncProjectWriter();
m_asyncWriter->setProjectDir(m_projectDir);
}
for (auto& e : ents) {
if (e.entityPayloadPath.isEmpty()) {
e.entityPayloadPath = core::persistence::EntityBundleStore::bundleRelativeDir(e.id);
pathsChanged = true;
} else if (e.entityPayloadPath.endsWith(QStringLiteral(".hfe"), Qt::CaseInsensitive)) {
// 与 v7 bundle 目录布局对齐(曾误写为单文件 .hfe)
e.entityPayloadPath = core::persistence::EntityBundleStore::bundleRelativeDir(e.id);
pathsChanged = true;
}
const QString rel = asRelativeUnderProject(e.entityPayloadPath);
if (rel.isEmpty()) {
return false;
}
if (rel != e.entityPayloadPath) {
e.entityPayloadPath = rel;
pathsChanged = true;
}
const bool dirty = m_dirtyEntityIds.contains(e.id);
if (dirty && m_asyncWriter) {
m_asyncWriter->requestWriteEntity(e);
}
}
if (pathsChanged) {
m_project.setEntities(ents);
markIndexDirty();
}
m_dirtyEntityIds.clear();
return true;
}
bool ProjectWorkspace::syncAnimationPayloadsToDisk() {
if (ensureAnimationsDir().isEmpty()) {
return false;
}
auto animations = m_project.animations();
bool pathsChanged = false;
if (!m_asyncWriter && !m_projectDir.isEmpty()) {
m_asyncWriter = new core::persistence::AsyncProjectWriter();
m_asyncWriter->setProjectDir(m_projectDir);
}
for (auto& a : animations) {
if (a.id.isEmpty()) {
return false;
}
if (a.payloadPath.isEmpty()) {
a.payloadPath = core::persistence::AnimationBundleStore::bundleRelativeDir(a.id);
pathsChanged = true;
m_dirtyAnimationIds.insert(a.id);
}
const QString rel = asRelativeUnderProject(a.payloadPath);
if (rel.isEmpty()) {
return false;
}
if (rel != a.payloadPath) {
a.payloadPath = rel;
pathsChanged = true;
m_dirtyAnimationIds.insert(a.id);
}
if (m_dirtyAnimationIds.contains(a.id) && m_asyncWriter) {
m_asyncWriter->requestWriteAnimation(a);
}
}
if (pathsChanged) {
m_project.setAnimations(animations);
markIndexDirty();
}
m_dirtyAnimationIds.clear();
return true;
}
bool ProjectWorkspace::saveSingleEntityPayload(Project::Entity& entity) {
if (m_projectDir.isEmpty()) {
return false;
}
if (ensureEntitiesDir().isEmpty()) {
return false;
}
if (entity.entityPayloadPath.isEmpty()) {
entity.entityPayloadPath = core::persistence::EntityBundleStore::bundleRelativeDir(entity.id);
}
const QString rel = asRelativeUnderProject(entity.entityPayloadPath);
if (rel.isEmpty()) {
return false;
}
entity.entityPayloadPath = rel;
return core::persistence::EntityBundleStore::save(m_projectDir, entity);
}
bool ProjectWorkspace::hydrateEntityPayloadsFromDisk() {
if (m_projectDir.isEmpty()) {
return true;
}
QVector<Project::Entity> ents = m_project.entities();
for (auto& e : ents) {
const QString expectId = e.id;
QString rel = e.entityPayloadPath;
if (rel.isEmpty()) {
return false;
}
rel = asRelativeUnderProject(rel);
if (rel.isEmpty()) {
return false;
}
e.entityPayloadPath = rel;
// 曾错误地把 payload 写成 *.hfe 单文件路径,而 bundle 实际写在 assets/entities/<id>/ 目录下。
QString tryRel = rel;
if (!core::persistence::EntityBundleStore::load(m_projectDir, e)) {
if (tryRel.endsWith(QStringLiteral(".hfe"), Qt::CaseInsensitive)) {
tryRel = core::persistence::EntityBundleStore::bundleRelativeDir(expectId);
tryRel = asRelativeUnderProject(tryRel);
if (!tryRel.isEmpty()) {
e.entityPayloadPath = tryRel;
if (!core::persistence::EntityBundleStore::load(m_projectDir, e)) {
return false;
}
} else {
return false;
}
} else {
return false;
}
}
if (e.id != expectId) {
return false;
}
}
m_project.setEntities(ents);
return true;
}
bool ProjectWorkspace::hydrateAnimationPayloadsFromDisk() {
if (m_projectDir.isEmpty()) {
return true;
}
QVector<Project::Animation> animations = m_project.animations();
for (auto& a : animations) {
const QString expectId = a.id;
QString rel = asRelativeUnderProject(a.payloadPath);
if (expectId.isEmpty() || rel.isEmpty()) {
return false;
}
a.payloadPath = rel;
if (!core::persistence::AnimationBundleStore::load(m_projectDir, a)) {
return false;
}
if (a.id != expectId) {
return false;
}
}
m_project.setAnimations(animations);
m_dirtyAnimationIds.clear();
return true;
}
// 不再支持 v1 legacy .anim sidecar(无兼容/无迁移)。
bool ProjectWorkspace::writeEntityImage(const QString& entityId, const QImage& image, QString& outRelPath) {
outRelPath.clear();
if (m_projectDir.isEmpty() || entityId.isEmpty() || image.isNull()) {
return false;
}
const auto entsDir = ensureEntitiesDir();
if (entsDir.isEmpty()) {
return false;
}
const auto fileName = QStringLiteral("%1.png").arg(entityId);
const auto destAbs = QDir(entsDir).filePath(fileName);
const auto destRel = QString::fromUtf8(kAssetsDirName) + "/entities/" + fileName;
const auto tmpAbs = destAbs + ".tmp";
if (QFileInfo::exists(tmpAbs)) {
QFile::remove(tmpAbs);
}
if (!image.save(tmpAbs, "PNG")) {
QFile::remove(tmpAbs);
return false;
}
QFile::remove(destAbs);
if (!QFile::rename(tmpAbs, destAbs)) {
QFile::remove(tmpAbs);
return false;
}
outRelPath = destRel;
return true;
}
bool ProjectWorkspace::writeEntityFrameImage(const QString& entityId, int frame, const QImage& image, QString& outRelPath) {
outRelPath.clear();
if (m_projectDir.isEmpty() || entityId.isEmpty() || image.isNull() || frame < 0) {
return false;
}
const auto entsDir = ensureEntitiesDir();
if (entsDir.isEmpty()) {
return false;
}
const auto fileName = QStringLiteral("%1_f%2.png").arg(entityId).arg(frame);
const auto destAbs = QDir(entsDir).filePath(fileName);
const auto destRel = QString::fromUtf8(kAssetsDirName) + "/entities/" + fileName;
const auto tmpAbs = destAbs + ".tmp";
if (QFileInfo::exists(tmpAbs)) {
QFile::remove(tmpAbs);
}
if (!image.save(tmpAbs, "PNG")) {
QFile::remove(tmpAbs);
return false;
}
QFile::remove(destAbs);
if (!QFile::rename(tmpAbs, destAbs)) {
QFile::remove(tmpAbs);
return false;
}
outRelPath = destRel;
return true;
}
static void upsertKey(QVector<Project::Entity::KeyframeVec2>& keys, int frame, const QPointF& v) {
for (auto& k : keys) {
if (k.frame == frame) {
k.value = v;
return;
}
}
keys.push_back(Project::Entity::KeyframeVec2{frame, v});
}
static void upsertKey(QVector<Project::Entity::KeyframeFloat01>& keys, int frame, double v) {
for (auto& k : keys) {
if (k.frame == frame) {
k.value = v;
return;
}
}
Project::Entity::KeyframeFloat01 kf;
kf.frame = frame;
kf.value = v;
keys.push_back(kf);
}
static void upsertKey(QVector<Project::Entity::KeyframeDouble>& keys, int frame, double v) {
for (auto& k : keys) {
if (k.frame == frame) {
k.value = v;
return;
}
}
Project::Entity::KeyframeDouble kf;
kf.frame = frame;
kf.value = v;
keys.push_back(kf);
}
static void upsertFrame(QVector<Project::Entity::ImageFrame>& frames, int frame, const QString& path) {
for (auto& k : frames) {
if (k.frame == frame) {
k.imagePath = path;
return;
}
}
Project::Entity::ImageFrame kf;
kf.frame = frame;
kf.imagePath = path;
frames.push_back(kf);
}
bool ProjectWorkspace::addEntity(const Project::Entity& entity, const QImage& image) {
// 允许在“只有背景、尚未计算深度”的情况下创建实体:depth 会退化为 0。
if (m_projectDir.isEmpty() || !hasBackground()) {
return false;
}
if (entity.id.isEmpty() || entity.polygonLocal.isEmpty()) {
return false;
}
Project::Entity e = entity;
if (!image.isNull()) {
QByteArray png;
QBuffer buf(&png);
if (!buf.open(QIODevice::WriteOnly) || !image.save(&buf, "PNG")) {
return false;
}
e.imagePath.clear();
e.defaultImagePng = png;
}
if (e.entityPayloadPath.isEmpty()) {
e.entityPayloadPath = core::persistence::EntityBundleStore::bundleRelativeDir(e.id);
}
if (e.blackholeId.isEmpty()) {
e.blackholeId = QStringLiteral("blackhole-%1").arg(e.id);
}
if (e.blackholeResolvedBy.isEmpty()) {
e.blackholeResolvedBy = QStringLiteral("pending");
}
auto ents = m_project.entities();
ents.push_back(e);
return applyEntities(ents, true, QStringLiteral("添加实体"));
}
bool ProjectWorkspace::setEntityVisible(const QString& id, bool on) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
auto ents = m_project.entities();
bool found = false;
bool changed = false;
for (auto& e : ents) {
if (e.id != id) continue;
found = true;
if (e.visible != on) {
e.visible = on;
changed = true;
}
break;
}
if (!found) return false;
if (!changed) return true;
return applyEntities(ents, true, on ? QStringLiteral("显示实体") : QStringLiteral("隐藏实体"));
}
bool ProjectWorkspace::setEntityBlackholeVisible(const QString& id, bool on) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
const auto before = m_project.entities();
auto ents = before;
bool found = false;
bool changed = false;
int hit = -1;
for (auto& e : ents) {
if (e.id != id) {
continue;
}
found = true;
hit = static_cast<int>(&e - ents.data());
if (e.blackholeVisible != on) {
e.blackholeVisible = on;
changed = true;
}
if (e.blackholeId.isEmpty()) {
e.blackholeId = QStringLiteral("blackhole-%1").arg(e.id);
changed = true;
}
if (on) {
if (e.blackholeResolvedBy.isEmpty()) {
e.blackholeResolvedBy = QStringLiteral("pending");
changed = true;
}
}
break;
}
if (!found) {
return false;
}
if (!changed || hit < 0) {
return true;
}
m_project.setEntities(ents);
if (!saveSingleEntityPayload(ents[hit]) || !writeIndexJsonWithoutPayloadSync()) {
m_project.setEntities(before);
return false;
}
Operation op;
op.type = Operation::Type::SetEntities;
op.label = on ? QStringLiteral("显示黑洞") : QStringLiteral("隐藏黑洞");
op.beforeEntities = before;
op.afterEntities = ents;
pushOperation(op);
m_redoStack.clear();
return true;
}
bool ProjectWorkspace::resolveBlackholeByUseOriginalBackground(const QString& id) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
const auto before = m_project.entities();
auto ents = before;
int hit = -1;
for (int i = 0; i < ents.size(); ++i) {
if (ents[i].id == id) {
hit = i;
break;
}
}
if (hit < 0) {
return false;
}
auto& e = ents[hit];
e.blackholeVisible = false;
if (e.blackholeId.isEmpty()) {
e.blackholeId = QStringLiteral("blackhole-%1").arg(e.id);
}
e.blackholeResolvedBy = QStringLiteral("use_original_background");
removeBlackholeOverlayFile(m_projectDir, e.blackholeOverlayPath);
e.blackholeOverlayPath.clear();
e.blackholeOverlayRect = QRect();
m_project.setEntities(ents);
if (!saveSingleEntityPayload(ents[hit]) || !writeIndexJsonWithoutPayloadSync()) {
m_project.setEntities(before);
return false;
}
Operation op;
op.type = Operation::Type::SetEntities;
op.label = QStringLiteral("黑洞使用原始背景");
op.beforeEntities = before;
op.afterEntities = ents;
pushOperation(op);
m_redoStack.clear();
return true;
}
bool ProjectWorkspace::resolveBlackholeByCopyBackground(const QString& id, const QRect& targetWorldInBackground,
const QRect& srcWorldInBackground, bool hideBlackholeAfterFill) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
const QString bgAbs = backgroundAbsolutePath();
if (bgAbs.isEmpty() || !QFileInfo::exists(bgAbs)) {
return false;
}
auto ents = m_project.entities();
int hit = -1;
for (int i = 0; i < ents.size(); ++i) {
if (ents[i].id == id) {
hit = i;
break;
}
}
if (hit < 0) {
return false;
}
const auto& ent = ents[hit];
if (ent.cutoutPolygonWorld.size() < 3) {
return false;
}
const QRect targetWorld = targetWorldInBackground.normalized();
const QRect srcWorld = srcWorldInBackground.normalized();
if (!targetWorld.isValid() || !srcWorld.isValid() || targetWorld.width() <= 0 || targetWorld.height() <= 0) {
return false;
}
if (targetWorld.width() != srcWorld.width() || targetWorld.height() != srcWorld.height()) {
return false;
}
QSize logicalSz;
if (!image_file::probeImagePixelSize(bgAbs, &logicalSz) || !logicalSz.isValid() || logicalSz.width() < 1 ||
logicalSz.height() < 1) {
return false;
}
const QRect bounds(0, 0, logicalSz.width(), logicalSz.height());
if (targetWorld.intersected(bounds) != targetWorld || srcWorld.intersected(bounds) != srcWorld) {
return false;
}
QImage targetPatch;
QImage srcPatch;
if (!image_file::loadRegionToQImageExact(bgAbs, targetWorld, &targetPatch) ||
!image_file::loadRegionToQImageExact(bgAbs, srcWorld, &srcPatch) || targetPatch.isNull() ||
srcPatch.isNull()) {
return false;
}
if (targetPatch.size() != srcPatch.size() || targetPatch.width() != targetWorld.width() ||
targetPatch.height() != targetWorld.height()) {
return false;
}
if (targetPatch.format() != QImage::Format_ARGB32_Premultiplied) {
targetPatch = targetPatch.convertToFormat(QImage::Format_ARGB32_Premultiplied);
}
if (srcPatch.format() != QImage::Format_ARGB32_Premultiplied) {
srcPatch = srcPatch.convertToFormat(QImage::Format_ARGB32_Premultiplied);
}
QPolygonF polyLocal;
polyLocal.reserve(ent.cutoutPolygonWorld.size());
for (const QPointF& pt : ent.cutoutPolygonWorld) {
polyLocal.append(pt - targetWorld.topLeft());
}
QPainterPath holePathLocal;
if (polyLocal.size() >= 3) {
holePathLocal.addPolygon(polyLocal);
holePathLocal.closeSubpath();
}
{
QPainter p(&targetPatch);
p.setRenderHint(QPainter::Antialiasing, true);
if (!holePathLocal.isEmpty()) {
p.setClipPath(holePathLocal);
}
p.drawImage(0, 0, srcPatch);
p.end();
}
const auto before = m_project.entities();
QString overlayRel;
if (!writeBlackholeOverlayPng(m_projectDir, id, ents[hit].blackholeOverlayPath, targetPatch, targetWorld,
&overlayRel)) {
return false;
}
ents[hit].blackholeOverlayPath = overlayRel;
ents[hit].blackholeOverlayRect = targetWorld;
ents[hit].blackholeVisible = hideBlackholeAfterFill ? false : ents[hit].blackholeVisible;
if (ents[hit].blackholeId.isEmpty()) {
ents[hit].blackholeId = QStringLiteral("blackhole-%1").arg(ents[hit].id);
}
ents[hit].blackholeResolvedBy = QStringLiteral("copy_background");
m_project.setEntities(ents);
if (!saveSingleEntityPayload(ents[hit]) || !writeIndexJsonWithoutPayloadSync()) {
m_project.setEntities(before);
return false;
}
Operation op;
op.type = Operation::Type::SetEntities;
op.label = QStringLiteral("黑洞复制填充");
op.beforeEntities = before;
op.afterEntities = ents;
pushOperation(op);
m_redoStack.clear();
return true;
}
bool ProjectWorkspace::resolveBlackholeByModelInpaint(const QString& id, const QImage& overlayPatch,
const QRect& overlayRectInBackground,
bool hideBlackholeAfterFill) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
if (overlayPatch.isNull() || !overlayRectInBackground.isValid() || overlayRectInBackground.width() <= 0 ||
overlayRectInBackground.height() <= 0) {
return false;
}
// 更新实体黑洞状态 + 记录历史
const auto before = m_project.entities();
auto ents = before;
int hit = -1;
for (int i = 0; i < ents.size(); ++i) {
if (ents[i].id == id) {
hit = i;
break;
}
}
if (hit < 0) {
return false;
}
QString overlayRel;
if (!writeBlackholeOverlayPng(m_projectDir, id, ents[hit].blackholeOverlayPath, overlayPatch,
overlayRectInBackground, &overlayRel)) {
return false;
}
ents[hit].blackholeOverlayPath = overlayRel;
ents[hit].blackholeOverlayRect = overlayRectInBackground;
ents[hit].blackholeVisible = hideBlackholeAfterFill ? false : ents[hit].blackholeVisible;
if (ents[hit].blackholeId.isEmpty()) {
ents[hit].blackholeId = QStringLiteral("blackhole-%1").arg(ents[hit].id);
}
ents[hit].blackholeResolvedBy = QStringLiteral("model_inpaint");
m_project.setEntities(ents);
if (!saveSingleEntityPayload(ents[hit]) || !writeIndexJsonWithoutPayloadSync()) {
m_project.setEntities(before);
return false;
}
Operation op;
op.type = Operation::Type::SetEntities;
op.label = QStringLiteral("黑洞模型补全");
op.beforeEntities = before;
op.afterEntities = ents;
pushOperation(op);
m_redoStack.clear();
return true;
}
bool ProjectWorkspace::setEntityVisibilityKey(const QString& id, int frame, bool visible) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
if (isNoneAnimationActive(m_project)) {
return false;
}
const auto beforeAnimations = m_project.animations();
Project::Animation* anim = activeAnimationOrCreate(m_project);
if (!anim) return false;
auto* track = findOrCreateTrack(*anim,
Project::AnimationTargetKind::Entity,
id,
Project::AnimationProperty::Visibility,
Project::AnimationValueType::Bool,
Project::AnimationInterpolation::Hold);
if (!track) return false;
upsertAnimKey(*track, frame, visible);
markAnimationDirty(anim->id);
const auto afterAnimations = m_project.animations();
m_project.setAnimations(beforeAnimations);
return applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(显隐)"));
}
bool ProjectWorkspace::removeEntityVisibilityKey(const QString& id, int frame) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
if (isNoneAnimationActive(m_project)) {
return false;
}
const auto beforeAnimations = m_project.animations();
bool changed = false;
if (Project::Animation* anim = activeAnimationOrCreate(m_project)) {
for (auto& t : anim->tracks) {
if (t.targetKind != Project::AnimationTargetKind::Entity ||
t.targetId != id ||
t.property != Project::AnimationProperty::Visibility) {
continue;
}
for (int i = 0; i < t.keys.size(); ++i) {
if (t.keys[i].frame == frame) {
t.keys.removeAt(i);
changed = true;
break;
}
}
}
if (changed) {
pruneEmptyAnimationTracks(*anim);
markAnimationDirty(anim->id);
}
}
if (!changed) return true;
const auto afterAnimations = m_project.animations();
m_project.setAnimations(beforeAnimations);
return applyAnimations(afterAnimations, true, QStringLiteral("删除关键帧(显隐)"));
}
bool ProjectWorkspace::setEntityDisplayName(const QString& id, const QString& displayName) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
const QString trimmed = displayName.trimmed();
const QString stored =
(trimmed.isEmpty() || trimmed == id) ? QString() : trimmed;
auto ents = m_project.entities();
bool found = false;
for (auto& e : ents) {
if (e.id != id) {
continue;
}
found = true;
e.displayName = stored;
break;
}
if (!found) {
return false;
}
return applyEntities(ents, true, QStringLiteral("重命名实体"));
}
bool ProjectWorkspace::setEntityIntroContent(const QString& id, const EntityIntroContent& intro) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
auto ents = m_project.entities();
bool found = false;
for (auto& e : ents) {
if (e.id != id) {
continue;
}
found = true;
e.intro = intro;
break;
}
if (!found) {
return false;
}
return applyEntities(ents, true, QStringLiteral("实体介绍"));
}
bool ProjectWorkspace::importEntityIntroImageFromFile(const QString& id, const QString& absoluteImagePath,
QString* outRelativePath) {
if (m_projectDir.isEmpty() || id.isEmpty() || absoluteImagePath.isEmpty()) {
return false;
}
const QFileInfo srcFi(absoluteImagePath);
if (!srcFi.exists() || !srcFi.isFile()) {
return false;
}
const QString entsDir = ensureEntitiesDir();
if (entsDir.isEmpty()) {
return false;
}
const QString suf = fileSuffixWithDot(absoluteImagePath);
for (int n = 0; n < 100000; ++n) {
const QString base = id + QStringLiteral("-intro-%1").arg(n) + suf;
const QString destAbs = QDir(entsDir).filePath(base);
if (!QFileInfo::exists(destAbs)) {
if (!QFile::copy(absoluteImagePath, destAbs)) {
return false;
}
const QString rel =
QString::fromUtf8(kAssetsDirName) + QStringLiteral("/entities/") + base;
if (outRelativePath) {
*outRelativePath = rel;
}
return true;
}
}
return false;
}
bool ProjectWorkspace::setEntityUserScale(const QString& id, double userScale, int keyframeAtFrame) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
const double u = std::clamp(userScale, 0.05, 20.0);
const bool writeAnimated = keyframeAtFrame >= 0 && !isNoneAnimationActive(m_project);
if (writeAnimated) {
return setEntityUserScaleKey(id, keyframeAtFrame, u);
}
auto ents = m_project.entities();
bool found = false;
for (auto& e : ents) {
if (e.id != id) {
continue;
}
found = true;
const bool baseSame = qFuzzyCompare(e.userScale + 1.0, u + 1.0);
if (baseSame) {
return true;
}
e.userScale = u;
break;
}
if (!found) {
return false;
}
return applyEntities(ents, true, QStringLiteral("整体缩放"));
}
bool ProjectWorkspace::setEntityIgnoreDistanceScale(const QString& id, bool on) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
auto ents = m_project.entities();
bool found = false;
bool changed = false;
for (auto& e : ents) {
if (e.id != id) continue;
found = true;
if (e.ignoreDistanceScale != on) {
e.ignoreDistanceScale = on;
changed = true;
}
break;
}
if (!found) return false;
if (!changed) return true;
return applyEntities(ents, true, QStringLiteral("距离缩放开关"));
}
bool ProjectWorkspace::setEntityParent(const QString& id, const QString& parentId, const QPointF& parentOffsetWorld) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
if (id == parentId) {
return false;
}
auto ents = m_project.entities();
QSet<QString> ids;
for (const auto& e : ents) ids.insert(e.id);
if (!parentId.isEmpty() && !ids.contains(parentId)) {
return false;
}
const int frameStart = std::max(0, m_project.frameStart());
// 父子关系切换时:需要把 location 关键帧在“绝对世界坐标”与“相对父对象偏移”之间互转,
// 否则同一组 key 会被用不同坐标系解释,造成跳跃。
auto fetchOrigin = [](const core::eval::ResolvedProjectFrame& rf, const QString& anyId) -> QPointF {
if (anyId.isEmpty()) return QPointF();
for (const auto& re : rf.entities) {
if (re.entity.id == anyId) return re.entity.originWorld;
}
for (const auto& rt : rf.tools) {
if (rt.tool.id == anyId) return rt.tool.originWorld;
}
return QPointF();
};
auto parentOriginAt = [&](const QString& pid, int f) -> QPointF {
if (pid.isEmpty()) return QPointF();
const auto rf = core::eval::evaluateAtFrame(m_project, f, 10);
return fetchOrigin(rf, pid);
};
auto convertKeys = [&](QVector<Project::Entity::KeyframeVec2>& keys,
const QString& oldPid,
const QString& newPid) {
if (keys.isEmpty()) return;
for (auto& k : keys) {
const QPointF oldParentO = oldPid.isEmpty() ? QPointF() : parentOriginAt(oldPid, k.frame);
const QPointF world = oldPid.isEmpty() ? k.value : (oldParentO + k.value);
const QPointF newParentO = newPid.isEmpty() ? QPointF() : parentOriginAt(newPid, k.frame);
k.value = newPid.isEmpty() ? world : (world - newParentO);
}
};
auto convertAnimTrackVec2 = [&](Project::Animation& anim,
Project::AnimationTargetKind kind,
const QString& targetId,
Project::AnimationProperty prop,
const QString& oldPid,
const QString& newPid) {
for (auto& t : anim.tracks) {
if (t.targetKind != kind || t.targetId != targetId || t.property != prop) continue;
if (t.valueType != Project::AnimationValueType::Vec2) continue;
if (t.keys.isEmpty()) continue;
for (auto& k : t.keys) {
const QPointF oldParentO = oldPid.isEmpty() ? QPointF() : parentOriginAt(oldPid, k.frame);
const QPointF world = oldPid.isEmpty() ? k.vec2Value : (oldParentO + k.vec2Value);
const QPointF newParentO = newPid.isEmpty() ? QPointF() : parentOriginAt(newPid, k.frame);
k.vec2Value = newPid.isEmpty() ? world : (world - newParentO);
}
}
};
bool found = false;
for (auto& e : ents) {
if (e.id != id) continue;
found = true;
const QString oldPid = e.parentId;
const QPointF oldBaseStored = oldPid.isEmpty() ? e.originWorld : e.parentOffsetWorld;
const QPointF oldParentOStart = oldPid.isEmpty() ? QPointF() : parentOriginAt(oldPid, frameStart);
const QPointF baseWorldAtStart = oldPid.isEmpty() ? oldBaseStored : (oldParentOStart + oldBaseStored);
// 转换内嵌 key(兼容旧数据来源/工具逻辑)
convertKeys(e.locationKeys, oldPid, parentId);
// 转换新动画轨道(作为运行时求值的真相源)
if (Project::Animation* anim = activeAnimationOrCreate(m_project)) {
convertAnimTrackVec2(*anim,
Project::AnimationTargetKind::Entity,
e.id,
Project::AnimationProperty::Position,
oldPid,
parentId);
markAnimationDirty(anim->id);
}
// 更新父子信息
e.parentId = parentId;
// 更新基准值:无 key/或 key 覆盖不到的区间仍应保持世界位置连续
if (parentId.isEmpty()) {
e.originWorld = baseWorldAtStart;
e.parentOffsetWorld = QPointF();
} else {
const QPointF newParentOStart = parentOriginAt(parentId, frameStart);
e.parentOffsetWorld = baseWorldAtStart - newParentOStart;
// cycle/parent missing 时 resolve 会回退到 sampledOriginForEntity;令其也尽量不跳
e.originWorld = baseWorldAtStart;
}
// 若调用方传入了当前帧下的 parentOffsetWorld(来自 UI 计算),在“绑定父对象”场景下优先采用,
// 保证操作当下立刻不跳(关键帧已整体转换,后续帧也保持一致)。
if (!parentId.isEmpty()) {
e.parentOffsetWorld = parentOffsetWorld;
}
break;
}
if (!found) return false;
return applyEntities(ents, true, QStringLiteral("设置父实体"));
}
bool ProjectWorkspace::normalizeAllEntityOriginsToCentroids(int frame) {
if (m_projectDir.isEmpty() || frame < 0) {
return false;
}
auto ents = m_project.entities();
bool changed = false;
for (auto& e : ents) {
const double s = combinedVisualScaleForEntity(e, frame);
if (snapEntityOriginToCentroidInPlace(m_project, e, frame, s)) {
changed = true;
}
}
if (!changed) {
return true;
}
return applyEntities(ents, true, QStringLiteral("对齐实体中心"));
}
QPointF ProjectWorkspace::entityCentroidWorldAtFrame(const QString& id, int frame) const {
if (id.isEmpty() || frame < 0) {
return {};
}
const auto rf = core::eval::evaluateAtFrame(m_project, frame, 10);
for (const auto& re : rf.entities) {
if (re.entity.id != id) {
continue;
}
const double s = combinedVisualScaleForEntity(re.entity, frame);
return entityPolygonCentroidWorld(m_project, re.entity, frame, s);
}
for (const auto& rt : rf.tools) {
if (rt.tool.id == id) {
return rt.tool.originWorld;
}
}
return {};
}
bool ProjectWorkspace::setEntityParentOffsetWorld(const QString& id, const QPointF& offsetWorld) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
auto ents = m_project.entities();
for (auto& e : ents) {
if (e.id != id) {
continue;
}
if (e.parentId.isEmpty()) {
return false;
}
e.parentOffsetWorld = offsetWorld;
return applyEntities(ents, true, QStringLiteral("相对父中心"));
}
return false;
}
bool ProjectWorkspace::moveEntityCentroidTo(const QString& id, int frame, const QPointF& targetCentroidWorld,
double sTotal, bool autoKeyLocation) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0 || sTotal <= 1e-9) {
return false;
}
auto ents = m_project.entities();
for (const auto& e : ents) {
if (e.id == id) {
const QPointF c = entityPolygonCentroidWorld(m_project, e, frame, sTotal);
const QPointF delta = targetCentroidWorld - c;
return moveEntityBy(id, delta, frame, autoKeyLocation);
}
}
return false;
}
bool ProjectWorkspace::reanchorEntityPivot(const QString& id, int frame, const QPointF& newPivotWorld, double sTotal) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0 || sTotal <= 1e-9) {
return false;
}
auto ents = m_project.entities();
QPointF pivotDelta;
bool found = false;
for (auto& e : ents) {
if (e.id != id) {
continue;
}
found = true;
const QPointF O_anim = resolvedOriginAtFrame(m_project, e.id, frame);
QVector<QPointF> polyWorld;
polyWorld.reserve(e.polygonLocal.size());
for (const QPointF& lp : e.polygonLocal) {
polyWorld.push_back(O_anim + lp * sTotal);
}
if (polyWorld.size() < 3) {
return false;
}
// 按目标枢轴精确重锚:外形世界坐标不变;形心不变,仅改变换原点
const QPointF O_new = newPivotWorld;
const QPointF I_disp = O_anim + (e.imageTopLeftWorld - e.originWorld) * sTotal;
const QPointF d = O_new - O_anim;
pivotDelta = d;
QVector<QPointF> newLocal;
newLocal.reserve(polyWorld.size());
for (const QPointF& p : polyWorld) {
newLocal.push_back((p - O_new) / sTotal);
}
for (auto& k : e.locationKeys) {
k.value += d;
}
if (!e.parentId.isEmpty()) {
e.parentOffsetWorld += d;
}
e.originWorld += d;
e.polygonLocal = std::move(newLocal);
e.imageTopLeftWorld = e.originWorld + (I_disp - O_new) / sTotal;
break;
}
if (!found) {
return false;
}
QVector<Project::Animation> anims = m_project.animations();
const bool shiftedAnim = shiftEntityPositionAnimKeys(anims, id, pivotDelta);
if (!applyEntities(ents, true, QStringLiteral("属性:枢轴"))) {
return false;
}
if (shiftedAnim && !applyAnimations(anims, true, QStringLiteral("属性:枢轴(位移轨)"))) {
return false;
}
return true;
}
bool ProjectWorkspace::reorderEntitiesById(const QStringList& idsInOrder) {
if (m_projectDir.isEmpty()) {
return false;
}
auto ents = m_project.entities();
if (ents.isEmpty()) {
return true;
}
if (idsInOrder.isEmpty()) {
return false;
}
// 构建 id->entity 映射,并确保 ids 覆盖全部实体且无重复
QHash<QString, Project::Entity> map;
map.reserve(ents.size());
for (const auto& e : ents) {
map.insert(e.id, e);
}
if (map.size() != ents.size()) {
return false;
}
if (idsInOrder.size() != ents.size()) {
return false;
}
QVector<Project::Entity> reordered;
reordered.reserve(ents.size());
QSet<QString> seen;
for (const auto& id : idsInOrder) {
if (id.isEmpty() || seen.contains(id) || !map.contains(id)) {
return false;
}
seen.insert(id);
reordered.push_back(map.value(id));
}
// 若顺序没变,直接返回
bool same = true;
for (int i = 0; i < ents.size(); ++i) {
if (ents[i].id != reordered[i].id) {
same = false;
break;
}
}
if (same) return true;
return applyEntities(reordered, true, QStringLiteral("排序实体"));
}
bool ProjectWorkspace::moveEntityBy(const QString& id, const QPointF& delta, int currentFrame, bool autoKeyLocation) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
if (qFuzzyIsNull(delta.x()) && qFuzzyIsNull(delta.y())) {
return true;
}
auto ents = m_project.entities();
bool found = false;
bool changedStatic = false;
for (auto& e : ents) {
if (e.id != id) {
continue;
}
found = true;
// 「无」动画为静态基底,不写位置关键帧。
if (autoKeyLocation && currentFrame >= 0 && !isNoneAnimationActive(m_project)) {
Project::Animation* anim = activeAnimationOrCreate(m_project);
if (!anim) return false;
// 关键:用“当前帧求值后的基准”写回关键帧,避免拖拽预览(求值态)与落盘(静态态)不一致导致松手瞬移。
const QPointF worldBase = resolvedOriginAtFrame(m_project, e.id, currentFrame);
QPointF base = worldBase;
if (!e.parentId.isEmpty()) {
const QPointF parentWorld = resolvedOriginAtFrame(m_project, e.parentId, currentFrame);
base = worldBase - parentWorld; // 相对父对象偏移
}
auto* track = findOrCreateTrack(*anim,
Project::AnimationTargetKind::Entity,
e.id,
Project::AnimationProperty::Position,
Project::AnimationValueType::Vec2,
Project::AnimationInterpolation::Linear);
upsertAnimKey(*track, currentFrame, base + delta);
markAnimationDirty(anim->id);
return writeIndexJson();
}
// 父子关系:绑定父对象时,位置曲线表示“相对父对象偏移”。
if (!e.parentId.isEmpty()) {
e.parentOffsetWorld += delta;
changedStatic = true;
break;
}
e.originWorld += delta;
e.imageTopLeftWorld += delta;
changedStatic = true;
break;
}
if (!found) {
return false;
}
if (!changedStatic) {
return true;
}
return applyEntities(ents, true, QStringLiteral("移动实体"));
}
bool ProjectWorkspace::addTool(const Project::Tool& tool) {
if (m_projectDir.isEmpty()) {
return false;
}
if (tool.id.isEmpty()) {
return false;
}
auto tools = m_project.tools();
for (const auto& t : tools) {
if (t.id == tool.id) {
return false;
}
}
tools.push_back(tool);
return applyTools(tools, true, QStringLiteral("添加工具"));
}
bool ProjectWorkspace::setCameraVisible(const QString& id, bool on) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
auto cams = m_project.cameras();
bool found = false;
bool changed = false;
for (auto& c : cams) {
if (c.id != id) continue;
found = true;
if (c.visible != on) {
c.visible = on;
changed = true;
}
break;
}
if (!found) return false;
if (!changed) return true;
return applyCameras(cams, true, on ? QStringLiteral("显示摄像机") : QStringLiteral("隐藏摄像机"));
}
bool ProjectWorkspace::setActiveCameraId(const QString& id) {
if (m_projectDir.isEmpty()) {
return false;
}
if (!id.isEmpty()) {
bool ok = false;
for (const auto& c : m_project.cameras()) {
if (c.id == id) {
ok = true;
break;
}
}
if (!ok) {
return false;
}
}
if (m_project.activeCameraId() == id) {
return true;
}
m_project.setActiveCameraId(id);
return writeIndexJson();
}
bool ProjectWorkspace::setCameraDisplayName(const QString& id, const QString& displayName) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
auto cams = m_project.cameras();
bool found = false;
for (auto& c : cams) {
if (c.id != id) continue;
found = true;
c.displayName = displayName;
break;
}
if (!found) return false;
return applyCameras(cams, true, QStringLiteral("摄像机名称"));
}
bool ProjectWorkspace::setCameraCenterWorld(const QString& id, const QPointF& centerWorld) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
auto cams = m_project.cameras();
bool found = false;
for (auto& c : cams) {
if (c.id != id) continue;
found = true;
c.centerWorld = centerWorld;
break;
}
if (!found) return false;
return applyCameras(cams, true, QStringLiteral("摄像机位置"));
}
bool ProjectWorkspace::setCameraViewScaleValue(const QString& id, double viewScale, int keyframeAtFrame) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
const double v = std::clamp(viewScale, 1e-6, 1e3);
const bool writeAnimated = keyframeAtFrame >= 0 && !isNoneAnimationActive(m_project);
if (writeAnimated) {
return setCameraScaleKey(id, keyframeAtFrame, v);
}
auto cams = m_project.cameras();
bool found = false;
for (auto& c : cams) {
if (c.id != id) continue;
found = true;
const bool baseSame = qFuzzyCompare(c.viewScale + 1.0, v + 1.0);
if (baseSame) {
return true;
}
c.viewScale = v;
break;
}
if (!found) return false;
return applyCameras(cams, true, QStringLiteral("摄像机缩放"));
}
bool ProjectWorkspace::moveCameraBy(const QString& id, const QPointF& delta, int currentFrame, bool autoKeyLocation) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
if (qFuzzyIsNull(delta.x()) && qFuzzyIsNull(delta.y())) {
return true;
}
auto cams = m_project.cameras();
bool found = false;
bool changedStatic = false;
for (auto& c : cams) {
if (c.id != id) continue;
found = true;
if (autoKeyLocation && currentFrame >= 0 && !isNoneAnimationActive(m_project)) {
Project::Animation* anim = activeAnimationOrCreate(m_project);
if (!anim) return false;
const QPointF base = resolvedOriginAtFrame(m_project, c.id, currentFrame);
auto* track = findOrCreateTrack(*anim,
Project::AnimationTargetKind::Camera,
c.id,
Project::AnimationProperty::Position,
Project::AnimationValueType::Vec2,
Project::AnimationInterpolation::Linear);
upsertAnimKey(*track, currentFrame, base + delta);
markAnimationDirty(anim->id);
// 同步到摄像机内嵌 keys(兼容旧工程/工具逻辑)
upsertKey(c.locationKeys, currentFrame, base + delta);
return writeIndexJson();
}
c.centerWorld += delta;
changedStatic = true;
break;
}
if (!found) return false;
if (!changedStatic) return true;
return applyCameras(cams, true, QStringLiteral("移动摄像机"));
}
bool ProjectWorkspace::setCameraLocationKey(const QString& id, int frame, const QPointF& centerWorld) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
if (isNoneAnimationActive(m_project)) {
return false;
}
const auto beforeAnimations = m_project.animations();
Project::Animation* anim = activeAnimationOrCreate(m_project);
if (!anim) return false;
auto* track = findOrCreateTrack(*anim,
Project::AnimationTargetKind::Camera,
id,
Project::AnimationProperty::Position,
Project::AnimationValueType::Vec2,
Project::AnimationInterpolation::Linear);
upsertAnimKey(*track, frame, centerWorld);
markAnimationDirty(anim->id);
const auto afterAnimations = m_project.animations();
m_project.setAnimations(beforeAnimations);
return applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(摄像机位置)"));
}
bool ProjectWorkspace::setCameraScaleKey(const QString& id, int frame, double viewScale) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
if (isNoneAnimationActive(m_project)) {
return false;
}
const double v = std::clamp(viewScale, 1e-6, 1e3);
const auto beforeAnimations = m_project.animations();
Project::Animation* anim = activeAnimationOrCreate(m_project);
if (!anim) return false;
auto* track = findOrCreateTrack(*anim,
Project::AnimationTargetKind::Camera,
id,
Project::AnimationProperty::CameraScale,
Project::AnimationValueType::Double,
Project::AnimationInterpolation::Linear);
upsertAnimKey(*track, frame, v);
markAnimationDirty(anim->id);
const auto afterAnimations = m_project.animations();
m_project.setAnimations(beforeAnimations);
return applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(摄像机缩放)"));
}
bool ProjectWorkspace::removeCameraLocationKey(const QString& id, int frame) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
if (isNoneAnimationActive(m_project)) {
return false;
}
const auto beforeAnimations = m_project.animations();
bool changed = false;
if (Project::Animation* anim = activeAnimationOrCreate(m_project)) {
for (auto& t : anim->tracks) {
if (t.targetKind != Project::AnimationTargetKind::Camera ||
t.targetId != id ||
t.property != Project::AnimationProperty::Position) {
continue;
}
for (int i = 0; i < t.keys.size(); ++i) {
if (t.keys[i].frame == frame) {
t.keys.removeAt(i);
changed = true;
break;
}
}
}
if (changed) {
pruneEmptyAnimationTracks(*anim);
markAnimationDirty(anim->id);
}
}
if (!changed) return true;
const auto afterAnimations = m_project.animations();
m_project.setAnimations(beforeAnimations);
return applyAnimations(afterAnimations, true, QStringLiteral("删除关键帧(摄像机位置)"));
}
bool ProjectWorkspace::removeCameraScaleKey(const QString& id, int frame) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
if (isNoneAnimationActive(m_project)) {
return false;
}
const auto beforeAnimations = m_project.animations();
bool changed = false;
if (Project::Animation* anim = activeAnimationOrCreate(m_project)) {
for (auto& t : anim->tracks) {
if (t.targetKind != Project::AnimationTargetKind::Camera ||
t.targetId != id ||
t.property != Project::AnimationProperty::CameraScale) {
continue;
}
for (int i = 0; i < t.keys.size(); ++i) {
if (t.keys[i].frame == frame) {
t.keys.removeAt(i);
changed = true;
break;
}
}
}
if (changed) {
pruneEmptyAnimationTracks(*anim);
markAnimationDirty(anim->id);
}
}
if (!changed) return true;
const auto afterAnimations = m_project.animations();
m_project.setAnimations(beforeAnimations);
return applyAnimations(afterAnimations, true, QStringLiteral("删除关键帧(摄像机缩放)"));
}
bool ProjectWorkspace::setToolVisible(const QString& id, bool on) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
auto tools = m_project.tools();
bool found = false;
bool changed = false;
for (auto& t : tools) {
if (t.id != id) continue;
found = true;
if (t.visible != on) {
t.visible = on;
changed = true;
}
break;
}
if (!found) return false;
if (!changed) return true;
return applyTools(tools, true, on ? QStringLiteral("显示工具") : QStringLiteral("隐藏工具"));
}
bool ProjectWorkspace::setToolText(const QString& id, const QString& text) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
auto tools = m_project.tools();
bool found = false;
for (auto& t : tools) {
if (t.id != id) continue;
found = true;
t.text = text;
break;
}
if (!found) return false;
return applyTools(tools, true, QStringLiteral("工具文本"));
}
bool ProjectWorkspace::setToolBubblePointerT01(const QString& id, double t01) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
const double tClamped = std::clamp(t01, 0.0, 1.0);
auto tools = m_project.tools();
bool found = false;
for (auto& tool : tools) {
if (tool.id != id) continue;
found = true;
tool.bubblePointerT01 = tClamped;
break;
}
if (!found) return false;
return applyTools(tools, true, QStringLiteral("气泡指向位置"));
}
bool ProjectWorkspace::setToolFontPx(const QString& id, int fontPx) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
const int px = std::clamp(fontPx, 8, 120);
auto tools = m_project.tools();
bool found = false;
for (auto& t : tools) {
if (t.id != id) continue;
found = true;
t.fontPx = px;
break;
}
if (!found) return false;
return applyTools(tools, true, QStringLiteral("工具字号"));
}
bool ProjectWorkspace::setToolAlign(const QString& id, core::Project::Tool::TextAlign align) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
auto tools = m_project.tools();
bool found = false;
for (auto& t : tools) {
if (t.id != id) continue;
found = true;
t.align = align;
break;
}
if (!found) return false;
return applyTools(tools, true, QStringLiteral("工具对齐"));
}
bool ProjectWorkspace::setToolVisibilityKey(const QString& id, int frame, bool visible) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
if (isNoneAnimationActive(m_project)) {
return false;
}
const auto beforeAnimations = m_project.animations();
Project::Animation* anim = activeAnimationOrCreate(m_project);
if (!anim) return false;
auto* track = findOrCreateTrack(*anim,
Project::AnimationTargetKind::Tool,
id,
Project::AnimationProperty::Visibility,
Project::AnimationValueType::Bool,
Project::AnimationInterpolation::Hold);
upsertAnimKey(*track, frame, visible);
markAnimationDirty(anim->id);
const auto afterAnimations = m_project.animations();
m_project.setAnimations(beforeAnimations);
return applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(工具显隐)"));
}
bool ProjectWorkspace::setToolPriorityKey(const QString& id, int frame, int priority) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
if (isNoneAnimationActive(m_project)) {
auto tools = m_project.tools();
for (auto& t : tools) {
if (t.id != id) continue;
t.priority = priority;
return applyTools(tools, true, QStringLiteral("工具优先级"));
}
return false;
}
const double v = std::clamp<double>(double(priority), -1e6, 1e6);
const auto beforeAnimations = m_project.animations();
Project::Animation* anim = activeAnimationOrCreate(m_project);
if (!anim) return false;
auto* track = findOrCreateTrack(*anim,
Project::AnimationTargetKind::Tool,
id,
Project::AnimationProperty::Priority,
Project::AnimationValueType::Double,
Project::AnimationInterpolation::Hold);
upsertAnimKey(*track, frame, v);
markAnimationDirty(anim->id);
const auto afterAnimations = m_project.animations();
m_project.setAnimations(beforeAnimations);
return applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(工具优先级)"));
}
bool ProjectWorkspace::removeToolVisibilityKey(const QString& id, int frame) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
if (isNoneAnimationActive(m_project)) {
return false;
}
const auto beforeAnimations = m_project.animations();
bool changed = false;
if (Project::Animation* anim = activeAnimationOrCreate(m_project)) {
for (auto& t : anim->tracks) {
if (t.targetKind != Project::AnimationTargetKind::Tool ||
t.targetId != id ||
t.property != Project::AnimationProperty::Visibility) {
continue;
}
for (int i = 0; i < t.keys.size(); ++i) {
if (t.keys[i].frame == frame) {
t.keys.removeAt(i);
changed = true;
break;
}
}
}
if (changed) {
pruneEmptyAnimationTracks(*anim);
markAnimationDirty(anim->id);
}
}
if (!changed) return true;
const auto afterAnimations = m_project.animations();
m_project.setAnimations(beforeAnimations);
return applyAnimations(afterAnimations, true, QStringLiteral("删除关键帧(工具显隐)"));
}
bool ProjectWorkspace::setToolParent(const QString& id, const QString& parentId, const QPointF& parentOffsetWorld) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
if (id == parentId) {
return false;
}
auto tools = m_project.tools();
const int frameStart = std::max(0, m_project.frameStart());
auto fetchOrigin = [](const core::eval::ResolvedProjectFrame& rf, const QString& anyId) -> QPointF {
if (anyId.isEmpty()) return QPointF();
for (const auto& re : rf.entities) {
if (re.entity.id == anyId) return re.entity.originWorld;
}
for (const auto& rt : rf.tools) {
if (rt.tool.id == anyId) return rt.tool.originWorld;
}
return QPointF();
};
auto parentOriginAt = [&](const QString& pid, int f) -> QPointF {
if (pid.isEmpty()) return QPointF();
const auto rf = core::eval::evaluateAtFrame(m_project, f, 10);
return fetchOrigin(rf, pid);
};
auto convertKeys = [&](QVector<Project::Entity::KeyframeVec2>& keys,
const QString& oldPid,
const QString& newPid) {
if (keys.isEmpty()) return;
for (auto& k : keys) {
const QPointF oldParentO = oldPid.isEmpty() ? QPointF() : parentOriginAt(oldPid, k.frame);
const QPointF world = oldPid.isEmpty() ? k.value : (oldParentO + k.value);
const QPointF newParentO = newPid.isEmpty() ? QPointF() : parentOriginAt(newPid, k.frame);
k.value = newPid.isEmpty() ? world : (world - newParentO);
}
};
bool found = false;
for (auto& t : tools) {
if (t.id != id) continue;
found = true;
const QString oldPid = t.parentId;
const QPointF oldBaseStored = oldPid.isEmpty() ? t.originWorld : t.parentOffsetWorld;
const QPointF oldParentOStart = oldPid.isEmpty() ? QPointF() : parentOriginAt(oldPid, frameStart);
const QPointF baseWorldAtStart = oldPid.isEmpty() ? oldBaseStored : (oldParentOStart + oldBaseStored);
convertKeys(t.locationKeys, oldPid, parentId);
if (Project::Animation* anim = activeAnimationOrCreate(m_project)) {
for (auto& tr : anim->tracks) {
if (tr.targetKind != Project::AnimationTargetKind::Tool ||
tr.targetId != t.id ||
tr.property != Project::AnimationProperty::Position ||
tr.valueType != Project::AnimationValueType::Vec2) {
continue;
}
for (auto& k : tr.keys) {
const QPointF oldParentO = oldPid.isEmpty() ? QPointF() : parentOriginAt(oldPid, k.frame);
const QPointF world = oldPid.isEmpty() ? k.vec2Value : (oldParentO + k.vec2Value);
const QPointF newParentO = parentId.isEmpty() ? QPointF() : parentOriginAt(parentId, k.frame);
k.vec2Value = parentId.isEmpty() ? world : (world - newParentO);
}
}
markAnimationDirty(anim->id);
}
t.parentId = parentId;
if (parentId.isEmpty()) {
t.originWorld = baseWorldAtStart;
t.parentOffsetWorld = QPointF();
} else {
const QPointF newParentOStart = parentOriginAt(parentId, frameStart);
t.parentOffsetWorld = baseWorldAtStart - newParentOStart;
t.originWorld = baseWorldAtStart;
t.parentOffsetWorld = parentOffsetWorld; // 同上:优先确保操作当下不跳
}
break;
}
if (!found) return false;
return applyTools(tools, true, QStringLiteral("设置工具父对象"));
}
bool ProjectWorkspace::moveToolBy(const QString& id, const QPointF& delta, int currentFrame, bool autoKeyLocation) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
if (qFuzzyIsNull(delta.x()) && qFuzzyIsNull(delta.y())) {
return true;
}
auto tools = m_project.tools();
bool found = false;
bool changedStatic = false;
for (auto& t : tools) {
if (t.id != id) continue;
found = true;
if (autoKeyLocation && currentFrame >= 0 && !isNoneAnimationActive(m_project)) {
Project::Animation* anim = activeAnimationOrCreate(m_project);
if (!anim) return false;
const QPointF worldBase = resolvedOriginAtFrame(m_project, t.id, currentFrame);
QPointF base = worldBase;
if (!t.parentId.isEmpty()) {
const QPointF parentWorld = resolvedOriginAtFrame(m_project, t.parentId, currentFrame);
base = worldBase - parentWorld;
}
auto* track = findOrCreateTrack(*anim,
Project::AnimationTargetKind::Tool,
t.id,
Project::AnimationProperty::Position,
Project::AnimationValueType::Vec2,
Project::AnimationInterpolation::Linear);
upsertAnimKey(*track, currentFrame, base + delta);
markAnimationDirty(anim->id);
// 同步到工具内嵌 keys(兼容旧工程/工具逻辑)
upsertKey(t.locationKeys, currentFrame, base + delta);
return writeIndexJson();
}
if (!t.parentId.isEmpty()) {
t.parentOffsetWorld += delta;
changedStatic = true;
break;
}
t.originWorld += delta;
changedStatic = true;
break;
}
if (!found) return false;
if (!changedStatic) return true;
return applyTools(tools, true, QStringLiteral("移动工具"));
}
bool ProjectWorkspace::setEntityLocationKey(const QString& id, int frame, const QPointF& originWorld) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
if (isNoneAnimationActive(m_project)) {
return false;
}
QPointF keyValue = originWorld;
for (const auto& e : m_project.entities()) {
if (e.id == id && !e.parentId.isEmpty()) {
const QPointF parentWorld = resolvedOriginAtFrame(m_project, e.parentId, frame);
keyValue = originWorld - parentWorld;
break;
}
}
const auto beforeAnimations = m_project.animations();
Project::Animation* anim = activeAnimationOrCreate(m_project);
if (!anim) return false;
auto* track = findOrCreateTrack(*anim,
Project::AnimationTargetKind::Entity,
id,
Project::AnimationProperty::Position,
Project::AnimationValueType::Vec2,
Project::AnimationInterpolation::Linear);
if (!track) return false;
upsertAnimKey(*track, frame, keyValue);
markAnimationDirty(anim->id);
const auto afterAnimations = m_project.animations();
m_project.setAnimations(beforeAnimations);
return applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(位置)"));
}
bool ProjectWorkspace::setEntityDepthScaleKey(const QString& id, int frame, double value01) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
if (isNoneAnimationActive(m_project)) {
return false;
}
const double v = std::clamp(value01, 0.0, 1.0);
const auto beforeAnimations = m_project.animations();
Project::Animation* anim = activeAnimationOrCreate(m_project);
if (!anim) return false;
auto* track = findOrCreateTrack(*anim,
Project::AnimationTargetKind::Entity,
id,
Project::AnimationProperty::DepthScale,
Project::AnimationValueType::Double,
Project::AnimationInterpolation::Linear);
if (!track) return false;
upsertAnimKey(*track, frame, v);
markAnimationDirty(anim->id);
const auto afterAnimations = m_project.animations();
m_project.setAnimations(beforeAnimations);
return applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(深度缩放)"));
}
bool ProjectWorkspace::setEntityUserScaleKey(const QString& id, int frame, double userScale) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
if (isNoneAnimationActive(m_project)) {
return false;
}
const double v = std::clamp(userScale, 1e-6, 1e3);
const auto beforeAnimations = m_project.animations();
Project::Animation* anim = activeAnimationOrCreate(m_project);
if (!anim) return false;
auto* track = findOrCreateTrack(*anim,
Project::AnimationTargetKind::Entity,
id,
Project::AnimationProperty::UserScale,
Project::AnimationValueType::Double,
Project::AnimationInterpolation::Linear);
if (!track) return false;
upsertAnimKey(*track, frame, v);
markAnimationDirty(anim->id);
const auto afterAnimations = m_project.animations();
m_project.setAnimations(beforeAnimations);
return applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(整体缩放)"));
}
bool ProjectWorkspace::setEntityPriorityKey(const QString& id, int frame, int priority) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
if (isNoneAnimationActive(m_project)) {
// “无”动画:直接写静态值
auto ents = m_project.entities();
for (auto& e : ents) {
if (e.id != id) continue;
e.priority = priority;
return applyEntities(ents, true, QStringLiteral("优先级"));
}
return false;
}
const double v = std::clamp<double>(double(priority), -1e6, 1e6);
const auto beforeAnimations = m_project.animations();
Project::Animation* anim = activeAnimationOrCreate(m_project);
if (!anim) return false;
auto* track = findOrCreateTrack(*anim,
Project::AnimationTargetKind::Entity,
id,
Project::AnimationProperty::Priority,
Project::AnimationValueType::Double,
Project::AnimationInterpolation::Hold);
if (!track) return false;
upsertAnimKey(*track, frame, v);
markAnimationDirty(anim->id);
const auto afterAnimations = m_project.animations();
m_project.setAnimations(beforeAnimations);
return applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(优先级)"));
}
bool ProjectWorkspace::setEntityDefaultImage(const QString& id, const QImage& image, QString* outRelPath) {
if (outRelPath) outRelPath->clear();
if (m_projectDir.isEmpty() || id.isEmpty() || image.isNull()) {
return false;
}
QByteArray png;
{
QBuffer buf(&png);
if (!buf.open(QIODevice::WriteOnly) || !image.save(&buf, "PNG")) {
return false;
}
}
auto ents = m_project.entities();
bool found = false;
for (auto& e : ents) {
if (e.id != id) continue;
found = true;
e.imagePath.clear();
e.defaultImagePng = png;
break;
}
if (!found) return false;
if (!applyEntities(ents, true, QStringLiteral("更换实体本体图"))) {
return false;
}
if (outRelPath) *outRelPath = QString();
return true;
}
bool ProjectWorkspace::setEntityImageFrame(const QString& id, int frame, const QImage& image, QString* outRelPath) {
if (outRelPath) outRelPath->clear();
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0 || image.isNull()) {
return false;
}
if (isNoneAnimationActive(m_project)) {
return false;
}
// 逐帧动画帧:按用户要求存入二进制(.hfa),不写出单独的图片文件。
QByteArray png;
{
QBuffer buf(&png);
if (!buf.open(QIODevice::WriteOnly) || !image.save(&buf, "PNG")) {
return false;
}
}
const auto beforeAnimations = m_project.animations();
Project::Animation* anim = activeAnimationOrCreate(m_project);
if (!anim) return false;
Project::AnimationTrack* track = findOrCreateTrack(
*anim,
Project::AnimationTargetKind::Entity,
id,
Project::AnimationProperty::Sprite,
Project::AnimationValueType::PngBytes,
Project::AnimationInterpolation::Hold);
if (!track) return false;
upsertAnimKey(*track, frame, png);
markAnimationDirty(anim->id);
const auto afterAnimations = m_project.animations();
m_project.setAnimations(beforeAnimations);
if (!applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(图像)"))) return false;
if (outRelPath) *outRelPath = QString(); // 不再产生资源路径
return true;
}
bool ProjectWorkspace::setEntityImageFramePath(const QString& id, int frame, const QString& relativePath) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
if (isNoneAnimationActive(m_project)) {
return false;
}
Q_UNUSED(relativePath);
// 不再支持路径型贴图关键帧(仅支持二进制 PNG bytes)。
return false;
}
QVector<Project::Entity::ImageFrame> ProjectWorkspace::entitySpriteFrames(const QString& id) const {
const Project::Animation* anim = m_project.activeAnimationOrNull();
if (anim) {
return spriteFramesForEntity(*anim, id);
}
return {};
}
QString ProjectWorkspace::entityImagePathAt(const QString& id, int frame) const {
const Project::Entity* entity = nullptr;
for (const auto& e : m_project.entities()) {
if (e.id == id) {
entity = &e;
break;
}
}
if (!entity) return {};
const QByteArray png = entityImagePngAt(id, frame);
if (png.isEmpty()) return {};
return QStringLiteral("pngb64:") + QString::fromLatin1(png.toBase64());
}
QByteArray ProjectWorkspace::entityImagePngAt(const QString& id, int frame) const {
const Project::Entity* entity = nullptr;
for (const auto& e : m_project.entities()) {
if (e.id == id) {
entity = &e;
break;
}
}
if (!entity) return {};
if (const Project::Animation* anim = m_project.activeAnimationOrNull()) {
for (const auto& t : anim->tracks) {
if (t.targetKind != Project::AnimationTargetKind::Entity ||
t.targetId != id ||
t.property != Project::AnimationProperty::Sprite) {
continue;
}
if (t.valueType != Project::AnimationValueType::PngBytes) {
continue;
}
QByteArray best;
int bestFrame = -1;
for (const auto& k : t.keys) {
if (k.frame <= frame && k.frame >= bestFrame && !k.bytesValue.isEmpty()) {
bestFrame = k.frame;
best = k.bytesValue;
}
}
if (!best.isEmpty()) return best;
}
}
return entity->defaultImagePng;
}
bool ProjectWorkspace::clearEntityImageFramesInRange(const QString& id, int startFrame, int endFrame) {
if (m_projectDir.isEmpty() || id.isEmpty()) return false;
if (isNoneAnimationActive(m_project)) {
return false;
}
const int a = std::min(startFrame, endFrame);
const int b = std::max(startFrame, endFrame);
const auto beforeAnimations = m_project.animations();
bool changed = false;
if (Project::Animation* anim = activeAnimationOrCreate(m_project)) {
for (auto& t : anim->tracks) {
if (t.targetKind != Project::AnimationTargetKind::Entity ||
t.targetId != id ||
t.property != Project::AnimationProperty::Sprite) {
continue;
}
const int before = static_cast<int>(t.keys.size());
for (int i = static_cast<int>(t.keys.size()) - 1; i >= 0; --i) {
if (t.keys[i].frame >= a && t.keys[i].frame <= b) {
t.keys.removeAt(i);
}
}
changed = changed || (before != t.keys.size());
}
if (changed) {
pruneEmptyAnimationTracks(*anim);
markAnimationDirty(anim->id);
}
}
if (!changed) return true;
const auto afterAnimations = m_project.animations();
m_project.setAnimations(beforeAnimations);
return applyAnimations(afterAnimations, true, QStringLiteral("清除区间图像帧"));
}
int ProjectWorkspace::activeAnimationLengthFrames() const {
if (const Project::Animation* anim = m_project.activeAnimationOrNull()) {
return std::max(1, anim->lengthFrames);
}
return Project::kClipFixedFrames;
}
int ProjectWorkspace::activeAnimationFps() const {
if (const Project::Animation* anim = m_project.activeAnimationOrNull()) {
return std::max(1, anim->fps);
}
return std::max(1, m_project.fps());
}
int ProjectWorkspace::animationLengthFramesForId(const QString& animationId) const {
if (const Project::Animation* anim = m_project.findAnimationById(animationId)) {
return std::max(1, anim->lengthFrames);
}
return Project::kClipFixedFrames;
}
int ProjectWorkspace::animationFpsForId(const QString& animationId) const {
if (const Project::Animation* anim = m_project.findAnimationById(animationId)) {
return std::max(1, anim->fps);
}
return std::max(1, m_project.fps());
}
bool ProjectWorkspace::animationLoopsForId(const QString& animationId) const {
if (const Project::Animation* anim = m_project.findAnimationById(animationId)) {
return anim->loop;
}
return false;
}
static void pruneAnimationTracksToLength(Project::Animation& anim, int lengthFramesCap) {
const int cap = std::max(1, lengthFramesCap);
for (auto& t : anim.tracks) {
QVector<Project::AnimationKey> kept;
kept.reserve(t.keys.size());
for (const auto& k : t.keys) {
if (k.frame >= 0 && k.frame < cap) {
kept.push_back(k);
}
}
t.keys = std::move(kept);
}
}
static QString nextUniqueAnimationId(const QVector<Project::Animation>& anims, const QString& prefix) {
QStringList ids;
ids.reserve(anims.size());
for (const auto& a : anims) {
ids.push_back(a.id);
}
int n = 1;
while (ids.contains(prefix + QString::number(n))) {
++n;
}
return prefix + QString::number(n);
}
bool ProjectWorkspace::createAnimationScheme(const QString& displayName, int lengthFrames, bool loop, int fps,
QString* outNewId) {
if (!isOpen() || lengthFrames < 1) {
return false;
}
const int fp = std::max(1, fps);
const QString nm = displayName.trimmed().isEmpty() ? QStringLiteral("方案_%1").arg(project().animations().size())
: displayName.trimmed();
Project::Animation a;
a.id = nextUniqueAnimationId(project().animations(), QStringLiteral("animation-"));
a.name = nm;
a.payloadPath = core::persistence::AnimationBundleStore::bundleRelativeDir(a.id);
a.fps = fp;
a.lengthFrames = lengthFrames;
a.loop = loop;
QVector<Project::Animation> next = project().animations();
next.push_back(a);
if (!applyAnimations(next, true, QStringLiteral("新建动画方案"))) {
return false;
}
project().setActiveAnimationId(a.id);
if (!writeIndexJson()) {
return false;
}
if (outNewId) {
*outNewId = a.id;
}
return true;
}
bool ProjectWorkspace::updateAnimationSchemeSettings(const QString& id, const QString& displayName,
int lengthFrames, bool loop, int fps) {
if (!isOpen() || id.isEmpty() || lengthFrames < 1) {
return false;
}
QVector<Project::Animation> next = project().animations();
int ix = -1;
for (int i = 0; i < next.size(); ++i) {
if (next[i].id == id) {
ix = i;
break;
}
}
if (ix < 0) {
return false;
}
Project::Animation a = next[ix];
a.name = displayName.trimmed().isEmpty() ? a.name : displayName.trimmed();
a.fps = std::max(1, fps);
a.loop = loop;
a.lengthFrames = std::max(1, lengthFrames);
pruneAnimationTracksToLength(a, a.lengthFrames);
next[ix] = a;
if (!applyAnimations(next, true, QStringLiteral("动画方案设置"))) {
return false;
}
return writeIndexJson();
}
namespace {
bool removeLocationKeyAtFrame(QVector<Project::Entity::KeyframeVec2>& keys, int frame) {
for (int i = 0; i < keys.size(); ++i) {
if (keys[i].frame == frame) {
keys.remove(i);
return true;
}
}
return false;
}
bool removeDepthKeyAtFrame(QVector<Project::Entity::KeyframeFloat01>& keys, int frame) {
for (int i = 0; i < keys.size(); ++i) {
if (keys[i].frame == frame) {
keys.remove(i);
return true;
}
}
return false;
}
bool removeUserScaleKeyAtFrame(QVector<Project::Entity::KeyframeDouble>& keys, int frame) {
for (int i = 0; i < keys.size(); ++i) {
if (keys[i].frame == frame) {
keys.remove(i);
return true;
}
}
return false;
}
bool removeImageKeyAtFrame(QVector<Project::Entity::ImageFrame>& keys, int frame) {
for (int i = 0; i < keys.size(); ++i) {
if (keys[i].frame == frame) {
keys.remove(i);
return true;
}
}
return false;
}
} // namespace
bool ProjectWorkspace::removeEntityLocationKey(const QString& id, int frame) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
if (isNoneAnimationActive(m_project)) {
return false;
}
const auto beforeAnimations = m_project.animations();
bool changed = false;
if (Project::Animation* anim = activeAnimationOrCreate(m_project)) {
for (auto& t : anim->tracks) {
if (t.targetKind != Project::AnimationTargetKind::Entity ||
t.targetId != id ||
t.property != Project::AnimationProperty::Position) {
continue;
}
for (int i = 0; i < t.keys.size(); ++i) {
if (t.keys[i].frame == frame) {
t.keys.removeAt(i);
changed = true;
break;
}
}
}
if (changed) {
pruneEmptyAnimationTracks(*anim);
markAnimationDirty(anim->id);
}
}
if (!changed) return true;
const auto afterAnimations = m_project.animations();
m_project.setAnimations(beforeAnimations);
return applyAnimations(afterAnimations, true, QStringLiteral("删除关键帧(位置)"));
}
bool ProjectWorkspace::removeEntityDepthScaleKey(const QString& id, int frame) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
if (isNoneAnimationActive(m_project)) {
return false;
}
const auto beforeAnimations = m_project.animations();
bool changed = false;
if (Project::Animation* anim = activeAnimationOrCreate(m_project)) {
for (auto& t : anim->tracks) {
if (t.targetKind != Project::AnimationTargetKind::Entity ||
t.targetId != id ||
t.property != Project::AnimationProperty::DepthScale) {
continue;
}
for (int i = 0; i < t.keys.size(); ++i) {
if (t.keys[i].frame == frame) {
t.keys.removeAt(i);
changed = true;
break;
}
}
}
if (changed) {
pruneEmptyAnimationTracks(*anim);
markAnimationDirty(anim->id);
}
}
if (!changed) return true;
const auto afterAnimations = m_project.animations();
m_project.setAnimations(beforeAnimations);
return applyAnimations(afterAnimations, true, QStringLiteral("删除关键帧(深度缩放)"));
}
bool ProjectWorkspace::removeEntityUserScaleKey(const QString& id, int frame) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
if (isNoneAnimationActive(m_project)) {
return false;
}
const auto beforeAnimations = m_project.animations();
bool changed = false;
if (Project::Animation* anim = activeAnimationOrCreate(m_project)) {
for (auto& t : anim->tracks) {
if (t.targetKind != Project::AnimationTargetKind::Entity ||
t.targetId != id ||
t.property != Project::AnimationProperty::UserScale) {
continue;
}
for (int i = 0; i < t.keys.size(); ++i) {
if (t.keys[i].frame == frame) {
t.keys.removeAt(i);
changed = true;
break;
}
}
}
if (changed) {
pruneEmptyAnimationTracks(*anim);
markAnimationDirty(anim->id);
}
}
if (!changed) return true;
const auto afterAnimations = m_project.animations();
m_project.setAnimations(beforeAnimations);
return applyAnimations(afterAnimations, true, QStringLiteral("删除关键帧(整体缩放)"));
}
bool ProjectWorkspace::removeEntityImageFrame(const QString& id, int frame) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
if (isNoneAnimationActive(m_project)) {
return false;
}
const auto beforeAnimations = m_project.animations();
bool removedAny = false;
if (Project::Animation* anim = activeAnimationOrCreate(m_project)) {
for (auto& t : anim->tracks) {
if (t.targetKind != Project::AnimationTargetKind::Entity ||
t.targetId != id ||
t.property != Project::AnimationProperty::Sprite) {
continue;
}
for (int i = 0; i < t.keys.size(); ++i) {
if (t.keys[i].frame == frame) {
t.keys.removeAt(i);
removedAny = true;
break;
}
}
}
if (removedAny) {
pruneEmptyAnimationTracks(*anim);
markAnimationDirty(anim->id);
}
}
if (!removedAny) return false;
const auto afterAnimations = m_project.animations();
m_project.setAnimations(beforeAnimations);
return applyAnimations(afterAnimations, true, QStringLiteral("删除关键帧(图像)"));
}
QString ProjectWorkspace::copyIntoAssetsAsBackground(const QString& sourceFilePath,
const QRect& cropRectInSourceImage) {
if (m_projectDir.isEmpty()) {
return {};
}
QFileInfo srcInfo(sourceFilePath);
if (!srcInfo.exists() || !srcInfo.isFile()) {
return {};
}
const auto assetsDir = assetsDirPath();
if (assetsDir.isEmpty()) {
return {};
}
// 统一落盘为 png,避免后续读取处理复杂化
const auto fileName = QStringLiteral("background.png");
const auto destAbs = QDir(assetsDir).filePath(fileName);
const auto destRel = QString::fromUtf8(kAssetsDirName) + "/" + fileName;
const auto tmpAbs = destAbs + ".tmp";
#ifdef LT_HAVE_VIPS
if (VipsBackend::isAvailable()) {
QSize srcSz;
if (VipsBackend::probeSize(sourceFilePath, &srcSz) && srcSz.isValid()) {
const QRect full(0, 0, srcSz.width(), srcSz.height());
const QRect clamped = cropRectInSourceImage.isNull()
? full
: clampRectToImage(cropRectInSourceImage, srcSz);
if (!clamped.isNull() && clamped.width() > 0 && clamped.height() > 0) {
if (QFileInfo::exists(tmpAbs)) {
QFile::remove(tmpAbs);
}
if (VipsBackend::saveRectToPng(sourceFilePath, clamped, tmpAbs)) {
QFile::remove(destAbs);
if (QFile::rename(tmpAbs, destAbs)) {
return destRel;
}
}
QFile::remove(tmpAbs);
}
}
}
#endif
// 回退:Qt 解码(像素上限),裁剪框需映射到缩放后的 QImage
core::image_decode::prepareLargeImageReader();
QImageReader reader(sourceFilePath);
reader.setAutoTransform(true);
const QSize srcLogicalSize = reader.size();
core::image_decode::downscaleReaderIfExceeds(reader, core::image_decode::kWorkspaceMaxPixels);
QImage img = reader.read();
if (img.isNull()) {
return {};
}
QRect crop;
if (cropRectInSourceImage.isNull()) {
crop = QRect(0, 0, img.width(), img.height());
} else if (srcLogicalSize.isValid() && srcLogicalSize.width() > 0 && srcLogicalSize.height() > 0 &&
img.size() != srcLogicalSize) {
const QRect clampedSrc = clampRectToImage(cropRectInSourceImage, srcLogicalSize);
crop = core::image_decode::mapRectBetweenSizes(clampedSrc, srcLogicalSize, img.size());
} else {
crop = clampRectToImage(cropRectInSourceImage, img.size());
}
crop = crop.intersected(img.rect());
if (crop.isNull() || crop.width() <= 0 || crop.height() <= 0) {
return {};
}
const QImage cropped = img.copy(crop);
if (cropped.isNull()) {
return {};
}
if (QFileInfo::exists(tmpAbs)) {
QFile::remove(tmpAbs);
}
if (!cropped.save(tmpAbs, "PNG")) {
QFile::remove(tmpAbs);
return {};
}
QFile::remove(destAbs);
if (!QFile::rename(tmpAbs, destAbs)) {
QFile::remove(tmpAbs);
return {};
}
return destRel;
}
bool ProjectWorkspace::writeDepthMap(const QImage& depth8) {
if (m_projectDir.isEmpty() || depth8.isNull()) {
return false;
}
const auto assetsDir = assetsDirPath();
if (assetsDir.isEmpty()) {
return false;
}
const auto fileName = QStringLiteral("depth.png");
const auto destAbs = QDir(assetsDir).filePath(fileName);
const auto destRel = QString::fromUtf8(kAssetsDirName) + "/" + fileName;
const auto tmpAbs = destAbs + ".tmp";
if (QFileInfo::exists(tmpAbs)) {
QFile::remove(tmpAbs);
}
if (!depth8.save(tmpAbs, "PNG")) {
QFile::remove(tmpAbs);
return false;
}
QFile::remove(destAbs);
if (!QFile::rename(tmpAbs, destAbs)) {
QFile::remove(tmpAbs);
return false;
}
m_project.setDepthComputed(true);
m_project.setDepthMapPath(destRel);
return writeIndexJson();
}
bool ProjectWorkspace::writeDepthMapBytes(const QByteArray& pngBytes) {
if (m_projectDir.isEmpty() || pngBytes.isEmpty()) {
return false;
}
const auto assetsDir = assetsDirPath();
if (assetsDir.isEmpty()) {
return false;
}
const auto fileName = QStringLiteral("depth.png");
const auto destAbs = QDir(assetsDir).filePath(fileName);
const auto destRel = QString::fromUtf8(kAssetsDirName) + "/" + fileName;
const auto tmpAbs = destAbs + ".tmp";
if (QFileInfo::exists(tmpAbs)) {
QFile::remove(tmpAbs);
}
QFile f(tmpAbs);
if (!f.open(QIODevice::WriteOnly)) {
QFile::remove(tmpAbs);
return false;
}
const qint64 n = f.write(pngBytes);
f.close();
if (n != pngBytes.size()) {
QFile::remove(tmpAbs);
return false;
}
QFile::remove(destAbs);
if (!QFile::rename(tmpAbs, destAbs)) {
QFile::remove(tmpAbs);
return false;
}
m_project.setDepthComputed(true);
m_project.setDepthMapPath(destRel);
return writeIndexJson();
}
bool ProjectWorkspace::computeFakeDepthForProject() {
if (m_projectDir.isEmpty()) {
return false;
}
const auto bgAbs = backgroundAbsolutePath();
if (bgAbs.isEmpty() || !QFileInfo::exists(bgAbs)) {
return false;
}
QImage bg = image_file::loadImageLimited(bgAbs, image_decode::kWorkspaceMaxPixels);
if (bg.isNull()) {
return false;
}
const QImage depth8 = DepthService::computeFakeDepthFromBackground(bg);
if (depth8.isNull()) {
return false;
}
return writeDepthMap(depth8);
}
bool ProjectWorkspace::computeDepthForProjectFromServer(const QString& serverBaseUrl, QString* outError, int timeoutMs) {
if (outError) {
outError->clear();
}
if (m_projectDir.isEmpty()) {
if (outError) *outError = QStringLiteral("项目未打开。");
return false;
}
const auto bgAbs = backgroundAbsolutePath();
if (bgAbs.isEmpty() || !QFileInfo::exists(bgAbs)) {
if (outError) *outError = QStringLiteral("背景不存在。");
return false;
}
QFile bgFile(bgAbs);
if (!bgFile.open(QIODevice::ReadOnly)) {
if (outError) *outError = QStringLiteral("读取背景失败。");
return false;
}
const QByteArray bgBytes = bgFile.readAll();
bgFile.close();
if (bgBytes.isEmpty()) {
if (outError) *outError = QStringLiteral("背景文件为空。");
return false;
}
QString base = serverBaseUrl.trimmed();
if (base.isEmpty()) {
const QByteArray env = qgetenv("MODEL_SERVER_URL");
base = env.isEmpty() ? QStringLiteral("http://127.0.0.1:8000") : QString::fromUtf8(env);
}
ModelServerClient client;
client.setBaseUrl(QUrl(base));
QByteArray depthPngBytes;
QString err;
if (!client.computeDepthPng8(bgBytes, depthPngBytes, err, timeoutMs)) {
if (outError) *outError = err.isEmpty() ? QStringLiteral("后端计算深度失败。") : err;
return false;
}
if (!writeDepthMapBytes(depthPngBytes)) {
if (outError) *outError = QStringLiteral("写入深度图失败。");
return false;
}
return true;
}
bool ProjectWorkspace::saveDepthMapPngBytes(const QByteArray& pngBytes, QString* outError) {
if (outError) {
outError->clear();
}
if (m_projectDir.isEmpty()) {
if (outError) *outError = QStringLiteral("项目未打开。");
return false;
}
if (pngBytes.isEmpty()) {
if (outError) *outError = QStringLiteral("深度数据为空。");
return false;
}
if (!writeDepthMapBytes(pngBytes)) {
if (outError) *outError = QStringLiteral("写入深度图失败。");
return false;
}
return true;
}
} // namespace core