#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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace core { namespace { QPointF polygonCentroidFromWorldPoints(const QVector& 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(p0.x()) * static_cast(p1.y()) - static_cast(p1.x()) * static_cast(p0.y()); a2 += cross; cx6a += (static_cast(p0.x()) + static_cast(p1.x())) * cross; cy6a += (static_cast(p0.y()) + static_cast(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(p.x())); minY = std::min(minY, static_cast(p.y())); maxX = std::max(maxX, static_cast(p.x())); maxY = std::max(maxY, static_cast(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 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& 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& 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& 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& 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& 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 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 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 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 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 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 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 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 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))); } } // 兼容旧字段:polygon(world) if (out.polygonLocal.isEmpty()) { const auto legacy = o.value("polygon"); if (legacy.isArray()) { const QJsonArray arr = legacy.toArray(); QVector 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& 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& 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 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 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 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& 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& 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& 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& 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(&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; } 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 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& 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 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 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 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 reordered; reordered.reserve(ents.size()); QSet 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* 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& 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* 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& 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& 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& 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& 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(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