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