Files
hfut-bishe/client/core/workspace/ProjectWorkspace.cpp
2026-04-09 23:38:14 +08:00

3091 lines
105 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "workspace/ProjectWorkspace.h"
#include "animation/AnimationSampling.h"
#include "eval/ProjectEvaluator.h"
#include "persistence/EntityPayloadBinary.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 <QImage>
#include <QImageWriter>
#include <QImageReader>
#include <QUrl>
#include <QHash>
#include <QSet>
#include <QPainter>
#include <QPainterPath>
#include <QPolygonF>
#include <cmath>
namespace core {
namespace {
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 entityPolygonCentroidWorld(const Project::Entity& e, int frame, double sTotal) {
const QPointF O =
sampleLocation(e.locationKeys, frame, e.originWorld, KeyInterpolation::Linear);
QVector<QPointF> w;
w.reserve(e.polygonLocal.size());
for (const QPointF& lp : e.polygonLocal) {
w.push_back(O + lp * sTotal);
}
return polygonCentroidFromWorldPoints(w);
}
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();
}
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 const Project::NlaStrip* findStripById(const Project::AnimationScheme& scheme, const QString& stripId) {
if (stripId.isEmpty()) return nullptr;
for (const auto& tr : scheme.tracks) {
for (const auto& st : tr.strips) {
if (st.id == stripId) return &st;
}
}
return nullptr;
}
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 Project::AnimationClip* activeClipOrNull(Project& project) {
auto* scheme = project.activeSchemeOrNull();
if (!scheme) return nullptr;
const Project::NlaStrip* st = findStripById(*scheme, project.selectedStripId());
if (!st) {
for (const auto& tr : scheme->tracks) {
for (const auto& s : tr.strips) {
if (s.enabled && !s.muted) {
st = &s;
break;
}
}
if (st) break;
}
}
if (!st) return nullptr;
return project.findClipById(st->clipId);
}
} // 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::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;
}
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;
}
// 初始化默认动画方案/片段600 帧固定片段)
if (m_project.animationSchemes().isEmpty()) {
Project::AnimationClip clip;
clip.id = QStringLiteral("clip-1");
clip.name = QStringLiteral("Clip_001");
m_project.setAnimationClips({clip});
Project::NlaStrip strip;
strip.id = QStringLiteral("strip-1");
strip.clipId = clip.id;
strip.startSlot = 0;
strip.slotLen = 1;
Project::NlaTrack track;
track.id = QStringLiteral("track-1");
track.name = QStringLiteral("Track");
track.strips = {strip};
Project::AnimationScheme scheme;
scheme.id = QStringLiteral("scheme-1");
scheme.name = QStringLiteral("方案_001");
scheme.tracks = {track};
m_project.setAnimationSchemes({scheme});
m_project.setActiveSchemeId(scheme.id);
m_project.setSelectedStripId(strip.id);
}
return writeIndexJson();
}
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 (!readIndexJson(indexPath)) {
m_projectDir = prevDir;
m_project = prevProject;
return false;
}
// readIndexJson 会写入 m_project这里补齐历史初始化
ensureDir(assetsDirPath());
m_undoStack.clear();
m_redoStack.clear();
// 旧项目迁移:若无动画方案/片段,则将实体/工具的关键帧迁移到默认 clip并创建默认 scheme/track/strip
if (m_project.animationSchemes().isEmpty()) {
Project::AnimationClip clip;
clip.id = QStringLiteral("clip-1");
clip.name = QStringLiteral("Clip_001");
auto ents = m_project.entities();
for (auto& e : ents) {
if (e.id.isEmpty()) continue;
if (!e.locationKeys.isEmpty()) clip.entityLocationKeys.insert(e.id, e.locationKeys);
if (!e.userScaleKeys.isEmpty()) clip.entityUserScaleKeys.insert(e.id, e.userScaleKeys);
if (!e.imageFrames.isEmpty()) clip.entityImageFrames.insert(e.id, e.imageFrames);
if (!e.visibilityKeys.isEmpty()) clip.entityVisibilityKeys.insert(e.id, e.visibilityKeys);
e.locationKeys.clear();
e.userScaleKeys.clear();
e.imageFrames.clear();
e.visibilityKeys.clear();
}
m_project.setEntities(ents);
auto tools = m_project.tools();
for (auto& t : tools) {
if (t.id.isEmpty()) continue;
if (!t.locationKeys.isEmpty()) clip.toolLocationKeys.insert(t.id, t.locationKeys);
if (!t.visibilityKeys.isEmpty()) clip.toolVisibilityKeys.insert(t.id, t.visibilityKeys);
t.locationKeys.clear();
t.visibilityKeys.clear();
}
m_project.setTools(tools);
m_project.setAnimationClips({clip});
Project::NlaStrip strip;
strip.id = QStringLiteral("strip-1");
strip.clipId = clip.id;
strip.startSlot = 0;
strip.slotLen = 1;
Project::NlaTrack track;
track.id = QStringLiteral("track-1");
track.name = QStringLiteral("Track");
track.strips = {strip};
Project::AnimationScheme scheme;
scheme.id = QStringLiteral("scheme-1");
scheme.name = QStringLiteral("方案_001");
scheme.tracks = {track};
m_project.setAnimationSchemes({scheme});
m_project.setActiveSchemeId(scheme.id);
m_project.setSelectedStripId(strip.id);
// 迁移后立即落盘,避免后续求值出现双来源
writeIndexJson();
}
return true;
}
void ProjectWorkspace::close() {
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::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::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::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() {
if (!m_projectDir.isEmpty() && !syncEntityPayloadsToDisk()) {
return false;
}
return writeIndexJsonWithoutPayloadSync();
}
bool ProjectWorkspace::writeIndexJsonWithoutPayloadSync() {
const auto root = projectToJson(m_project);
QJsonDocument doc(root);
QFile f(indexFilePath());
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
return false;
}
const auto bytes = doc.toJson(QJsonDocument::Indented);
return f.write(bytes) == bytes.size();
}
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 (fileVer == 1) {
loadV1LegacyAnimationSidecars();
} else {
if (!hydrateEntityPayloadsFromDisk()) {
return 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);
// —— 动画v3——
root.insert("activeSchemeId", project.activeSchemeId());
root.insert("selectedStripId", project.selectedStripId());
QJsonArray clips;
for (const auto& c : project.animationClips()) {
QJsonObject co;
co.insert("id", c.id);
co.insert("name", c.name);
auto encodeVec2 = [](const QVector<Project::Entity::KeyframeVec2>& keys) {
QJsonArray a;
for (const auto& k : keys) {
QJsonObject o;
o.insert("frame", k.frame);
o.insert("x", k.value.x());
o.insert("y", k.value.y());
a.append(o);
}
return a;
};
auto encodeDouble = [](const QVector<Project::Entity::KeyframeDouble>& keys) {
QJsonArray a;
for (const auto& k : keys) {
QJsonObject o;
o.insert("frame", k.frame);
o.insert("value", k.value);
a.append(o);
}
return a;
};
auto encodeImage = [](const QVector<Project::Entity::ImageFrame>& keys) {
QJsonArray a;
for (const auto& k : keys) {
QJsonObject o;
o.insert("frame", k.frame);
o.insert("path", k.imagePath);
a.append(o);
}
return a;
};
auto encodeBool = [](const QVector<Project::ToolKeyframeBool>& keys) {
QJsonArray a;
for (const auto& k : keys) {
QJsonObject o;
o.insert("frame", k.frame);
o.insert("value", k.value);
a.append(o);
}
return a;
};
auto encodeHash = [&](const auto& h, auto encoder) {
QJsonObject out;
for (auto it = h.constBegin(); it != h.constEnd(); ++it) {
out.insert(it.key(), encoder(it.value()));
}
return out;
};
co.insert("entityLocationKeys", encodeHash(c.entityLocationKeys, encodeVec2));
co.insert("entityUserScaleKeys", encodeHash(c.entityUserScaleKeys, encodeDouble));
co.insert("entityImageFrames", encodeHash(c.entityImageFrames, encodeImage));
co.insert("entityVisibilityKeys", encodeHash(c.entityVisibilityKeys, encodeBool));
co.insert("toolLocationKeys", encodeHash(c.toolLocationKeys, encodeVec2));
co.insert("toolVisibilityKeys", encodeHash(c.toolVisibilityKeys, encodeBool));
clips.append(co);
}
root.insert("animationClips", clips);
QJsonArray schemes;
for (const auto& s : project.animationSchemes()) {
QJsonObject so;
so.insert("id", s.id);
so.insert("name", s.name);
QJsonArray tracks;
for (const auto& t : s.tracks) {
QJsonObject to;
to.insert("id", t.id);
to.insert("name", t.name);
to.insert("muted", t.muted);
to.insert("solo", t.solo);
QJsonArray strips;
for (const auto& st : t.strips) {
QJsonObject sto;
sto.insert("id", st.id);
sto.insert("clipId", st.clipId);
sto.insert("startSlot", st.startSlot);
sto.insert("slotLen", st.slotLen);
sto.insert("enabled", st.enabled);
sto.insert("muted", st.muted);
strips.append(sto);
}
to.insert("strips", strips);
tracks.append(to);
}
so.insert("tracks", tracks);
schemes.append(so);
}
root.insert("animationSchemes", schemes);
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 != 1 && version != 2 && version != 3) {
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 (version == 1) {
if (entityFromJsonV1(v.toObject(), e)) {
entities.push_back(e);
}
} else {
if (!entityStubFromJsonV2(v.toObject(), e)) {
return false;
}
entities.push_back(e);
}
}
}
outProject.setEntities(entities);
QVector<Project::Tool> tools;
if (version >= 2) {
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);
// —— 动画v3可选——
if (version >= 3) {
outProject.setActiveSchemeId(root.value("activeSchemeId").toString());
outProject.setSelectedStripId(root.value("selectedStripId").toString());
QVector<Project::AnimationClip> clips;
const auto clipsVal = root.value("animationClips");
if (clipsVal.isArray()) {
const QJsonArray arr = clipsVal.toArray();
clips.reserve(arr.size());
for (const auto& v : arr) {
if (!v.isObject()) continue;
const QJsonObject co = v.toObject();
Project::AnimationClip c;
c.id = co.value("id").toString();
c.name = co.value("name").toString();
auto decodeVec2 = [](const QJsonValue& val) {
QVector<Project::Entity::KeyframeVec2> out;
if (!val.isArray()) return out;
const QJsonArray karr = val.toArray();
out.reserve(karr.size());
for (const auto& kv : karr) {
if (!kv.isObject()) continue;
const QJsonObject o = kv.toObject();
Project::Entity::KeyframeVec2 k;
k.frame = o.value("frame").toInt(0);
k.value = QPointF(o.value("x").toDouble(0.0), o.value("y").toDouble(0.0));
out.push_back(k);
}
return out;
};
auto decodeDouble = [](const QJsonValue& val) {
QVector<Project::Entity::KeyframeDouble> out;
if (!val.isArray()) return out;
const QJsonArray karr = val.toArray();
out.reserve(karr.size());
for (const auto& kv : karr) {
if (!kv.isObject()) continue;
const QJsonObject o = kv.toObject();
Project::Entity::KeyframeDouble k;
k.frame = o.value("frame").toInt(0);
k.value = o.value("value").toDouble(1.0);
out.push_back(k);
}
return out;
};
auto decodeImage = [](const QJsonValue& val) {
QVector<Project::Entity::ImageFrame> out;
if (!val.isArray()) return out;
const QJsonArray karr = val.toArray();
out.reserve(karr.size());
for (const auto& kv : karr) {
if (!kv.isObject()) continue;
const QJsonObject o = kv.toObject();
Project::Entity::ImageFrame k;
k.frame = o.value("frame").toInt(0);
k.imagePath = asOptionalRelativeUnderProject(o.value("path").toString());
out.push_back(k);
}
return out;
};
auto decodeBool = [](const QJsonValue& val) {
QVector<Project::ToolKeyframeBool> out;
if (!val.isArray()) return out;
const QJsonArray karr = val.toArray();
out.reserve(karr.size());
for (const auto& kv : karr) {
if (!kv.isObject()) continue;
const QJsonObject o = kv.toObject();
Project::ToolKeyframeBool k;
k.frame = o.value("frame").toInt(0);
k.value = o.value("value").toBool(true);
out.push_back(k);
}
return out;
};
auto decodeHash = [&](const QJsonValue& val, auto decoder, auto& dst) {
dst.clear();
if (!val.isObject()) return;
const QJsonObject ho = val.toObject();
for (auto it = ho.begin(); it != ho.end(); ++it) {
dst.insert(it.key(), decoder(it.value()));
}
};
decodeHash(co.value("entityLocationKeys"), decodeVec2, c.entityLocationKeys);
decodeHash(co.value("entityUserScaleKeys"), decodeDouble, c.entityUserScaleKeys);
decodeHash(co.value("entityImageFrames"), decodeImage, c.entityImageFrames);
decodeHash(co.value("entityVisibilityKeys"), decodeBool, c.entityVisibilityKeys);
decodeHash(co.value("toolLocationKeys"), decodeVec2, c.toolLocationKeys);
decodeHash(co.value("toolVisibilityKeys"), decodeBool, c.toolVisibilityKeys);
clips.push_back(std::move(c));
}
}
outProject.setAnimationClips(clips);
QVector<Project::AnimationScheme> schemes;
const auto schemesVal = root.value("animationSchemes");
if (schemesVal.isArray()) {
const QJsonArray arr = schemesVal.toArray();
schemes.reserve(arr.size());
for (const auto& v : arr) {
if (!v.isObject()) continue;
const QJsonObject so = v.toObject();
Project::AnimationScheme s;
s.id = so.value("id").toString();
s.name = so.value("name").toString();
const auto tracksVal = so.value("tracks");
if (tracksVal.isArray()) {
const QJsonArray tarr = tracksVal.toArray();
s.tracks.reserve(tarr.size());
for (const auto& tv : tarr) {
if (!tv.isObject()) continue;
const QJsonObject to = tv.toObject();
Project::NlaTrack t;
t.id = to.value("id").toString();
t.name = to.value("name").toString();
t.muted = to.value("muted").toBool(false);
t.solo = to.value("solo").toBool(false);
const auto stripsVal = to.value("strips");
if (stripsVal.isArray()) {
const QJsonArray sarr = stripsVal.toArray();
t.strips.reserve(sarr.size());
for (const auto& sv : sarr) {
if (!sv.isObject()) continue;
const QJsonObject sto = sv.toObject();
Project::NlaStrip st;
st.id = sto.value("id").toString();
st.clipId = sto.value("clipId").toString();
st.startSlot = sto.value("startSlot").toInt(0);
st.slotLen = std::max(1, sto.value("slotLen").toInt(1));
st.enabled = sto.value("enabled").toBool(true);
st.muted = sto.value("muted").toBool(false);
t.strips.push_back(std::move(st));
}
}
s.tracks.push_back(std::move(t));
}
}
schemes.push_back(std::move(s));
}
}
outProject.setAnimationSchemes(schemes);
}
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("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));
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;
}
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;
}
bool ProjectWorkspace::entityFromJsonV1(const QJsonObject& o, Project::Entity& out) {
out.id = o.value("id").toString();
if (out.id.isEmpty()) {
return false;
}
out.depth = o.value("depth").toInt(0);
out.imagePath = asOptionalRelativeUnderProject(o.value("imagePath").toString());
out.imageTopLeftWorld = QPointF(o.value("imageTopLeftX").toDouble(0.0), o.value("imageTopLeftY").toDouble(0.0));
out.originWorld = QPointF(o.value("originX").toDouble(0.0), o.value("originY").toDouble(0.0));
out.polygonLocal.clear();
out.cutoutPolygonWorld.clear();
out.entityPayloadPath.clear();
out.legacyAnimSidecarPath = asOptionalRelativeUnderProject(o.value("animationBundle").toString());
out.locationKeys.clear();
out.depthScaleKeys.clear();
out.imageFrames.clear();
const auto localVal = o.value("polygonLocal");
if (localVal.isArray()) {
const QJsonArray arr = localVal.toArray();
out.polygonLocal.reserve(arr.size());
for (const auto& v : arr) {
if (!v.isObject()) {
continue;
}
const QJsonObject p = v.toObject();
out.polygonLocal.push_back(QPointF(p.value("x").toDouble(0.0), p.value("y").toDouble(0.0)));
}
}
const auto cutoutVal = o.value("cutoutPolygon");
if (cutoutVal.isArray()) {
const QJsonArray arr = cutoutVal.toArray();
out.cutoutPolygonWorld.reserve(arr.size());
for (const auto& v : arr) {
if (!v.isObject()) {
continue;
}
const QJsonObject p = v.toObject();
out.cutoutPolygonWorld.push_back(QPointF(p.value("x").toDouble(0.0), p.value("y").toDouble(0.0)));
}
}
// 兼容旧字段polygonworld
if (out.polygonLocal.isEmpty()) {
const auto legacy = o.value("polygon");
if (legacy.isArray()) {
const QJsonArray arr = legacy.toArray();
QVector<QPointF> polyWorld;
polyWorld.reserve(arr.size());
for (const auto& v : arr) {
if (!v.isObject()) continue;
const QJsonObject p = v.toObject();
polyWorld.push_back(QPointF(p.value("x").toDouble(0.0), p.value("y").toDouble(0.0)));
}
if (!polyWorld.isEmpty()) {
// 若没给 origin则用包围盒中心近似
if (qFuzzyIsNull(out.originWorld.x()) && qFuzzyIsNull(out.originWorld.y())) {
QRectF bb;
for (const auto& pt : polyWorld) {
bb = bb.isNull() ? QRectF(pt, QSizeF(1, 1)) : bb.united(QRectF(pt, QSizeF(1, 1)));
}
out.originWorld = bb.center();
}
out.cutoutPolygonWorld = polyWorld;
out.polygonLocal.reserve(polyWorld.size());
for (const auto& pt : polyWorld) {
out.polygonLocal.push_back(pt - out.originWorld);
}
}
}
}
if (out.cutoutPolygonWorld.isEmpty()) {
// 没有 cutout 就默认用当前实体形状origin+local
for (const auto& lp : out.polygonLocal) {
out.cutoutPolygonWorld.push_back(out.originWorld + lp);
}
}
out.blackholeVisible = true;
out.blackholeId = QStringLiteral("blackhole-%1").arg(out.id);
out.blackholeResolvedBy = QStringLiteral("pending");
// 旧版:关键帧内嵌在 project.json若存在对应 .anim 文件,打开项目时会被二进制数据覆盖。
const auto lk = o.value("locationKeys");
if (lk.isArray()) {
for (const auto& v : lk.toArray()) {
if (!v.isObject()) continue;
const auto ko = v.toObject();
Project::Entity::KeyframeVec2 kf;
kf.frame = ko.value("frame").toInt(0);
kf.value = QPointF(ko.value("x").toDouble(0.0), ko.value("y").toDouble(0.0));
out.locationKeys.push_back(kf);
}
}
const auto dk = o.value("depthScaleKeys");
if (dk.isArray()) {
for (const auto& v : dk.toArray()) {
if (!v.isObject()) continue;
const auto ko = v.toObject();
Project::Entity::KeyframeFloat01 kf;
kf.frame = ko.value("frame").toInt(0);
kf.value = ko.value("v").toDouble(0.5);
out.depthScaleKeys.push_back(kf);
}
}
const auto ik = o.value("imageFrames");
if (ik.isArray()) {
for (const auto& v : ik.toArray()) {
if (!v.isObject()) continue;
const auto ko = v.toObject();
Project::Entity::ImageFrame kf;
kf.frame = ko.value("frame").toInt(0);
kf.imagePath = asOptionalRelativeUnderProject(ko.value("imagePath").toString());
if (!kf.imagePath.isEmpty()) {
out.imageFrames.push_back(kf);
}
}
}
return !out.polygonLocal.isEmpty();
}
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);
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();
m_project.setEntities(entities);
if (!writeIndexJson()) {
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);
if (!writeIndexJson()) {
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;
}
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 changed = false;
for (auto& e : ents) {
e.legacyAnimSidecarPath.clear();
if (e.entityPayloadPath.isEmpty()) {
e.entityPayloadPath =
QString::fromUtf8(kAssetsDirName) + QStringLiteral("/entities/") + e.id + QStringLiteral(".hfe");
changed = true;
}
const QString rel = asRelativeUnderProject(e.entityPayloadPath);
if (rel.isEmpty()) {
return false;
}
if (rel != e.entityPayloadPath) {
e.entityPayloadPath = rel;
changed = true;
}
const QString abs = QDir(m_projectDir).filePath(rel);
if (!EntityPayloadBinary::save(abs, e)) {
return false;
}
}
if (changed) {
m_project.setEntities(ents);
}
return true;
}
bool ProjectWorkspace::saveSingleEntityPayload(Project::Entity& entity) {
if (m_projectDir.isEmpty()) {
return false;
}
if (ensureEntitiesDir().isEmpty()) {
return false;
}
entity.legacyAnimSidecarPath.clear();
if (entity.entityPayloadPath.isEmpty()) {
entity.entityPayloadPath =
QString::fromUtf8(kAssetsDirName) + QStringLiteral("/entities/") + entity.id + QStringLiteral(".hfe");
}
const QString rel = asRelativeUnderProject(entity.entityPayloadPath);
if (rel.isEmpty()) {
return false;
}
entity.entityPayloadPath = rel;
const QString abs = QDir(m_projectDir).filePath(rel);
return EntityPayloadBinary::save(abs, 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;
}
const QString abs = QDir(m_projectDir).filePath(rel);
if (!QFileInfo::exists(abs)) {
return false;
}
if (!EntityPayloadBinary::load(abs, e)) {
return false;
}
if (e.id != expectId) {
return false;
}
e.entityPayloadPath = rel;
}
m_project.setEntities(ents);
return true;
}
void ProjectWorkspace::loadV1LegacyAnimationSidecars() {
if (m_projectDir.isEmpty()) {
return;
}
QVector<Project::Entity> ents = m_project.entities();
for (auto& e : ents) {
QString rel = e.legacyAnimSidecarPath;
if (rel.isEmpty()) {
rel = QString::fromUtf8(kAssetsDirName) + QStringLiteral("/anim/") + e.id + QStringLiteral(".anim");
}
rel = asRelativeUnderProject(rel);
if (!rel.isEmpty()) {
const QString abs = QDir(m_projectDir).filePath(rel);
if (QFileInfo::exists(abs)) {
EntityPayloadBinary::loadLegacyAnimFile(abs, e);
}
}
e.legacyAnimSidecarPath.clear();
}
m_project.setEntities(ents);
}
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()) {
QString rel;
if (!writeEntityImage(e.id, image, rel)) {
return false;
}
e.imagePath = rel;
}
if (e.entityPayloadPath.isEmpty()) {
e.entityPayloadPath =
QString::fromUtf8(kAssetsDirName) + QStringLiteral("/entities/") + e.id + QStringLiteral(".hfe");
}
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");
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 QPoint& sourceOffsetPx,
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;
}
QImage bg(bgAbs);
if (bg.isNull()) {
return false;
}
if (bg.format() != QImage::Format_ARGB32_Premultiplied) {
bg = bg.convertToFormat(QImage::Format_ARGB32_Premultiplied);
}
const QImage srcSnapshot = bg;
QPainterPath holePath;
holePath.addPolygon(QPolygonF(ent.cutoutPolygonWorld));
holePath.closeSubpath();
const QRect targetRect = holePath.boundingRect().toAlignedRect().intersected(QRect(QPoint(0, 0), bg.size()));
if (!targetRect.isValid() || targetRect.width() <= 0 || targetRect.height() <= 0) {
return false;
}
QRect srcRect(targetRect.topLeft() + sourceOffsetPx, targetRect.size());
if (srcRect.left() < 0) srcRect.moveLeft(0);
if (srcRect.top() < 0) srcRect.moveTop(0);
if (srcRect.right() >= bg.width()) srcRect.moveRight(bg.width() - 1);
if (srcRect.bottom() >= bg.height()) srcRect.moveBottom(bg.height() - 1);
srcRect = srcRect.intersected(QRect(QPoint(0, 0), bg.size()));
if (srcRect.width() != targetRect.width() || srcRect.height() != targetRect.height()) {
return false;
}
{
QPainter p(&bg);
p.setRenderHint(QPainter::Antialiasing, true);
p.setClipPath(holePath);
p.drawImage(targetRect.topLeft(), srcSnapshot, srcRect);
p.end();
}
QImageWriter writer(bgAbs);
writer.setFormat("png");
writer.setCompression(1);
if (!writer.write(bg)) {
return false;
}
const auto before = m_project.entities();
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& patchedBackground,
bool hideBlackholeAfterFill) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
const QString bgAbs = backgroundAbsolutePath();
if (bgAbs.isEmpty() || !QFileInfo::exists(bgAbs)) {
return false;
}
if (patchedBackground.isNull()) {
return false;
}
// 写回背景文件
{
QImage bg = patchedBackground;
if (bg.format() != QImage::Format_ARGB32_Premultiplied) {
bg = bg.convertToFormat(QImage::Format_ARGB32_Premultiplied);
}
QImageWriter writer(bgAbs);
writer.setFormat("png");
writer.setCompression(1);
if (!writer.write(bg)) {
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;
}
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;
}
Project::AnimationClip* clip = activeClipOrNull(m_project);
if (!clip) return false;
auto& keys = clip->entityVisibilityKeys[id];
upsertBoolKey(keys, frame, visible);
return writeIndexJson();
}
bool ProjectWorkspace::removeEntityVisibilityKey(const QString& id, int frame) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
Project::AnimationClip* clip = activeClipOrNull(m_project);
if (!clip) return false;
if (!clip->entityVisibilityKeys.contains(id)) return true;
auto keys = clip->entityVisibilityKeys.value(id);
bool changed = false;
for (int i = 0; i < keys.size(); ++i) {
if (keys[i].frame == frame) {
keys.removeAt(i);
changed = true;
break;
}
}
if (!changed) return true;
clip->entityVisibilityKeys.insert(id, keys);
return writeIndexJson();
}
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);
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);
e.userScale = u;
if (keyframeAtFrame >= 0) {
Project::AnimationClip* clip = activeClipOrNull(m_project);
if (!clip) return false;
auto& keys = clip->entityUserScaleKeys[id];
upsertKey(keys, keyframeAtFrame, std::clamp(u, 1e-6, 1e3));
} else if (baseSame) {
return true;
}
break;
}
if (!found) {
return false;
}
if (!applyEntities(ents, true, QStringLiteral("整体缩放"))) {
return false;
}
// 缩放关键帧已写入 clip此处只需保证索引落盘
return writeIndexJson();
}
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);
}
};
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);
// 转换 clip 与内嵌 key兼容 v1/v2/v3 数据来源)
if (Project::AnimationClip* clip = activeClipOrNull(m_project)) {
if (clip->entityLocationKeys.contains(e.id)) {
auto k = clip->entityLocationKeys.value(e.id);
convertKeys(k, oldPid, parentId);
clip->entityLocationKeys.insert(e.id, k);
}
}
convertKeys(e.locationKeys, oldPid, parentId);
// 更新父子信息
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::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(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();
bool found = false;
for (auto& e : ents) {
if (e.id != id) {
continue;
}
found = true;
const QPointF O_anim =
sampleLocation(e.locationKeys, frame, e.originWorld, KeyInterpolation::Linear);
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;
}
double minX = polyWorld[0].x();
double minY = polyWorld[0].y();
double maxX = minX;
double maxY = minY;
for (const QPointF& p : polyWorld) {
minX = std::min(minX, p.x());
minY = std::min(minY, p.y());
maxX = std::max(maxX, p.x());
maxY = std::max(maxY, p.y());
}
QPointF O_new(newPivotWorld);
O_new.setX(std::clamp(O_new.x(), minX, maxX));
O_new.setY(std::clamp(O_new.y(), minY, maxY));
const QPointF I_disp = O_anim + (e.imageTopLeftWorld - e.originWorld) * sTotal;
const QPointF d = O_new - O_anim;
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;
}
e.originWorld += d;
e.polygonLocal = std::move(newLocal);
e.imageTopLeftWorld = e.originWorld + (I_disp - O_new) / sTotal;
break;
}
if (!found) {
return false;
}
return applyEntities(ents, true, QStringLiteral("属性:枢轴"));
}
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;
for (auto& e : ents) {
if (e.id != id) {
continue;
}
found = true;
Project::AnimationClip* clip = activeClipOrNull(m_project);
QVector<Project::Entity::KeyframeVec2>* keys = nullptr;
if (clip) {
keys = &clip->entityLocationKeys[e.id];
}
// 父子关系:绑定父对象时,位置曲线表示“相对父对象偏移”。
if (!e.parentId.isEmpty()) {
const bool hasCurve = (keys && !keys->isEmpty()) || (!e.locationKeys.isEmpty());
if (autoKeyLocation && currentFrame >= 0) {
const QPointF sampled =
(keys)
? sampleLocation(*keys, currentFrame, e.parentOffsetWorld, KeyInterpolation::Linear)
: sampleLocation(e.locationKeys, currentFrame, e.parentOffsetWorld, KeyInterpolation::Linear);
if (keys) upsertKey(*keys, currentFrame, sampled + delta);
else upsertKey(e.locationKeys, currentFrame, sampled + delta);
} else if (!hasCurve) {
e.parentOffsetWorld += delta;
} else if (currentFrame >= 0) {
const QPointF sampled =
(keys)
? sampleLocation(*keys, currentFrame, e.parentOffsetWorld, KeyInterpolation::Linear)
: sampleLocation(e.locationKeys, currentFrame, e.parentOffsetWorld, KeyInterpolation::Linear);
if (keys) upsertKey(*keys, currentFrame, sampled + delta);
else upsertKey(e.locationKeys, currentFrame, sampled + delta);
} else {
e.parentOffsetWorld += delta;
}
break;
}
const bool hasCurve = (keys && !keys->isEmpty()) || (!e.locationKeys.isEmpty());
if (autoKeyLocation && currentFrame >= 0) {
const QPointF sampled =
(keys)
? sampleLocation(*keys, currentFrame, e.originWorld, KeyInterpolation::Linear)
: sampleLocation(e.locationKeys, currentFrame, e.originWorld, KeyInterpolation::Linear);
if (keys) upsertKey(*keys, currentFrame, sampled + delta);
else upsertKey(e.locationKeys, currentFrame, sampled + delta);
} else if (!hasCurve) {
e.originWorld += delta;
e.imageTopLeftWorld += delta;
} else if (currentFrame >= 0) {
const QPointF sampled =
(keys)
? sampleLocation(*keys, currentFrame, e.originWorld, KeyInterpolation::Linear)
: sampleLocation(e.locationKeys, currentFrame, e.originWorld, KeyInterpolation::Linear);
if (keys) upsertKey(*keys, currentFrame, sampled + delta);
else upsertKey(e.locationKeys, currentFrame, sampled + delta);
} else {
e.originWorld += delta;
e.imageTopLeftWorld += delta;
}
break;
}
if (!found) {
return false;
}
if (!applyEntities(ents, true, QStringLiteral("移动实体"))) {
return false;
}
// clip 曲线与选中条带存于 project.json需要保证落盘
return writeIndexJson();
}
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::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;
}
Project::AnimationClip* clip = activeClipOrNull(m_project);
if (!clip) return false;
auto& keys = clip->toolVisibilityKeys[id];
upsertBoolKey(keys, frame, visible);
return writeIndexJson();
}
bool ProjectWorkspace::removeToolVisibilityKey(const QString& id, int frame) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
Project::AnimationClip* clip = activeClipOrNull(m_project);
if (!clip) return false;
if (!clip->toolVisibilityKeys.contains(id)) return true;
auto keys = clip->toolVisibilityKeys.value(id);
bool changed = false;
for (int i = 0; i < keys.size(); ++i) {
if (keys[i].frame == frame) {
keys.removeAt(i);
changed = true;
break;
}
}
if (!changed) return true;
clip->toolVisibilityKeys.insert(id, keys);
return writeIndexJson();
}
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);
if (Project::AnimationClip* clip = activeClipOrNull(m_project)) {
if (clip->toolLocationKeys.contains(t.id)) {
auto k = clip->toolLocationKeys.value(t.id);
convertKeys(k, oldPid, parentId);
clip->toolLocationKeys.insert(t.id, k);
}
}
convertKeys(t.locationKeys, oldPid, parentId);
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;
for (auto& t : tools) {
if (t.id != id) continue;
found = true;
Project::AnimationClip* clip = activeClipOrNull(m_project);
QVector<Project::Entity::KeyframeVec2>* keys = nullptr;
if (clip) {
keys = &clip->toolLocationKeys[t.id];
}
if (!t.parentId.isEmpty()) {
const bool hasCurve = (keys && !keys->isEmpty()) || (!t.locationKeys.isEmpty());
if (autoKeyLocation && currentFrame >= 0) {
const QPointF sampled =
(keys)
? sampleLocation(*keys, currentFrame, t.parentOffsetWorld, KeyInterpolation::Linear)
: sampleLocation(t.locationKeys, currentFrame, t.parentOffsetWorld, KeyInterpolation::Linear);
if (keys) upsertKey(*keys, currentFrame, sampled + delta);
else upsertKey(t.locationKeys, currentFrame, sampled + delta);
} else if (!hasCurve) {
t.parentOffsetWorld += delta;
} else if (currentFrame >= 0) {
const QPointF sampled =
(keys)
? sampleLocation(*keys, currentFrame, t.parentOffsetWorld, KeyInterpolation::Linear)
: sampleLocation(t.locationKeys, currentFrame, t.parentOffsetWorld, KeyInterpolation::Linear);
if (keys) upsertKey(*keys, currentFrame, sampled + delta);
else upsertKey(t.locationKeys, currentFrame, sampled + delta);
} else {
t.parentOffsetWorld += delta;
}
break;
}
const bool hasCurve = (keys && !keys->isEmpty()) || (!t.locationKeys.isEmpty());
if (autoKeyLocation && currentFrame >= 0) {
const QPointF sampled =
(keys)
? sampleLocation(*keys, currentFrame, t.originWorld, KeyInterpolation::Linear)
: sampleLocation(t.locationKeys, currentFrame, t.originWorld, KeyInterpolation::Linear);
if (keys) upsertKey(*keys, currentFrame, sampled + delta);
else upsertKey(t.locationKeys, currentFrame, sampled + delta);
} else if (!hasCurve) {
t.originWorld += delta;
} else if (currentFrame >= 0) {
const QPointF sampled =
(keys)
? sampleLocation(*keys, currentFrame, t.originWorld, KeyInterpolation::Linear)
: sampleLocation(t.locationKeys, currentFrame, t.originWorld, KeyInterpolation::Linear);
if (keys) upsertKey(*keys, currentFrame, sampled + delta);
else upsertKey(t.locationKeys, currentFrame, sampled + delta);
} else {
t.originWorld += delta;
}
break;
}
if (!found) return false;
if (!applyTools(tools, true, QStringLiteral("移动工具"))) {
return false;
}
return writeIndexJson();
}
bool ProjectWorkspace::setEntityLocationKey(const QString& id, int frame, const QPointF& originWorld) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
Project::AnimationClip* clip = activeClipOrNull(m_project);
if (!clip) 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;
}
}
auto& keys = clip->entityLocationKeys[id];
upsertKey(keys, frame, keyValue);
return writeIndexJson();
}
bool ProjectWorkspace::setEntityDepthScaleKey(const QString& id, int frame, double value01) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
const double v = std::clamp(value01, 0.0, 1.0);
auto ents = m_project.entities();
bool found = false;
for (auto& e : ents) {
if (e.id != id) continue;
found = true;
upsertKey(e.depthScaleKeys, frame, v);
break;
}
if (!found) return false;
return applyEntities(ents, true, QStringLiteral("插入关键帧(缩放)"));
}
bool ProjectWorkspace::setEntityUserScaleKey(const QString& id, int frame, double userScale) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
const double v = std::clamp(userScale, 1e-6, 1e3);
Project::AnimationClip* clip = activeClipOrNull(m_project);
if (!clip) return false;
auto& keys = clip->entityUserScaleKeys[id];
upsertKey(keys, frame, v);
return writeIndexJson();
}
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;
}
QString rel;
if (!writeEntityFrameImage(id, frame, image, rel)) {
return false;
}
Project::AnimationClip* clip = activeClipOrNull(m_project);
if (!clip) return false;
auto& frames = clip->entityImageFrames[id];
upsertFrame(frames, frame, rel);
if (!writeIndexJson()) return false;
if (outRelPath) *outRelPath = rel;
return true;
}
bool ProjectWorkspace::setEntityImageFramePath(const QString& id, int frame, const QString& relativePath) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
const QString rel = relativePath.trimmed();
if (rel.isEmpty()) {
return false;
}
auto ents = m_project.entities();
bool found = false;
for (auto& e : ents) {
if (e.id != id) continue;
found = true;
upsertFrame(e.imageFrames, frame, rel);
break;
}
if (!found) return false;
return applyEntities(ents, true, QStringLiteral("插入关键帧(图像)"));
}
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;
}
Project::AnimationClip* clip = activeClipOrNull(m_project);
if (!clip) return false;
if (!clip->entityLocationKeys.contains(id)) return false;
auto keys = clip->entityLocationKeys.value(id);
const bool removed = removeLocationKeyAtFrame(keys, frame);
if (!removed) return false;
clip->entityLocationKeys.insert(id, keys);
return writeIndexJson();
}
bool ProjectWorkspace::removeEntityDepthScaleKey(const QString& id, int frame) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
auto ents = m_project.entities();
bool found = false;
bool removed = false;
for (auto& e : ents) {
if (e.id != id) {
continue;
}
found = true;
removed = removeDepthKeyAtFrame(e.depthScaleKeys, frame);
break;
}
if (!found || !removed) {
return false;
}
return applyEntities(ents, true, QStringLiteral("删除关键帧(缩放)"));
}
bool ProjectWorkspace::removeEntityUserScaleKey(const QString& id, int frame) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
Project::AnimationClip* clip = activeClipOrNull(m_project);
if (!clip) return false;
if (!clip->entityUserScaleKeys.contains(id)) return false;
auto keys = clip->entityUserScaleKeys.value(id);
const bool removed = removeUserScaleKeyAtFrame(keys, frame);
if (!removed) return false;
clip->entityUserScaleKeys.insert(id, keys);
return writeIndexJson();
}
bool ProjectWorkspace::removeEntityImageFrame(const QString& id, int frame) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
Project::AnimationClip* clip = activeClipOrNull(m_project);
if (!clip) return false;
if (!clip->entityImageFrames.contains(id)) return false;
auto keys = clip->entityImageFrames.value(id);
const bool removed = removeImageKeyAtFrame(keys, frame);
if (!removed) return false;
clip->entityImageFrames.insert(id, keys);
return writeIndexJson();
}
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;
// Qt 默认的 image allocation limit 较小,超大分辨率背景可能会被拒绝。
// 这里提高 limit并对极端大图按像素数上限自动缩放后再裁剪落盘。
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
QImageReader::setAllocationLimit(1024); // MB
#endif
QImageReader reader(sourceFilePath);
reader.setAutoTransform(true);
const QSize sz = reader.size();
if (sz.isValid()) {
constexpr qint64 kMaxPixels = 160LL * 1000LL * 1000LL; // 160MP
const qint64 pixels = qint64(sz.width()) * qint64(sz.height());
if (pixels > kMaxPixels) {
const double s = std::sqrt(double(kMaxPixels) / std::max<double>(1.0, double(pixels)));
const int nw = std::max(1, int(std::lround(sz.width() * s)));
const int nh = std::max(1, int(std::lround(sz.height() * s)));
reader.setScaledSize(QSize(nw, nh));
}
}
QImage img = reader.read();
if (img.isNull()) {
return {};
}
const QRect crop = cropRectInSourceImage.isNull() ? QRect(0, 0, img.width(), img.height())
: clampRectToImage(cropRectInSourceImage, img.size());
if (crop.isNull()) {
return {};
}
const QImage cropped = img.copy(crop);
if (cropped.isNull()) {
return {};
}
// 覆盖式更新背景:先写临时文件,再替换,避免中间态损坏
const auto tmpAbs = destAbs + ".tmp";
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(bgAbs);
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