This commit is contained in:
2026-04-09 23:13:33 +08:00
parent d67d7dc0c5
commit 6cb82cec57
24 changed files with 1733 additions and 71 deletions

View File

@@ -1,5 +1,6 @@
#include "workspace/ProjectWorkspace.h"
#include "animation/AnimationSampling.h"
#include "eval/ProjectEvaluator.h"
#include "persistence/EntityPayloadBinary.h"
#include "depth/DepthService.h"
#include "net/ModelServerClient.h"
@@ -13,9 +14,14 @@
#include <QJsonObject>
#include <QJsonValue>
#include <QImage>
#include <QImageWriter>
#include <QImageReader>
#include <QUrl>
#include <QHash>
#include <QSet>
#include <QPainter>
#include <QPainterPath>
#include <QPolygonF>
#include <cmath>
@@ -67,6 +73,24 @@ QPointF entityPolygonCentroidWorld(const Project::Entity& e, int frame, double s
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);
@@ -632,6 +656,10 @@ bool ProjectWorkspace::writeIndexJson() {
if (!m_projectDir.isEmpty() && !syncEntityPayloadsToDisk()) {
return false;
}
return writeIndexJsonWithoutPayloadSync();
}
bool ProjectWorkspace::writeIndexJsonWithoutPayloadSync() {
const auto root = projectToJson(m_project);
QJsonDocument doc(root);
@@ -1153,6 +1181,9 @@ bool ProjectWorkspace::entityStubFromJsonV2(const QJsonObject& o, Project::Entit
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;
}
@@ -1238,6 +1269,9 @@ bool ProjectWorkspace::entityFromJsonV1(const QJsonObject& o, Project::Entity& o
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");
@@ -1419,6 +1453,27 @@ bool ProjectWorkspace::syncEntityPayloadsToDisk() {
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;
@@ -1602,6 +1657,12 @@ bool ProjectWorkspace::addEntity(const Project::Entity& entity, const QImage& im
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);
@@ -1629,6 +1690,185 @@ bool ProjectWorkspace::setEntityVisible(const QString& id, bool on) {
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::setEntityVisibilityKey(const QString& id, int frame, bool visible) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
@@ -1804,12 +2044,78 @@ bool ProjectWorkspace::setEntityParent(const QString& id, const QString& parentI
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;
e.parentOffsetWorld = parentOffsetWorld;
// 更新基准值:无 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;
@@ -1951,16 +2257,35 @@ bool ProjectWorkspace::moveEntityBy(const QString& id, const QPointF& delta, int
continue;
}
found = true;
// 父子关系:若该实体绑定父实体,则移动应修改相对偏移(保持“跟随父实体”语义)。
if (!e.parentId.isEmpty()) {
e.parentOffsetWorld += delta;
break;
}
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 =
@@ -2139,12 +2464,66 @@ bool ProjectWorkspace::setToolParent(const QString& id, const QString& 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;
t.parentOffsetWorld = parentOffsetWorld;
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;
@@ -2163,15 +2542,34 @@ bool ProjectWorkspace::moveToolBy(const QString& id, const QPointF& delta, int c
for (auto& t : tools) {
if (t.id != id) continue;
found = true;
if (!t.parentId.isEmpty()) {
t.parentOffsetWorld += delta;
break;
}
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 =
@@ -2207,8 +2605,16 @@ bool ProjectWorkspace::setEntityLocationKey(const QString& id, int frame, const
}
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, originWorld);
upsertKey(keys, frame, keyValue);
return writeIndexJson();
}
@@ -2406,7 +2812,25 @@ QString ProjectWorkspace::copyIntoAssetsAsBackground(const QString& sourceFilePa
const auto destAbs = QDir(assetsDir).filePath(fileName);
const auto destRel = QString::fromUtf8(kAssetsDirName) + "/" + fileName;
QImage img(sourceFilePath);
// 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 {};
}

View File

@@ -8,6 +8,7 @@
#include <QString>
#include <QStringList>
#include <QVector>
#include <QPoint>
namespace core {
@@ -95,6 +96,11 @@ public:
bool moveToolBy(const QString& id, const QPointF& delta, int currentFrame, bool autoKeyLocation);
bool addEntity(const Project::Entity& entity, const QImage& image);
bool setEntityVisible(const QString& id, bool on);
bool setEntityBlackholeVisible(const QString& id, bool on);
bool resolveBlackholeByUseOriginalBackground(const QString& id);
// 复制背景其他区域填充黑洞sourceOffsetPx 以黑洞包围盒左上角为基准偏移)
bool resolveBlackholeByCopyBackground(const QString& id, const QPoint& sourceOffsetPx,
bool hideBlackholeAfterFill);
bool setEntityVisibilityKey(const QString& id, int frame, bool visible);
bool removeEntityVisibilityKey(const QString& id, int frame);
bool setEntityDisplayName(const QString& id, const QString& displayName);
@@ -133,6 +139,8 @@ private:
bool syncEntityPayloadsToDisk();
bool hydrateEntityPayloadsFromDisk();
void loadV1LegacyAnimationSidecars();
bool writeIndexJsonWithoutPayloadSync();
bool saveSingleEntityPayload(Project::Entity& entity);
static QJsonObject projectToJson(const Project& project);
static bool projectFromJson(const QJsonObject& root, Project& outProject, int* outFileVersion);