From ba8b3dd24db4e8e4a3e475b2b1d506b760299296 Mon Sep 17 00:00:00 2001 From: SSSimpleC <89213712+SSSimpleC@users.noreply.github.com> Date: Mon, 10 Aug 2026 02:22:55 +0800 Subject: [PATCH 1/5] feat: ship coverage-first matching v2 --- .github/pull_request_template.md | 4 + .github/workflows/ci.yml | 37 +- CHANGELOG.md | 25 + CONTRIBUTING.md | 16 +- Docs/Decisions/0002-matching-v2.md | 106 ++ Docs/PRIVACY.md | 44 +- Docs/RELEASE.md | 29 +- Docs/TESTING.md | 96 +- .../ExifToolClient.swift | 143 +- .../MetadataModels.swift | 342 ++++- .../XMPTransaction.swift | 262 +++- .../ExifToolClientTests.swift | 79 +- .../RealExifToolContractTests.swift | 19 +- .../V2ContractTests.swift | 142 ++ .../XMPTransactionTests.swift | 76 ++ README.md | 30 +- .../RawGeoCore/ClockSuggestionEngine.swift | 82 ++ .../RawGeoCore/LocationInference.swift | 986 ++++++++++++++ .../Sources/RawGeoCore/TrajectoryCorpus.swift | 233 ++++ RawGeoCore/Sources/RawGeoCore/V2Models.swift | 742 +++++++++++ .../RawGeoCoreTests/V2InferenceTests.swift | 487 +++++++ RawGeoSync.xcodeproj/project.pbxproj | 120 +- .../xcschemes/RawGeoSync.xcscheme | 26 + RawGeoSyncApp/Models/WorkflowModels.swift | 111 +- .../Services/GeoWorkflowService.swift | 12 +- .../Services/LiveGeoWorkflowService.swift | 1168 ++++++++++++++++- .../ViewModels/WorkspaceViewModel.swift | 60 +- .../Views/AnalysisWorkspaceView.swift | 72 +- .../Views/Components/SharedComponents.swift | 1 + RawGeoSyncApp/Views/SourceSetupView.swift | 71 +- .../WorkspaceSelectionTests.swift | 86 ++ Scripts/build-release.sh | 20 +- Scripts/build.sh | 20 +- Scripts/ci.sh | 64 + Scripts/real-data-regression.sh | 273 ++++ Scripts/real-sample-smoke.sh | 31 +- Scripts/repository-policy-check.sh | 106 ++ Scripts/test.sh | 2 +- Tools/RawGeoSmoke/SmokeMain.swift | 249 ++-- 39 files changed, 6116 insertions(+), 356 deletions(-) create mode 100644 Docs/Decisions/0002-matching-v2.md create mode 100644 MetadataInfrastructure/Tests/MetadataInfrastructureTests/V2ContractTests.swift create mode 100644 RawGeoCore/Sources/RawGeoCore/ClockSuggestionEngine.swift create mode 100644 RawGeoCore/Sources/RawGeoCore/LocationInference.swift create mode 100644 RawGeoCore/Sources/RawGeoCore/TrajectoryCorpus.swift create mode 100644 RawGeoCore/Sources/RawGeoCore/V2Models.swift create mode 100644 RawGeoCore/Tests/RawGeoCoreTests/V2InferenceTests.swift create mode 100644 RawGeoSyncAppTests/WorkspaceSelectionTests.swift create mode 100755 Scripts/ci.sh create mode 100755 Scripts/real-data-regression.sh create mode 100755 Scripts/repository-policy-check.sh diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b8aad54..0e04c28 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,9 +6,11 @@ - [ ] `Scripts/format-check.sh` - [ ] `Scripts/verify-vendor.sh` +- [ ] `Scripts/repository-policy-check.sh` - [ ] `swift test --package-path RawGeoCore` - [ ] `swift test --package-path MetadataInfrastructure` - [ ] RawGeoSync Debug 构建 +- [ ] RawGeoSync Release 构建与 `xcodebuild analyze` ## 数据与安全 @@ -16,6 +18,8 @@ - [ ] 未改变 RAW 只读和 XMP sidecar 边界 - [ ] 已有 GPS、取消、失败、重复运行行为已检查 - [ ] 若修改了元数据写入,已补充事务或契约测试 +- [ ] 若修改了匹配规则,已覆盖阈值边界、来源优先级、单跳传播和强冲突 +- [ ] 真实 dry-run 报告、路径、文件名、坐标和哈希清单未上传 ## 其他 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b21e160..0f82a62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,11 +8,17 @@ on: permissions: contents: read +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: macos: - name: macOS checks + name: Repository, tests, build and analyze runs-on: macos-15 - timeout-minutes: 25 + timeout-minutes: 45 + env: + DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer steps: - name: Checkout uses: actions/checkout@v4 @@ -23,25 +29,12 @@ jobs: xcodebuild -version swift --version - - name: Verify bundled ExifTool - run: ./Scripts/verify-vendor.sh - - - name: Swift format - run: ./Scripts/format-check.sh - - - name: Core tests - run: swift test --package-path RawGeoCore - - - name: Metadata tests - run: swift test --package-path MetadataInfrastructure + - name: Full quality gate + run: ./Scripts/ci.sh - - name: Application build + - name: Assert clean checkout run: | - xcodebuild \ - -project RawGeoSync.xcodeproj \ - -scheme RawGeoSync \ - -configuration Debug \ - -destination 'platform=macOS,arch=arm64' \ - -derivedDataPath .local/CI-DerivedData \ - CODE_SIGNING_ALLOWED=NO \ - build + if [[ -n "$(git status --porcelain --untracked-files=all)" ]]; then + git status --short --untracked-files=all + exit 1 + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index d3b749a..39d0d36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ 本文件记录面向用户和贡献者的重要变化。版本遵循语义化版本的意图;在 1.0.0 前,MVP 的行为和界面仍可能调整。 +## [0.2.0] - 2026-08-10 + +### 新增 + +- 引入多来源位置证据模型和稳定 reason code,保留候选来源、粒度、规则、传播跳数与确认状态。 +- 按 manual、直接传感器、同照片、GPX、相机定位、burst、序列、有界停留、跨相机和活动区分层回退,提高缺轨照片的可解释覆盖率。 +- 支持相机时钟偏移建议和多相机锚点,同时阻断循环传播、航班边界与跨活动区污染。 +- 新增接受 GPX 目录和照片目录的全量只读回归契约,记录 wall time、峰值内存并用前后 SHA-256 快照证明原目录未变。 +- 新增本地与 GitHub Actions 一致的仓库策略、Debug/Release 测试、应用构建和静态分析门禁。 +- 照片目录改为递归扫描;DNG、JPEG、TIFF 和相邻 XMP 可作为只读证据,只有专有 RAW 能成为 XMP 写入目标。 +- “全选照片”按当前筛选直接切换照片写入勾选,覆盖可靠、待确认、粗略和未匹配分类。 +- 大型图库按批读取元数据,单个损坏媒体会被隔离并报告,不再使整批分析失败。 +- 覆盖优先模式将活动按 30 分钟照片间隔拆成会话,并把粗粒度区域限制在各会话时间包络内,避免多城市旅行日被单一日中心污染。 + +### 安全与隐私 + +- 不再在文档或脚本中记录可关联特定私有样本的路径、文件名、位置或黄金结果;PR 可分享脱敏聚合数量,本地真实报告不得作为 CI artifact 上传。 +- 明确区分源提供的水平精度和规则推断范围。源无 hacc 时保持 unknown,不显示伪米级误差。 +- 强候选位置冲突、传播超过一跳或证据循环时禁止自动写入;活动区等弱候选不能覆盖强候选。 + +### 兼容性说明 + +- `Scripts/real-sample-smoke.sh` 保留为兼容入口,但转交新的 `real-data-regression.sh`;旧的单 GPX 文件和私有黄金计数接口不再支持。 +- 缺少回归 CLI 能力时普通本地检查会明确标为 skipped,发布门禁则视为失败。 + ## [0.1.0] - 2026-08-08 ### 新增 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db68c77..3684306 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,7 @@ ## 数据与隐私 -不要提交真实 NEF、其他照片、GPX、XMP、地图截图或含坐标的日志。真实样本只能放在被 Git 忽略的 `.local/` 下,并且写入测试必须使用副本。合成夹具应固定使用虚构时间和位置。 +不要提交真实 NEF、其他照片、GPX、XMP、地图截图或含坐标的日志。不要在源码、文档、Issue 或 PR 中写入真实绝对路径、文件名和精确时间线。真实样本只能通过参数引用;本地报告放在被 Git 忽略的 `.local/` 下,写入测试必须使用副本。合成夹具应固定使用虚构时间和位置。 RawGeoSync 不上传照片、轨迹或坐标。新增网络请求、遥测、反向地理编码或云端依赖需要单独的设计讨论和用户同意。 @@ -21,10 +21,18 @@ RawGeoSync 不上传照片、轨迹或坐标。新增网络请求、遥测、反 1. 从 `main` 创建主题分支,分支名使用 `agent/<简短描述>` 或 `feature/<简短描述>`。 2. 先修改核心模型和测试,再修改适配器或界面;不要让 SwiftUI 视图直接依赖 GPX/XML 或 ExifTool 细节。 -3. 运行 `Scripts/format-check.sh`、`Scripts/verify-vendor.sh` 和 `Scripts/test.sh`。 +3. 运行 `Scripts/repository-policy-check.sh`、`Scripts/format-check.sh`、`Scripts/verify-vendor.sh` 和 `Scripts/test.sh`。合并前运行一次 `Scripts/ci.sh`。 4. 提交信息使用简短、可读的动词开头,例如 `feat: ...`、`fix: ...`、`test: ...`、`docs: ...`。 5. Pull Request 中说明行为变化、数据安全影响和已运行的验证命令。 +匹配规则的阈值、来源优先级、传播边界或 confidence 语义发生变化时,必须同步更新 ADR、reason code 测试和 dry-run 报告契约。证据等级不是概率;只有来源明确给出 hacc 时才能展示传感器精度。 + +## 真实数据回归 + +`Scripts/real-data-regression.sh` 是唯一支持的原始数据只读入口。它接收 GPX 目录和照片目录,不接受写入目标;报告和前后哈希清单只能写入 `.local/` 或另一个与输入目录无包含关系的目录。 + +真实回归结果不得上传。PR 中只写脱敏聚合信息,例如照片数、轨迹点数、规则计数、wall time 和峰值内存。需要测试 XMP 写入时,先创建新的 `.local/write-regression/` 副本,并人工核对解析后的目标路径不是原始目录。 + ## 元数据边界 - RAW 文件永远不能作为写入目标。 @@ -36,7 +44,9 @@ RawGeoSync 不上传照片、轨迹或坐标。新增网络请求、遥测、反 - [ ] 没有提交真实照片、GPX、XMP、绝对路径或秘密 - [ ] 新增策略分支有合成单元测试 +- [ ] 候选来源、reason code、传播跳数和冲突行为可解释 +- [ ] 未把规则推断范围写成传感器精度或概率 - [ ] 取消、失败、冲突和重复运行仍然安全 - [ ] RAW SHA-256 在相关测试前后保持不变 -- [ ] 格式检查、包测试和应用构建通过 +- [ ] 仓库策略、格式检查、包测试、应用构建和静态分析通过 - [ ] README、CHANGELOG 或决策文档已同步更新 diff --git a/Docs/Decisions/0002-matching-v2.md b/Docs/Decisions/0002-matching-v2.md new file mode 100644 index 0000000..c2f386c --- /dev/null +++ b/Docs/Decisions/0002-matching-v2.md @@ -0,0 +1,106 @@ +# ADR 0002:匹配 v2 的证据链、分层回退与冲突边界 + +状态:已接受(2026-08-09) + +## 背景 + +GPX 记录器可能按位移而不是固定时间采样;静止、后台暂停、跨城交通和航班会形成从数分钟到数天的空洞。照片还可能携带独立 GPS、相机最近定位、同一拍摄序列或另一台相机的锚点。仅按“最近时间点”选择位置,会把活动区或城市之间的轨迹错误传播到照片。 + +v2 的目标是在尽可能提高覆盖率的同时,使每个结果都能回答三个问题:位置来自哪里、经过了几次传播、为何允许或拒绝自动采用。`confidence` 表示证据等级,不是统计概率;没有传感器精度时不得生成看似精确的米级误差。 + +## 决策 + +### 候选来源优先级 + +同一照片可产生多个 `LocationCandidate`,来源优先级从高到低为: + +1. `manualOverride` +2. `directSensor` +3. `sameAsset` +4. `gpxExact` +5. `gpxInterpolated` +6. `embeddedFreshFix` +7. `embeddedTrackFix` +8. `burstPropagation` +9. `sequencePropagation` / `stationaryBounded` +10. `crossCamera` +11. `activityRegion` + +低层候选只能补足缺失,不得覆盖可比较的高层候选。人工位置具有最高优先级,但必须保留其人工来源,不能伪装成传感器观测。 + +只有 `high` 或用户明确给出的 `manual` 候选可以形成无需确认的终态;`medium` 与 `low` 一律进入待确认。因而 burst、照片序列、有界停留和跨相机传播即使满足各自门槛,也不会显示为绿色可靠结果。 + +来源到输入的映射如下:`directSensor` 是目标曝光自身的直接定位观测;`sameAsset` 是同一照片的 sidecar、渲染衍生物或显式同资产关系提供的位置;`embeddedFreshFix` 是相机内嵌且仍在 fresh-age 门槛内的单次 fix;`embeddedTrackFix` 是由一组相机内嵌 fix 构成的逻辑轨迹候选。`gpxExact` 与 `gpxInterpolated` 只来自外部 GPX 逻辑轨迹。 + +### 证据与可追溯性 + +候选必须携带 `sourceKind`、`granularity`、`confidence`、稳定的 `ruleID` 和 `LocationEvidence`。证据至少记录:来源标识、相关照片标识、UTC 观测时间、定位年龄、源提供的水平精度、规则推断范围、传播跳数、是否发生循环及脱敏备注。 + +- `horizontalAccuracyMeters` 只表示源明确提供的传感器精度;源未提供时为 `nil`,界面显示“源未提供定位精度”。 +- `estimatedRadiusMeters` 只能表示由锚点离散度或活动区边界计算出的保守推断范围,必须与传感器精度分开展示;无可验证几何边界时为 `nil`。 +- 传播只允许从无需复核的 primary anchor 出发,最多一跳。由传播得到的候选不能继续充当传播锚点。 +- `isCircular` 为真或证据图形成环时,候选不得自动采用。 + +primary anchor 限于 `manualOverride` 到 `embeddedTrackFix` 这些来源中无需复核、且 confidence 为 `manual` 或 `high` 的候选。当前规则不会产生无需复核的 `medium` 候选。“可比较强候选”同样指来源位于这一闭区间且无需复核的候选;不同来源之间也必须比较。人工覆盖由用户显式决定,因此不参与强候选距离冲突;其余可比较强候选都受冲突门槛约束。 + +当前 confidence 映射是确定的:有效人工覆盖为 `manual`;未触发复核的直接传感器、同资产、GPX exact、密集 GPX 插值、fresh embedded fix 和 embedded fix 轨迹为 `high`;burst、sequence、有界停留为 `medium` 且始终复核;稀疏插值、nearest、跨相机和自动学习活动区为 `low` 且始终复核。直接或 embedded 观测的源水平精度超过 500 米、同资产关系形成循环,都会降为 `low` 并要求复核。 + +### 默认规则 + +| 规则 | v2 默认边界 | 生成与采用约束 | +| --- | --- | --- | +| 直接 GPS | 定位年龄不超过 60 秒 | 源有效且无强候选冲突 | +| 相机 fresh fix | 定位年龄不超过 120 秒 | 保留原始观测时间和精度 | +| 密集 GPX | 相邻点不超过 60 秒且端点不超过 250 米 | 可作可靠插值 | +| 稀疏 GPX | 相邻点不超过 600 秒 | 只生成待确认插值;其余普通空洞切断会话 | +| 停留候选 | 600 秒到 6 小时且端点不超过 150 米 | 只作待确认候选,不把端点接近当作停留事实 | +| 航班边界 | 推导速度达到 100 米/秒 | 切断普通地面传播与插值 | +| 拍摄 burst | 相邻 30 秒、序号差不超过 3、锚点离散不超过 200 米 | 单跳、非循环,作为待确认候选 | +| 照片序列 | 相邻 300 秒、序号差不超过 50、锚点离散不超过 500 米 | 只作补足,通常需复核 | +| 有界停留 | 双锚跨度不超过 1,800 秒、锚点距离不超过 500 米 | 双锚一致且无活动边界,作为待确认候选 | +| 跨相机 | 时间差不超过 120 秒、锚点一致在 500 米内 | 由强锚或多个独立一致锚点生成;结果仍为 `low` 并待确认 | +| 活动区 | 同一活动内相邻照片不超过 30 分钟形成拍摄会话 | 仅在该会话首末照片时间内作粗粒度补足,不覆盖强候选 | + +阈值是规则门槛,不是定位精度。任何可比较的强候选相距超过 1,000 米时,解析结果必须为 `conflict`,不得用优先级悄悄选中一方。1,000 米以内也不等价于一致;各规则仍需满足自身更严格的离散度边界。 + +### 活动区回退的会话化 + +活动目录是有用的上下文,但不能直接等同于单一地点。覆盖优先模式先在每个活动内按拍摄时间排序,再以相邻照片超过 30 分钟为边界切成独立拍摄会话。每个区域候选必须携带 `activeFromUTC` 与 `activeToUTC`,解析器只允许它匹配该会话时间包络内的照片,防止旅行日的一个城市代表点覆盖同日其他城市。 + +会话代表位置按以下顺序建立: + +1. 使用从“会话首张照片前 1 小时”到“会话末张照片后 1 小时”的连续闭区间内全部 GPX 点计算代表位置;点集为空或半径超过 150 公里时转入下一层回退。 +2. 在用户为本次任务选择的 IANA 时区所定义的同一当地日中,排除明确速度不低于 80 米/秒的点,选择时间最接近会话中点的点,再收集距该点不超过 50 公里的所有同日点,计算城市级代表;推断半径下限为 10 公里。 +3. 若当天也缺乏可用点,取时间上紧邻会话起点之前与会话终点之后的两个 GPX 点。仅当两点分别距会话不超过 36 小时、两点相距不超过 1 公里时,生成“相邻日同地点”候选;推断半径下限同为 10 公里。它仍只表示端点一致,不能证明中间从未离开,因此必须复核。 + +最终半径超过 150 公里的候选仍然丢弃。上述自动学习区域全部为 `low` 证据、需要确认;用户明确放置的活动 pin 才是 `manual`。这些半径是区域覆盖范围,不是相机或手机的定位精度。 + +所有点集都使用同一确定性代表算法:分别计算纬度和经度中位数,然后在原始点集中选择距离这个中位中心最近的实际轨迹点;若距离相同,以输入的稳定时间/来源顺序为准。将 `n` 个距离升序排列为从 0 开始的数组,`estimatedRadiusMeters` 取索引 `round(0.9 × (n - 1))` 的元素,最小为 1 米。算法不制造轨迹中不存在的新坐标,也不把该统计范围当作传感器精度。 + +本 ADR 中全部空间距离统一使用半径 6,371,000 米球体上的 haversine 大圆距离;插值使用跨 180 度安全的球面线性插值。阈值边界测试必须调用同一 `GeoMath` 实现,不能混用平面投影距离。 + +用户活动 pin 在候选模型中仍使用 `activityRegion` 来源,但 `ActivityRegionSource=userPin` 会把 confidence 设为 `manual` 且无需复核;它不会改名为 `manualOverride`,两者分别表示“对单张资产的人工覆盖”和“用户为活动会话指定的区域”。 + +`embeddedTrackFix` 只由 `cameraEmbedded` 观测构建,时间必须取 GPS fix 自身的 UTC 时间而非照片拍摄时间。构建器按时间及稳定 ID 排序,并按“毫秒时间戳 + 纳度经纬度”去重;随后复用与外部 GPX 相同的规范化、断段、航班边界和匹配规则。exact / 密集插值为 `high`,稀疏插值 / nearest 为 `low` 且需复核。nearest 只在照片距轨迹端点或普通 gap 边界不超过 120 秒时生成;同样接近两个边界且时间差完全相等时形成歧义而不自动选边。匹配优先级是 exact、密集插值、稀疏插值、nearest;轨迹来源之间再按显式 source priority 和候选一致性解决。 + +### 时钟校准 + +自动建议相机时钟偏移至少需要三条独立配对。偏移样本的 MAD 不超过 2 秒可标为高证据,不超过 10 秒可标为中等证据;超出时只展示建议和样本,不自动套用。保存的是相机配置和用户决定,不持久化完整位置时间线。 + +### 覆盖优先模式 + +默认模式只自动选择无需确认的强证据。覆盖优先模式可依次使用 burst、序列、有界停留、跨相机和活动区候选,但待确认状态不能因“希望每张都有 GPS”而被提升。仍无可接受证据时,用户可以在地图上为一组照片指定手工位置。 + +写入清单必须保留最终坐标对应的来源层级、规则、证据摘要和确认状态。XMP 只承载照片位置,不能取代本地事务清单中的推断记录。 + +## 被否决的替代方案 + +- 固定三分钟或其他固定采样间隔:实际记录机制可能按位移和运动状态自适应。 +- 对所有 GPX 空洞线性插值:会穿过停留、离开后返回、城市跳跃和航班边界。 +- 直接采用全天或全文件中心点:多活动区日期会产生并不存在的位置。 +- 用统一“预计误差”包装所有结果:源没有 hacc 时会制造虚假精度。 +- 允许传播候选继续传播:会放大一次错误并形成不可解释的循环证据。 + +## 后果 + +匹配结果数量会增加,但自动采用比例不会等量增加;覆盖率和可靠性必须分别报告。模型、界面、dry-run 报告和事务清单都需要支持来源层级与 reason code。规则阈值必须由合成边界测试和只读真实回归共同验证,任何改变都需要更新本 ADR 或后续决策文档。 diff --git a/Docs/PRIVACY.md b/Docs/PRIVACY.md index 965df56..848e136 100644 --- a/Docs/PRIVACY.md +++ b/Docs/PRIVACY.md @@ -1,9 +1,43 @@ -# 隐私说明 +# 数据隐私与本地处理规范 -RawGeoSync 的核心处理在本机完成。应用不会上传照片、RAW、GPX、XMP、GPS 坐标或使用遥测服务,也不执行反向地理编码。 +RawGeoSync 处理照片、拍摄时间和精确位置,这些数据可以还原个人行程。项目把它们视为敏感个人数据,并以“本地优先、最少保留、默认不分享”为边界。 -应用可能在地图视图可见时通过 Apple MapKit 获取地图瓦片;地图服务的网络请求由 macOS 和 Apple 的服务条款管理。关闭地图或只使用表格预览时,不需要该地图显示功能。 +## 数据流 -应用只在用户选择的 GPX 文件和照片目录范围内读取。原始照片只读;写入阶段仅生成同目录 XMP 临时文件并通过事务方式替换。事务备份保存在用户的 Application Support 目录中,用于撤销和崩溃恢复,并按默认的最近 10 批且不超过 30 天策略清理。 +| 数据 | 读取位置 | 用途 | 默认持久化 | +| --- | --- | --- | --- | +| RAW 与照片 | 用户选择的目录 | 读取拍摄时间和已有元数据 | 不复制、不修改 | +| GPX | 用户选择的文件或目录 | 建立时间到位置的候选 | 不保存完整轨迹副本 | +| XMP sidecar | 照片同目录 | 预检、合并和写入 GPS | 用户确认后创建或更新 | +| 匹配证据 | 内存 | 解释候选与冲突 | 未写入的分析默认不保存;已写入项在事务清单保留必要证据 | +| 原 XMP 备份 | Application Support | 撤销和崩溃恢复 | 默认最近 10 批且不超过 30 天 | +| 偏好 | UserDefaults | 时区、相机偏移等设置 | 不包含完整轨迹或照片内容 | -诊断日志默认不包含绝对路径、文件名、坐标或完整时间线。用户主动导出诊断信息时,应先检查内容并去除个人数据后再分享。 +RAW 始终只读。分析和 dry-run 不得在 GPX 或照片源目录创建临时文件、缓存、XMP、索引或隐藏文件。写入阶段只允许修改同名 XMP sidecar,并必须经过不可变计划、原子替换和复读验证。 + +事务清单为完成撤销、幂等和来源审计,可能包含本地文件引用、GPS、指纹和已选证据;它与 XMP 备份同属敏感本地数据,受相同保留和清理策略约束,不得作为普通诊断日志分享。 + +## 网络边界 + +核心解析、匹配和元数据处理不使用网络,不上传照片、轨迹、坐标、文件名或遥测,也不执行反向地理编码。地图视图可见时,Apple MapKit 可能按系统行为请求地图瓦片;用户可以关闭地图并只使用表格工作流。新增网络请求、崩溃上报、云同步或第三方分析服务必须通过新的 ADR、威胁评估和明确的用户同意。 + +## 日志与报告 + +默认日志和可提交的测试报告不得包含: + +- 绝对路径、用户目录名和真实文件名; +- 经纬度、完整 GPX 片段和精确拍摄时间线; +- 未脱敏的 XMP、EXIF 或事务备份; +- 可反推出个人活动的地图截图或地点名称。 + +本地只读回归可以在被 Git 忽略的 `.local/` 中保存逐文件哈希、相对路径和完整结果,供本人核验;这些产物不得上传到 Issue、Pull Request、CI artifact 或 Release。对外分享时只保留聚合计数、耗时、峰值内存、规则分布和已脱敏错误类别。 + +## 开发与测试 + +单元测试使用虚构时间、位置和合成夹具。真实数据只能通过参数传入,不得写入脚本、README、测试源码或 CI 配置。只读回归用 macOS sandbox 拒绝源目录写入,并在运行前后比较 GPX 和照片目录的内容哈希与基础元数据;任何差异都视为失败。需要验证写入、幂等或撤销时,必须先把最小样本复制到 `.local/` 下的新目录,并再次确认目标不是原始目录。 + +仓库门禁会拒绝本机绝对路径、疑似高精度坐标、常见密钥、真实照片、XMP、测试夹具目录之外的 GPX,以及未忽略的真实回归报告/哈希清单。自动检查只是下限,贡献者仍需人工检查提交和生成报告。 + +## 删除、撤销与分享 + +用户可以撤销仍未被外部程序修改的事务,并可删除 Application Support 中的历史备份。清理备份不会删除原始照片或用户主动保留的 XMP。若怀疑真实位置或照片被误提交,应立即停止分享、从当前分支移除数据并按安全策略报告;仅新增 `.gitignore` 不能清除既有 Git 历史。 diff --git a/Docs/RELEASE.md b/Docs/RELEASE.md index 7d5045d..1a42db7 100644 --- a/Docs/RELEASE.md +++ b/Docs/RELEASE.md @@ -6,10 +6,30 @@ 1. 确认工作区干净,目标提交已合并到 `main`。 2. 更新 `CHANGELOG.md`、README 和本次决策文档。 -3. 执行 `Scripts/format-check.sh`、`Scripts/verify-vendor.sh`、两个 Swift Package 的 Debug/Release 测试,以及应用 Debug/Release 构建。 -4. 执行 `xcodebuild analyze`,检查 `git diff --check`,确认没有照片、轨迹、XMP、日志或密钥。 +3. 执行 `Scripts/ci.sh`,完成仓库策略、vendor、格式、两个 Swift Package 的 Debug/Release 测试、应用 Debug/Release 构建与静态分析。 +4. 检查 `git status` 和 CI 日志,确认没有照片、轨迹、XMP、本机路径、真实坐标、日志或密钥。 5. 为版本创建带注释的 Git tag,并推送提交和 tag。 +## v0.2 匹配与全量回归门禁 + +1. 确认 [ADR 0002](Decisions/0002-matching-v2.md) 的来源层级、阈值、reason code 与实现一致。 +2. 用合成夹具验证每个门槛两侧、强候选冲突、传播单跳、循环拒绝、航班断段和活动区降级。 +3. 使用参数化真实数据执行: + + ```sh + ./Scripts/real-data-regression.sh \ + --gpx-dir "$GPX_DIR" \ + --photo-dir "$PHOTO_DIR" \ + --cli "$REGRESSION_CLI" \ + --require-capability + ``` + +4. 在同一机器运行三次,分类和规则分布完全一致;wall time 与峰值内存满足 [测试规范](TESTING.md) 的回归预算。 +5. 只在 `.local/` 的最小照片副本上执行 XMP 应用、幂等和撤销;原始 GPX、照片目录及 RAW SHA-256 前后不变。 +6. 不上传真实 dry-run 报告、哈希清单、文件名、坐标或性能日志原件。PR 和 Release 仅记录脱敏聚合指标。 + +`--require-capability` 只禁止缺失能力被跳过;发布者仍需完成第 2、4、5 步的语义、性能与副本写入验收。 + ## 构建发布 个人本机使用可以发布未签名的 `.app`。对外分发前必须: @@ -29,7 +49,10 @@ Mac App Store 不是当前 MVP 目标。若未来进入 Mac App Store,需要 - 已有不同 GPS 默认跳过; - 重复运行识别为 already-applied 且不改变 XMP mtime; - 取消、单项失败和崩溃不会留下半写 XMP; -- 撤销遇到后续 Lightroom 修改时必须拒绝覆盖。 +- 撤销遇到后续 Lightroom 修改时必须拒绝覆盖; +- 强候选冲突、传播循环和跨活动区候选不能自动写入; +- 源无 hacc 时界面和报告都显示 unknown,不生成伪精度; +- 全量 dry-run 的输入目录前后 SHA-256 清单完全一致。 ## 第三方组件 diff --git a/Docs/TESTING.md b/Docs/TESTING.md index d589740..2815cb4 100644 --- a/Docs/TESTING.md +++ b/Docs/TESTING.md @@ -1,18 +1,94 @@ -# 测试与验收 +# 测试与验收规范 -## 自动回归 +## 测试层级 -`Scripts/test.sh` 依次运行核心包、元数据包和 macOS 应用构建。当前基线为 RawGeoCore 23 项、MetadataInfrastructure 15 项全部通过;另执行 Release 测试、`xcodebuild analyze` 和 Swift 格式检查。测试数据必须是合成数据,不得提交真实坐标、照片或年度 GPX。 +1. `RawGeoCore` 合成单元测试覆盖 GPX、时间归一化、规则边界、证据优先级、传播单跳和冲突解析。 +2. `MetadataInfrastructure` 合成与 ExifTool 契约测试覆盖只读扫描、XMP 合并、幂等、原子写入、取消、失败继续和安全撤销。 +3. 应用构建与静态分析验证 Swift 6 严格并发和模块集成。 +4. 参数化真实数据回归只执行 dry-run,用于发现记录器行为、相机元数据和全量性能问题。 +5. 写入验收只对 `.local/` 中的最小副本执行,不接触原始目录。 -重点覆盖:GPX格式与坏点、时区和相机偏移、匹配阈值边界、大圆插值、停留候选、缺轨、多轨冲突、ExifTool进程错误、XMP合并、幂等、原子写入、失败继续和安全撤销。 +测试数量会随功能演进,不在文档中锁定。门禁以命令退出状态、行为断言和不变量为准。 -## 真实样本 +## 本地与 CI 命令 -真实样本只读引用: +快速开发验证: -- `/Users/simplechen/Downloads/2026.01.01-2026.12.31.gpx` -- `/Users/simplechen/Picture/2026-8-8 我们四在东莞/Z50` +```sh +./Scripts/repository-policy-check.sh +./Scripts/format-check.sh +./Scripts/test.sh +``` -黄金预览为29张可靠匹配、23张属于同一停留候选批次、0张跨明显缺轨自动插值。任何写测试必须先复制必要文件到 `.local/tmp/<唯一目录>`,并验证原始 NEF 的 SHA-256 始终不变。 +与 CI 等价的完整门禁会额外运行两个 Package 的 Release 测试、应用 Release 构建和 `xcodebuild analyze`: -真实写入验收须满足:复制品生成29个 XMP;第二次预检全部识别为 already-applied 且 mtime 不变;撤销后29个新建 XMP 全部移除;原始目录与复制目录的52个 NEF SHA-256 前后一致。真实原图目录不得出现 XMP。 +```sh +./Scripts/ci.sh +``` + +## v2 规则验收 + +合成夹具至少覆盖以下边界及其门槛两侧: + +- GPX 断段、倒序、重复时间戳、孤立坏点、长空洞和航班边界; +- direct、same-asset、GPX、相机 fix、burst、sequence、有界停留、跨相机和活动区的优先级; +- 传播只使用非复核 primary anchor、只传播一跳且不能形成循环; +- 可比较强候选相距超过冲突门槛时保持 `conflict`; +- 活动区等弱候选不能覆盖强候选; +- 源无 hacc 时保持 unknown,不产生伪米级精度; +- 时区、夏令时歧义、相机时钟偏移和多相机校准; +- 每张照片恰有一个终态,分类计数之和等于扫描照片数。 + +阈值改变需要添加“刚好低于、等于、刚好高于”三类测试,并同步 ADR。 + +## 真实只读全量回归 + +真实路径只能通过参数或环境变量提供: + +```sh +./Scripts/real-data-regression.sh \ + --gpx-dir "$GPX_DIR" \ + --photo-dir "$PHOTO_DIR" \ + --cli "$REGRESSION_CLI" +``` + +脚本要求 GPX **目录** 和照片目录,并拒绝包含符号链接的输入,避免链接目标逃出只读证明范围。仓库内输出目录必须由 `git check-ignore` 明确确认已忽略。脚本通过 macOS sandbox 拒绝对两个源目录的写入,再把能力声明、CLI 输出、dry-run 报告、性能日志和输入目录前后内容/基础元数据快照写到 `.local/real-regression/`。未来 CLI 的稳定契约为: + +```text + capabilities --format json + dry-run --gpx-directory --photo-directory \ + --report --read-only-source-directories +``` + +能力 JSON 必须声明 `schemaVersion=1`、`features.fullCorpusDryRun=true` 和 `guarantees.readOnlySourceDirectories=true`;报告必须声明 `schemaVersion=1` 与 `mode=dry-run`。找不到 CLI,或 CLI 以退出状态 78 明确表示能力不可用时,脚本默认输出 `SKIP`;发布验收使用 `--require-capability` 将这两种情况变为失败。CLI 已存在时的崩溃、权限错误、无效 JSON 或契约回退一律失败,不能降级成跳过。 + +`--require-capability` 只证明发布候选具备并完成了一次全量 dry-run,不替代下面的重复性、语义和性能验收。当前 CLI 报告 API 尚未稳定,因此脚本只机器校验 schema、mode、退出状态、sandbox 和输入快照;唯一终态、reason code、冲突与规则分布由发布清单显式复核,待报告 schema 冻结后再提升为机器门禁。 + +真实回归通过条件: + +- sandbox 未报告源目录写入,且 GPX 和照片目录前后内容哈希、inode、mode、uid/gid、链接数、mtime 和 ctime 一致,没有新增 XMP、缓存或隐藏文件; +- 所有可读取照片进入唯一终态,失败项有脱敏 reason code; +- 重复运行在相同输入与配置下得到相同分类、候选来源和规则分布; +- 强冲突、飞行边界和跨活动段不会被覆盖优先模式越过; +- 报告不把证据等级描述为概率或传感器精度。 + +精度字段还必须覆盖四种组合:源 hacc 与几何推断半径可同时存在;多锚推断可以 hacc 为 nil 而半径非 nil;单锚且无源 hacc 时两者都为 nil;UI 与报告始终把“传感器精度”和“推断范围”分栏展示。 + +## 性能验收 + +CLI 运行时间与输入哈希时间分开记录。`real-data-regression.sh` 报告 CLI wall time 和 macOS `time -l` 的 maximum resident set size;逐文件 SHA-256 只用于只读证明,不计入匹配性能。 + +发布候选在同一台机器、相同电源模式、相同输入和空闲系统下连续运行三次,取中位数: + +- 分类计数、规则分布和终态必须三次完全一致; +- 相对已接受基线,wall time 不得回退超过 20%,峰值常驻内存不得回退超过 15%; +- 没有历史基线时,先记录脱敏的照片数、轨迹点数、wall time、峰值内存和工具链,作为该机器的 v0.2 基线; +- 合成缩放测试把照片数和轨迹点数各扩大一倍时,wall time 不应超过原来的 2.5 倍;超出必须分析算法复杂度后才能发布。 + +不同机器的绝对耗时不可直接比较。性能证据只提交聚合指标,不提交本地路径、文件名、坐标或报告原件。 + +本地基线保存在 `.local/performance-baselines/<机器类别>-<工具链>.json`,至少包含 schemaVersion、应用版本、macOS/Xcode/Swift 版本、脱敏语料标识、照片数、轨迹点数、三次 wall/RSS 原始值和中位数。比较公式为 `(候选中位数 - 基线中位数) / 基线中位数`;wall 结果不得大于 0.20,RSS 不得大于 0.15。缩放夹具记录一倍与两倍规模的同一指标,并计算 `两倍 / 一倍`,不得大于 2.5。基线文件只留本机,不进入 Git。 + +## 副本写入验收 + +从真实数据中选择最小集合,复制到 `.local/write-regression/<随机目录>/` 后执行:首次预检、应用、复读、第二次预检、撤销。必须证明 RAW SHA-256 始终不变,第二次预检为 already-applied 且 XMP mtime 不变,撤销不会覆盖之后被 Lightroom 修改的 sidecar。原始目录的前后快照也必须保持一致。 diff --git a/MetadataInfrastructure/Sources/MetadataInfrastructure/ExifToolClient.swift b/MetadataInfrastructure/Sources/MetadataInfrastructure/ExifToolClient.swift index bc9fe47..f2d2c92 100644 --- a/MetadataInfrastructure/Sources/MetadataInfrastructure/ExifToolClient.swift +++ b/MetadataInfrastructure/Sources/MetadataInfrastructure/ExifToolClient.swift @@ -23,6 +23,7 @@ public struct ExifToolConfiguration: Hashable, Sendable { ExifToolConfiguration( executableURL: URL(fileURLWithPath: "/usr/bin/perl"), leadingArguments: [scriptURL.standardizedFileURL.path], + timeout: .seconds(120), minimumVersion: try ExifToolVersion("13.59") ) } @@ -81,6 +82,25 @@ private enum JSONValue: Codable, Hashable, Sendable { default: nil } } + + var int64Value: Int64? { + switch self { + case .number(let value) where value.isFinite: + Int64(exactly: value) + case .string(let value): + Int64(value) + default: + nil + } + } + + var canonicalStringValue: String? { + if let stringValue { return stringValue } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(self) else { return nil } + return String(data: data, encoding: .utf8) + } } public actor ExifToolClient: MetadataTooling { @@ -107,10 +127,21 @@ public actor ExifToolClient: MetadataTooling { } public func readRawMetadata(_ files: [ReadOnlyRawFile]) async throws -> [RawPhotoMetadata] { + let mediaMetadata = try await readMediaMetadata(files.map(\.mediaFile)) + let byURL = Dictionary(uniqueKeysWithValues: mediaMetadata.map { ($0.mediaFile.url, $0) }) + return try files.map { rawFile in + guard let metadata = byURL[rawFile.url] else { + throw MetadataInfrastructureError.missingMetadata(rawFile.url) + } + return RawPhotoMetadata(mediaMetadata: metadata, rawFile: rawFile) + } + } + + public func readMediaMetadata(_ files: [ReadOnlyMediaFile]) async throws -> [MediaMetadata] { guard !files.isEmpty else { return [] } let arguments = [ - "-json", "-G1", "-a", "-s", + "-json", "-G1", "-a", "-s", "-struct", "-EXIF:DateTimeOriginal", "-EXIF:SubSecTimeOriginal", "-EXIF:OffsetTimeOriginal", @@ -118,6 +149,19 @@ public actor ExifToolClient: MetadataTooling { "-EXIF:GPSLongitude#", "-EXIF:GPSAltitude#", "-EXIF:GPSAltitudeRef#", + "-Make", + "-Model", + "-SerialNumber", + "-InternalSerialNumber", + "-ShutterCount#", + "-FileSize#", + "-GPSDateTime", + "-GPSHPositioningError#", + "-DocumentID", + "-OriginalDocumentID", + "-DerivedFrom", + "-Software", + "-XMPToolkit", ] + files.map(\.url.path) let objects = try await readJSON(arguments) var byPath: [String: [String: JSONValue]] = [:] @@ -126,18 +170,69 @@ public actor ExifToolClient: MetadataTooling { byPath[URL(fileURLWithPath: sourcePath).standardizedFileURL.path] = object } - return try files.map { rawFile in - guard let object = byPath[rawFile.url.path] else { - throw MetadataInfrastructureError.missingMetadata(rawFile.url) + return try files.map { mediaFile in + guard let object = byPath[mediaFile.url.path] else { + throw MetadataInfrastructureError.missingMetadata(mediaFile.url) } let parsedGPS = try parsedGPS(in: object) - return RawPhotoMetadata( - rawFile: rawFile, + return MediaMetadata( + mediaFile: mediaFile, dateTimeOriginal: value(in: object, suffix: "DateTimeOriginal")?.stringValue, subsecondTimeOriginal: value(in: object, suffix: "SubSecTimeOriginal")?.stringValue, offsetTimeOriginal: value(in: object, suffix: "OffsetTimeOriginal")?.stringValue, gps: parsedGPS.metadata, - gpsIsPartial: parsedGPS.isPartial + gpsIsPartial: parsedGPS.isPartial, + make: value(in: object, preferredKeys: ["IFD0:Make"], suffix: "Make")?.stringValue, + model: value(in: object, preferredKeys: ["IFD0:Model"], suffix: "Model")?.stringValue, + serialNumber: value( + in: object, + preferredKeys: ["Nikon:SerialNumber", "EXIF:SerialNumber"], + suffix: "SerialNumber" + )?.stringValue, + internalSerialNumber: value( + in: object, + preferredKeys: ["Nikon:InternalSerialNumber"], + suffix: "InternalSerialNumber" + )?.stringValue, + shutterCount: value( + in: object, + preferredKeys: ["Nikon:ShutterCount"], + suffix: "ShutterCount" + )?.int64Value.flatMap(Int.init(exactly:)), + fileSize: value(in: object, preferredKeys: ["File:FileSize"], suffix: "FileSize")? + .int64Value, + gpsDateTime: value( + in: object, + preferredKeys: ["Composite:GPSDateTime", "EXIF:GPSDateTime"], + suffix: "GPSDateTime" + )?.stringValue, + gpsHorizontalPositioningError: value( + in: object, + preferredKeys: ["EXIF:GPSHPositioningError"], + suffix: "GPSHPositioningError" + )?.doubleValue, + documentID: value( + in: object, + preferredKeys: ["XMP-xmpMM:DocumentID"], + suffix: "DocumentID" + )?.stringValue, + originalDocumentID: value( + in: object, + preferredKeys: ["XMP-xmpMM:OriginalDocumentID"], + suffix: "OriginalDocumentID" + )?.stringValue, + derivedFrom: value( + in: object, + preferredKeys: ["XMP-xmpMM:DerivedFrom"], + suffix: "DerivedFrom" + )?.canonicalStringValue, + software: value(in: object, preferredKeys: ["IFD0:Software"], suffix: "Software")? + .stringValue, + xmpToolkit: value( + in: object, + preferredKeys: ["XMP-x:XMPToolkit"], + suffix: "XMPToolkit" + )?.stringValue ) } } @@ -156,6 +251,22 @@ public actor ExifToolClient: MetadataTooling { throw MetadataInfrastructureError.missingMetadata(sidecar.url) } let parsedGPS = try parsedGPS(in: object) + let documentID = value( + in: object, preferredKeys: ["XMP-xmpMM:DocumentID"], suffix: "DocumentID" + )?.stringValue + let originalDocumentID = value( + in: object, + preferredKeys: ["XMP-xmpMM:OriginalDocumentID"], + suffix: "OriginalDocumentID" + )?.stringValue + let derivedFrom = value( + in: object, preferredKeys: ["XMP-xmpMM:DerivedFrom"], suffix: "DerivedFrom" + )?.canonicalStringValue + let software = value(in: object, preferredKeys: ["IFD0:Software"], suffix: "Software")? + .stringValue + let xmpToolkit = value( + in: object, preferredKeys: ["XMP-x:XMPToolkit"], suffix: "XMPToolkit" + )?.stringValue object = object.filter { key, _ in !Self.ignoredForSemanticDigest(key) } @@ -166,7 +277,12 @@ public actor ExifToolClient: MetadataTooling { return SidecarMetadata( gps: parsedGPS.metadata, gpsIsPartial: parsedGPS.isPartial, - nonGPSSemanticDigest: digest + nonGPSSemanticDigest: digest, + documentID: documentID, + originalDocumentID: originalDocumentID, + derivedFrom: derivedFrom, + software: software, + xmpToolkit: xmpToolkit ) } @@ -218,6 +334,17 @@ public actor ExifToolClient: MetadataTooling { }?.value } + private func value( + in object: [String: JSONValue], + preferredKeys: [String], + suffix: String + ) -> JSONValue? { + for key in preferredKeys { + if let value = object[key] { return value } + } + return value(in: object, suffix: suffix) + } + private func parsedGPS(in object: [String: JSONValue]) throws -> ( metadata: GPSMetadata?, isPartial: Bool ) { diff --git a/MetadataInfrastructure/Sources/MetadataInfrastructure/MetadataModels.swift b/MetadataInfrastructure/Sources/MetadataInfrastructure/MetadataModels.swift index 70f19f1..3098869 100644 --- a/MetadataInfrastructure/Sources/MetadataInfrastructure/MetadataModels.swift +++ b/MetadataInfrastructure/Sources/MetadataInfrastructure/MetadataModels.swift @@ -62,22 +62,49 @@ public enum MetadataInfrastructureError: Error, LocalizedError, Equatable, Senda } } -/// RAW 文件的只读句柄。写入 API 不接受裸 URL 或此类型作为目标。 -public struct ReadOnlyRawFile: Hashable, Codable, Sendable { +public enum ReadOnlyMediaKind: String, Codable, Hashable, Sendable { + case proprietaryRaw + case dng + case jpeg + case tiff +} + +/// 所有可扫描照片的只读句柄。它不能直接成为 sidecar 写入目标。 +public struct ReadOnlyMediaFile: Hashable, Codable, Sendable { public static let supportedExtensions: Set = [ "nef", "nrw", "arw", "cr2", "cr3", "raf", "orf", "rw2", "dng", "jpg", "jpeg", "tif", "tiff", ] public let url: URL + public let kind: ReadOnlyMediaKind public init(url: URL) throws { + let normalized = try Self.validate(url) + let ext = normalized.pathExtension.lowercased() + guard Self.supportedExtensions.contains(ext) else { + throw MetadataInfrastructureError.unsupportedRawExtension(ext) + } + self.url = normalized + switch ext { + case "dng": self.kind = .dng + case "jpg", "jpeg": self.kind = .jpeg + case "tif", "tiff": self.kind = .tiff + default: self.kind = .proprietaryRaw + } + } + + fileprivate init(validatedURL: URL, kind: ReadOnlyMediaKind) { + self.url = validatedURL + self.kind = kind + } + + fileprivate static func validate(_ url: URL) throws -> URL { let normalized = url.standardizedFileURL guard normalized.isFileURL else { throw MetadataInfrastructureError.notAFile(normalized) } - try Self.validateSafeName(normalized) - + try validateSafeName(normalized) let values = try normalized.resourceValues(forKeys: [ .isRegularFileKey, .isSymbolicLinkKey, @@ -88,21 +115,86 @@ public struct ReadOnlyRawFile: Hashable, Codable, Sendable { guard values.isSymbolicLink != true else { throw MetadataInfrastructureError.symbolicLinkNotAllowed(normalized) } - - let ext = normalized.pathExtension.lowercased() - guard Self.supportedExtensions.contains(ext) else { - throw MetadataInfrastructureError.unsupportedRawExtension(ext) - } - self.url = normalized + return normalized } - static func validateSafeName(_ url: URL) throws { + fileprivate static func validateSafeName(_ url: URL) throws { if url.path.unicodeScalars.contains(where: { scalar in CharacterSet.controlCharacters.contains(scalar) }) { throw MetadataInfrastructureError.unsafeFileName(url) } } + + private enum CodingKeys: String, CodingKey { + case url, kind + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let decoded = try Self(url: container.decode(URL.self, forKey: .url)) + if let encodedKind = try container.decodeIfPresent(ReadOnlyMediaKind.self, forKey: .kind), + encodedKind != decoded.kind + { + throw DecodingError.dataCorruptedError( + forKey: .kind, + in: container, + debugDescription: "媒体类型与文件扩展名不一致" + ) + } + self = decoded + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(url, forKey: .url) + try container.encode(kind, forKey: .kind) + } +} + +/// 允许生成相邻 XMP sidecar 的专有 RAW。DNG/JPEG/TIFF 无法构造此类型。 +public struct ReadOnlyRawFile: Hashable, Codable, Sendable { + public static let supportedExtensions: Set = [ + "nef", "nrw", "arw", "cr2", "cr3", "raf", "orf", "rw2", + ] + + public let url: URL + + public init(url: URL) throws { + let media = try ReadOnlyMediaFile(url: url) + guard media.kind == .proprietaryRaw else { + throw MetadataInfrastructureError.unsupportedRawExtension( + media.url.pathExtension.lowercased()) + } + self.url = media.url + } + + public init(mediaFile: ReadOnlyMediaFile) throws { + guard mediaFile.kind == .proprietaryRaw else { + throw MetadataInfrastructureError.unsupportedRawExtension( + mediaFile.url.pathExtension.lowercased() + ) + } + self.url = mediaFile.url + } + + public var mediaFile: ReadOnlyMediaFile { + ReadOnlyMediaFile(validatedURL: url, kind: .proprietaryRaw) + } + + private enum CodingKeys: String, CodingKey { + case url + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + try self.init(url: container.decode(URL.self, forKey: .url)) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(url, forKey: .url) + } } /// 只能指向相邻 XMP sidecar 的写入目标。 @@ -114,16 +206,55 @@ public struct SidecarURL: Hashable, Codable, Sendable { .deletingPathExtension() .appendingPathExtension("xmp") .standardizedFileURL - try ReadOnlyRawFile.validateSafeName(url) + try ReadOnlyMediaFile.validateSafeName(url) self.url = url } + /// 从已经枚举到的相邻 XMP 构造目标。批量扫描可先按目录建立一次索引, + /// 避免为目录中的每张 RAW 重复枚举全部文件。 + public init(existingURL url: URL) throws { + let values = try url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) + guard values.isRegularFile == true, values.isSymbolicLink != true else { + throw MetadataInfrastructureError.notAFile(url) + } + try self.init(validatedURL: url) + } + + public static func existing( + for rawFile: ReadOnlyRawFile, + fileManager: FileManager = .default + ) throws -> SidecarURL? { + let expected = try SidecarURL(for: rawFile) + let directory = rawFile.url.deletingLastPathComponent() + let baseName = rawFile.url.deletingPathExtension().lastPathComponent + let entries = try fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], + options: [.skipsHiddenFiles] + ).filter { candidate in + guard candidate.deletingPathExtension().lastPathComponent == baseName, + candidate.pathExtension.caseInsensitiveCompare("xmp") == .orderedSame, + let values = try? candidate.resourceValues(forKeys: [ + .isRegularFileKey, .isSymbolicLinkKey, + ]) + else { return false } + return values.isRegularFile == true && values.isSymbolicLink != true + } + if entries.count > 1 { + throw MetadataInfrastructureError.sidecarConflict( + expected.url, + "同时存在多个大小写不同的 XMP" + ) + } + return try entries.first.map(SidecarURL.init(validatedURL:)) + } + init(validatedURL url: URL) throws { let normalized = url.standardizedFileURL guard normalized.isFileURL, normalized.pathExtension.lowercased() == "xmp" else { throw MetadataInfrastructureError.notAFile(normalized) } - try ReadOnlyRawFile.validateSafeName(normalized) + try ReadOnlyMediaFile.validateSafeName(normalized) self.url = normalized } } @@ -167,6 +298,96 @@ public struct GPSMetadata: Hashable, Codable, Sendable { } } +public struct MediaMetadata: Hashable, Codable, Sendable { + public let mediaFile: ReadOnlyMediaFile + public let dateTimeOriginal: String? + public let subsecondTimeOriginal: String? + public let offsetTimeOriginal: String? + public let gps: GPSMetadata? + public let gpsIsPartial: Bool + public let make: String? + public let model: String? + public let serialNumber: String? + public let internalSerialNumber: String? + public let shutterCount: Int? + public let fileSize: Int64? + public let gpsDateTime: String? + public let gpsHorizontalPositioningError: Double? + public let documentID: String? + public let originalDocumentID: String? + /// 标量按原值保存;结构化 DerivedFrom 保存为稳定排序的 JSON。 + public let derivedFrom: String? + public let software: String? + public let xmpToolkit: String? + + public init( + mediaFile: ReadOnlyMediaFile, + dateTimeOriginal: String? = nil, + subsecondTimeOriginal: String? = nil, + offsetTimeOriginal: String? = nil, + gps: GPSMetadata? = nil, + gpsIsPartial: Bool = false, + make: String? = nil, + model: String? = nil, + serialNumber: String? = nil, + internalSerialNumber: String? = nil, + shutterCount: Int? = nil, + fileSize: Int64? = nil, + gpsDateTime: String? = nil, + gpsHorizontalPositioningError: Double? = nil, + documentID: String? = nil, + originalDocumentID: String? = nil, + derivedFrom: String? = nil, + software: String? = nil, + xmpToolkit: String? = nil + ) { + self.mediaFile = mediaFile + self.dateTimeOriginal = dateTimeOriginal + self.subsecondTimeOriginal = subsecondTimeOriginal + self.offsetTimeOriginal = offsetTimeOriginal + self.gps = gps + self.gpsIsPartial = gpsIsPartial + self.make = make + self.model = model + self.serialNumber = serialNumber + self.internalSerialNumber = internalSerialNumber + self.shutterCount = shutterCount + self.fileSize = fileSize + self.gpsDateTime = gpsDateTime + self.gpsHorizontalPositioningError = gpsHorizontalPositioningError + self.documentID = documentID + self.originalDocumentID = originalDocumentID + self.derivedFrom = derivedFrom + self.software = software + self.xmpToolkit = xmpToolkit + } + + public init(rawMetadata: RawPhotoMetadata) { + self.init( + mediaFile: rawMetadata.rawFile.mediaFile, + dateTimeOriginal: rawMetadata.dateTimeOriginal, + subsecondTimeOriginal: rawMetadata.subsecondTimeOriginal, + offsetTimeOriginal: rawMetadata.offsetTimeOriginal, + gps: rawMetadata.gps, + gpsIsPartial: rawMetadata.gpsIsPartial, + make: rawMetadata.make, + model: rawMetadata.model, + serialNumber: rawMetadata.serialNumber, + internalSerialNumber: rawMetadata.internalSerialNumber, + shutterCount: rawMetadata.shutterCount, + fileSize: rawMetadata.fileSize, + gpsDateTime: rawMetadata.gpsDateTime, + gpsHorizontalPositioningError: rawMetadata.gpsHorizontalPositioningError, + documentID: rawMetadata.documentID, + originalDocumentID: rawMetadata.originalDocumentID, + derivedFrom: rawMetadata.derivedFrom, + software: rawMetadata.software, + xmpToolkit: rawMetadata.xmpToolkit + ) + } +} + +/// v1 专有 RAW 调用面的兼容模型;新扫描流程应使用 MediaMetadata。 public struct RawPhotoMetadata: Hashable, Codable, Sendable { public let rawFile: ReadOnlyRawFile public let dateTimeOriginal: String? @@ -174,6 +395,19 @@ public struct RawPhotoMetadata: Hashable, Codable, Sendable { public let offsetTimeOriginal: String? public let gps: GPSMetadata? public let gpsIsPartial: Bool + public let make: String? + public let model: String? + public let serialNumber: String? + public let internalSerialNumber: String? + public let shutterCount: Int? + public let fileSize: Int64? + public let gpsDateTime: String? + public let gpsHorizontalPositioningError: Double? + public let documentID: String? + public let originalDocumentID: String? + public let derivedFrom: String? + public let software: String? + public let xmpToolkit: String? public init( rawFile: ReadOnlyRawFile, @@ -181,7 +415,20 @@ public struct RawPhotoMetadata: Hashable, Codable, Sendable { subsecondTimeOriginal: String?, offsetTimeOriginal: String?, gps: GPSMetadata?, - gpsIsPartial: Bool = false + gpsIsPartial: Bool = false, + make: String? = nil, + model: String? = nil, + serialNumber: String? = nil, + internalSerialNumber: String? = nil, + shutterCount: Int? = nil, + fileSize: Int64? = nil, + gpsDateTime: String? = nil, + gpsHorizontalPositioningError: Double? = nil, + documentID: String? = nil, + originalDocumentID: String? = nil, + derivedFrom: String? = nil, + software: String? = nil, + xmpToolkit: String? = nil ) { self.rawFile = rawFile self.dateTimeOriginal = dateTimeOriginal @@ -189,6 +436,43 @@ public struct RawPhotoMetadata: Hashable, Codable, Sendable { self.offsetTimeOriginal = offsetTimeOriginal self.gps = gps self.gpsIsPartial = gpsIsPartial + self.make = make + self.model = model + self.serialNumber = serialNumber + self.internalSerialNumber = internalSerialNumber + self.shutterCount = shutterCount + self.fileSize = fileSize + self.gpsDateTime = gpsDateTime + self.gpsHorizontalPositioningError = gpsHorizontalPositioningError + self.documentID = documentID + self.originalDocumentID = originalDocumentID + self.derivedFrom = derivedFrom + self.software = software + self.xmpToolkit = xmpToolkit + } + + public init(mediaMetadata: MediaMetadata, rawFile: ReadOnlyRawFile) { + self.init( + rawFile: rawFile, + dateTimeOriginal: mediaMetadata.dateTimeOriginal, + subsecondTimeOriginal: mediaMetadata.subsecondTimeOriginal, + offsetTimeOriginal: mediaMetadata.offsetTimeOriginal, + gps: mediaMetadata.gps, + gpsIsPartial: mediaMetadata.gpsIsPartial, + make: mediaMetadata.make, + model: mediaMetadata.model, + serialNumber: mediaMetadata.serialNumber, + internalSerialNumber: mediaMetadata.internalSerialNumber, + shutterCount: mediaMetadata.shutterCount, + fileSize: mediaMetadata.fileSize, + gpsDateTime: mediaMetadata.gpsDateTime, + gpsHorizontalPositioningError: mediaMetadata.gpsHorizontalPositioningError, + documentID: mediaMetadata.documentID, + originalDocumentID: mediaMetadata.originalDocumentID, + derivedFrom: mediaMetadata.derivedFrom, + software: mediaMetadata.software, + xmpToolkit: mediaMetadata.xmpToolkit + ) } } @@ -197,11 +481,30 @@ public struct SidecarMetadata: Hashable, Codable, Sendable { public let gpsIsPartial: Bool /// 除 GPS 和 ExifTool 自身标记外,其余 XMP 的规范化 SHA-256。 public let nonGPSSemanticDigest: String + public let documentID: String? + public let originalDocumentID: String? + public let derivedFrom: String? + public let software: String? + public let xmpToolkit: String? - public init(gps: GPSMetadata?, gpsIsPartial: Bool = false, nonGPSSemanticDigest: String) { + public init( + gps: GPSMetadata?, + gpsIsPartial: Bool = false, + nonGPSSemanticDigest: String, + documentID: String? = nil, + originalDocumentID: String? = nil, + derivedFrom: String? = nil, + software: String? = nil, + xmpToolkit: String? = nil + ) { self.gps = gps self.gpsIsPartial = gpsIsPartial self.nonGPSSemanticDigest = nonGPSSemanticDigest + self.documentID = documentID + self.originalDocumentID = originalDocumentID + self.derivedFrom = derivedFrom + self.software = software + self.xmpToolkit = xmpToolkit } } @@ -234,7 +537,16 @@ public struct ExifToolVersion: Hashable, Codable, Sendable, Comparable, CustomSt public protocol MetadataTooling: Sendable { func checkVersion() async throws -> ExifToolVersion + func readMediaMetadata(_ files: [ReadOnlyMediaFile]) async throws -> [MediaMetadata] func readRawMetadata(_ files: [ReadOnlyRawFile]) async throws -> [RawPhotoMetadata] func readSidecarMetadata(at sidecar: SidecarURL) async throws -> SidecarMetadata func writeGPS(_ gps: GPSMetadata, to sidecar: SidecarURL) async throws } + +extension MetadataTooling { + /// v1 工具实现的兼容适配;跨格式扫描工具应覆盖此方法。 + public func readMediaMetadata(_ files: [ReadOnlyMediaFile]) async throws -> [MediaMetadata] { + let rawFiles = try files.map(ReadOnlyRawFile.init(mediaFile:)) + return try await readRawMetadata(rawFiles).map(MediaMetadata.init(rawMetadata:)) + } +} diff --git a/MetadataInfrastructure/Sources/MetadataInfrastructure/XMPTransaction.swift b/MetadataInfrastructure/Sources/MetadataInfrastructure/XMPTransaction.swift index b85b560..611f608 100644 --- a/MetadataInfrastructure/Sources/MetadataInfrastructure/XMPTransaction.swift +++ b/MetadataInfrastructure/Sources/MetadataInfrastructure/XMPTransaction.swift @@ -4,21 +4,129 @@ import Foundation public enum ExistingGPSPolicy: String, Codable, Hashable, Sendable { case skip case replace + case replaceIfStrongerProvenance +} + +public enum MatchProvenanceSource: String, Codable, Hashable, Sendable { + case nearestTrackPoint + case stationaryCandidate + case interpolatedTrack + case exactTrackPoint + case manual + + fileprivate var rank: Int { + switch self { + case .nearestTrackPoint: 0 + case .stationaryCandidate: 1 + case .interpolatedTrack: 2 + case .exactTrackPoint: 3 + case .manual: 4 + } + } +} + +public enum MatchProvenanceVerification: String, Codable, Hashable, Sendable { + case automatic + case userConfirmed + case manual + + fileprivate var rank: Int { + switch self { + case .automatic: 0 + case .userConfirmed: 1 + case .manual: 2 + } + } +} + +/// 解释坐标如何产生,并提供可重复的强度比较。它存入事务,不写入未知外部 XMP。 +public struct MatchProvenance: Hashable, Codable, Sendable { + public let source: MatchProvenanceSource + public let verification: MatchProvenanceVerification + public let algorithmVersion: String + public let trackFileSHA256: String? + public let generatedAt: Date + public let sourceTimeLowerBound: Date? + public let sourceTimeUpperBound: Date? + public let temporalDistanceSeconds: Double? + public let horizontalAccuracyMeters: Double? + + public init( + source: MatchProvenanceSource, + verification: MatchProvenanceVerification, + algorithmVersion: String, + trackFileSHA256: String? = nil, + generatedAt: Date = Date(), + sourceTimeLowerBound: Date? = nil, + sourceTimeUpperBound: Date? = nil, + temporalDistanceSeconds: Double? = nil, + horizontalAccuracyMeters: Double? = nil + ) { + self.source = source + self.verification = verification + self.algorithmVersion = algorithmVersion + self.trackFileSHA256 = trackFileSHA256 + self.generatedAt = generatedAt + self.sourceTimeLowerBound = sourceTimeLowerBound + self.sourceTimeUpperBound = sourceTimeUpperBound + self.temporalDistanceSeconds = temporalDistanceSeconds.flatMap { + $0.isFinite ? $0 : nil + } + self.horizontalAccuracyMeters = horizontalAccuracyMeters.flatMap { + $0.isFinite && $0 >= 0 ? $0 : nil + } + } + + public func isProvablyStronger(than other: MatchProvenance) -> Bool { + if verification.rank != other.verification.rank { + return verification.rank > other.verification.rank + } + if source.rank != other.source.rank { + return source.rank > other.source.rank + } + switch (horizontalAccuracyMeters, other.horizontalAccuracyMeters) { + case (let lhs?, let rhs?) where lhs != rhs: + return lhs < rhs + case (.some, .none): + return true + case (.none, .some): + return false + default: + break + } + switch (temporalDistanceSeconds, other.temporalDistanceSeconds) { + case (let lhs?, let rhs?) where abs(lhs) != abs(rhs): + return abs(lhs) < abs(rhs) + case (.some, .none): + return true + default: + return false + } + } +} + +public enum ExistingGPSOrigin: String, Codable, Hashable, Sendable { + case embeddedMedia + case externalSidecar + case rawGeoSyncTransaction } public struct SidecarWriteRequest: Hashable, Codable, Sendable { public let rawFile: ReadOnlyRawFile public let gps: GPSMetadata public let existingGPSPolicy: ExistingGPSPolicy + public let matchProvenance: MatchProvenance? public init( rawFile: ReadOnlyRawFile, gps: GPSMetadata, - existingGPSPolicy: ExistingGPSPolicy = .skip + existingGPSPolicy: ExistingGPSPolicy = .skip, + matchProvenance: MatchProvenance? = nil ) { self.rawFile = rawFile self.gps = gps self.existingGPSPolicy = existingGPSPolicy + self.matchProvenance = matchProvenance } } @@ -89,6 +197,11 @@ public struct SidecarWritePlanItem: Hashable, Codable, Sendable, Identifiable { public let rawPrecondition: FileFingerprint public let sidecarPrecondition: FileFingerprint? public let originalNonGPSSemanticDigest: String? + public let matchProvenance: MatchProvenance? + public let existingGPS: GPSMetadata? + public let existingGPSOrigin: ExistingGPSOrigin? + public let existingMatchProvenance: MatchProvenance? + public let provenanceTransactionID: UUID? public init( id: UUID = UUID(), @@ -98,7 +211,12 @@ public struct SidecarWritePlanItem: Hashable, Codable, Sendable, Identifiable { disposition: SidecarWriteDisposition, rawPrecondition: FileFingerprint, sidecarPrecondition: FileFingerprint?, - originalNonGPSSemanticDigest: String? + originalNonGPSSemanticDigest: String?, + matchProvenance: MatchProvenance? = nil, + existingGPS: GPSMetadata? = nil, + existingGPSOrigin: ExistingGPSOrigin? = nil, + existingMatchProvenance: MatchProvenance? = nil, + provenanceTransactionID: UUID? = nil ) { self.id = id self.rawFile = rawFile @@ -108,6 +226,11 @@ public struct SidecarWritePlanItem: Hashable, Codable, Sendable, Identifiable { self.rawPrecondition = rawPrecondition self.sidecarPrecondition = sidecarPrecondition self.originalNonGPSSemanticDigest = originalNonGPSSemanticDigest + self.matchProvenance = matchProvenance + self.existingGPS = existingGPS + self.existingGPSOrigin = existingGPSOrigin + self.existingMatchProvenance = existingMatchProvenance + self.provenanceTransactionID = provenanceTransactionID } } @@ -158,6 +281,9 @@ public struct TransactionFileRecord: Hashable, Codable, Sendable, Identifiable { } public struct XMPTransactionManifest: Hashable, Codable, Sendable, Identifiable { + public static let currentSchemaVersion = 2 + + public let schemaVersion: Int public let id: UUID public let createdAt: Date public var updatedAt: Date @@ -165,12 +291,37 @@ public struct XMPTransactionManifest: Hashable, Codable, Sendable, Identifiable public var records: [TransactionFileRecord] public init(plan: SidecarWritePlan, now: Date = Date()) { + self.schemaVersion = Self.currentSchemaVersion self.id = plan.id self.createdAt = plan.createdAt self.updatedAt = now self.status = .applying self.records = plan.items.map(TransactionFileRecord.init) } + + private enum CodingKeys: String, CodingKey { + case schemaVersion, id, createdAt, updatedAt, status, records + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1 + self.id = try container.decode(UUID.self, forKey: .id) + self.createdAt = try container.decode(Date.self, forKey: .createdAt) + self.updatedAt = try container.decode(Date.self, forKey: .updatedAt) + self.status = try container.decode(TransactionStatus.self, forKey: .status) + self.records = try container.decode([TransactionFileRecord].self, forKey: .records) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(Self.currentSchemaVersion, forKey: .schemaVersion) + try container.encode(id, forKey: .id) + try container.encode(createdAt, forKey: .createdAt) + try container.encode(updatedAt, forKey: .updatedAt) + try container.encode(status, forKey: .status) + try container.encode(records, forKey: .records) + } } public struct TransactionApplyReport: Hashable, Codable, Sendable { @@ -205,6 +356,11 @@ public struct BackupCleanupReport: Hashable, Codable, Sendable { } } +private struct ProvenanceProof: Sendable { + let transactionID: UUID + let provenance: MatchProvenance +} + public actor XMPTransactionCoordinator { private let metadataTool: any MetadataTooling private let backupRoot: URL @@ -224,6 +380,7 @@ public actor XMPTransactionCoordinator { public func makeWritePlan(_ requests: [SidecarWriteRequest]) async throws -> SidecarWritePlan { let rawMetadata = try await metadataTool.readRawMetadata(requests.map(\.rawFile)) let metadataByURL = Dictionary(uniqueKeysWithValues: rawMetadata.map { ($0.rawFile.url, $0) }) + let priorManifests = provenanceManifests() var items: [SidecarWritePlanItem] = [] items.reserveCapacity(requests.count) @@ -252,6 +409,21 @@ public actor XMPTransactionCoordinator { } let rawGPS = metadataByURL[request.rawFile.url]?.gps let sidecarGPS = sidecarMetadata?.gps + let existingGPS = sidecarGPS ?? rawGPS + let proof = provenanceProof( + rawFile: request.rawFile, + sidecar: resolvedSidecar, + rawFingerprint: rawFingerprint, + sidecarFingerprint: sidecarFingerprint, + existingGPS: sidecarGPS, + manifests: priorManifests + ) + let existingOrigin: ExistingGPSOrigin? = { + if sidecarGPS != nil { + return proof == nil ? .externalSidecar : .rawGeoSyncTransaction + } + return rawGPS == nil ? nil : .embeddedMedia + }() let disposition: SidecarWriteDisposition if let sidecarReadFailure { disposition = .conflict("无法安全读取现有 XMP:\(sidecarReadFailure)") @@ -263,7 +435,10 @@ public actor XMPTransactionCoordinator { rawGPSIsPartial: metadataByURL[request.rawFile.url]?.gpsIsPartial == true, sidecarGPSIsPartial: sidecarMetadata?.gpsIsPartial == true, sidecarExists: sidecarExists, - policy: request.existingGPSPolicy + policy: request.existingGPSPolicy, + desiredProvenance: request.matchProvenance, + existingProvenance: proof?.provenance, + existingOrigin: existingOrigin ) } @@ -275,7 +450,12 @@ public actor XMPTransactionCoordinator { disposition: disposition, rawPrecondition: rawFingerprint, sidecarPrecondition: sidecarFingerprint, - originalNonGPSSemanticDigest: sidecarMetadata?.nonGPSSemanticDigest + originalNonGPSSemanticDigest: sidecarMetadata?.nonGPSSemanticDigest, + matchProvenance: request.matchProvenance, + existingGPS: existingGPS, + existingGPSOrigin: existingOrigin, + existingMatchProvenance: proof?.provenance, + provenanceTransactionID: proof?.transactionID )) } return SidecarWritePlan(items: items) @@ -437,7 +617,10 @@ public actor XMPTransactionCoordinator { rawGPSIsPartial: Bool, sidecarGPSIsPartial: Bool, sidecarExists: Bool, - policy: ExistingGPSPolicy + policy: ExistingGPSPolicy, + desiredProvenance: MatchProvenance?, + existingProvenance: MatchProvenance?, + existingOrigin: ExistingGPSOrigin? ) -> SidecarWriteDisposition { if rawGPSIsPartial || sidecarGPSIsPartial { return .conflict("文件中存在不完整的 GPS 元数据") @@ -447,27 +630,66 @@ public actor XMPTransactionCoordinator { } if let existing = sidecarGPS ?? rawGPS { if existing.isEquivalent(to: desired) { return .alreadyApplied } - if policy == .skip { return .conflict("文件中已有不同 GPS") } + switch policy { + case .skip: + return .conflict("文件中已有不同 GPS") + case .replace: + break + case .replaceIfStrongerProvenance: + guard existingOrigin == .rawGeoSyncTransaction, + let desiredProvenance, + let existingProvenance, + desiredProvenance.isProvablyStronger(than: existingProvenance) + else { + return .conflict("已有 GPS 无法证明来自较弱的 RawGeoSync 匹配;未知外部 XMP 受保护") + } + } } return sidecarExists ? .update : .create } - private func resolveSidecar(for rawFile: ReadOnlyRawFile) throws -> SidecarURL { - let expected = try SidecarURL(for: rawFile) - let directory = rawFile.url.deletingLastPathComponent() - let baseName = rawFile.url.deletingPathExtension().lastPathComponent - let entries = try fileManager.contentsOfDirectory( - at: directory, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles] - ).filter { - $0.deletingPathExtension().lastPathComponent == baseName - && $0.pathExtension.caseInsensitiveCompare("xmp") == .orderedSame + private func provenanceManifests() -> [XMPTransactionManifest] { + guard let ids = try? transactionIDs() else { return [] } + let terminal: Set = [.completed, .completedWithFailures, .cancelled] + return ids.compactMap { id in + guard let manifest = try? self.manifest(transactionID: id), + terminal.contains(manifest.status) + else { return nil } + return manifest } - if entries.count > 1 { - throw MetadataInfrastructureError.sidecarConflict(expected.url, "同时存在多个大小写不同的 XMP") + .sorted { $0.updatedAt > $1.updatedAt } + } + + private func provenanceProof( + rawFile: ReadOnlyRawFile, + sidecar: SidecarURL, + rawFingerprint: FileFingerprint, + sidecarFingerprint: FileFingerprint?, + existingGPS: GPSMetadata?, + manifests: [XMPTransactionManifest] + ) -> ProvenanceProof? { + guard let rawDigest = rawFingerprint.sha256, + let sidecarDigest = sidecarFingerprint?.sha256, + let existingGPS + else { return nil } + for manifest in manifests { + for record in manifest.records where record.status == .applied { + let item = record.planItem + guard item.rawFile.url == rawFile.url, + item.sidecar.url == sidecar.url, + item.rawPrecondition.sha256 == rawDigest, + record.postWriteFingerprint?.sha256 == sidecarDigest, + item.desiredGPS.isEquivalent(to: existingGPS), + let provenance = item.matchProvenance + else { continue } + return ProvenanceProof(transactionID: manifest.id, provenance: provenance) + } } - return try entries.first.map(SidecarURL.init(validatedURL:)) ?? expected + return nil + } + + private func resolveSidecar(for rawFile: ReadOnlyRawFile) throws -> SidecarURL { + try SidecarURL.existing(for: rawFile, fileManager: fileManager) ?? SidecarURL(for: rawFile) } private func applyRecord( diff --git a/MetadataInfrastructure/Tests/MetadataInfrastructureTests/ExifToolClientTests.swift b/MetadataInfrastructure/Tests/MetadataInfrastructureTests/ExifToolClientTests.swift index 7715ec3..49ac607 100644 --- a/MetadataInfrastructure/Tests/MetadataInfrastructureTests/ExifToolClientTests.swift +++ b/MetadataInfrastructure/Tests/MetadataInfrastructureTests/ExifToolClientTests.swift @@ -47,6 +47,15 @@ struct ExifToolClientTests { #expect(invocation.arguments == ["/bundle/exiftool", "-ver"]) } + @Test("bundled configuration allows large-library metadata batches") + func bundledConfigurationTimeout() throws { + let configuration = try ExifToolConfiguration.bundledPerl( + scriptURL: URL(fileURLWithPath: "/bundle/exiftool") + ) + + #expect(configuration.timeout == .seconds(120)) + } + @Test("parses numeric and string subseconds and restores input order") func parsesMixedJSONAndRestoresOrder() async throws { let directory = try makeTemporaryDirectory() @@ -73,9 +82,22 @@ struct ExifToolClientTests { "EXIF:SubSecTimeOriginal": "080", "EXIF:OffsetTimeOriginal": "+08:00", "EXIF:GPSLatitude": 22.5, - "EXIF:GPSLongitude": 113.7, - "EXIF:GPSAltitude": 10, - "EXIF:GPSAltitudeRef": 1 + "EXIF:GPSLongitude": 113.7, + "EXIF:GPSAltitude": 10, + "EXIF:GPSAltitudeRef": 1, + "IFD0:Make": "NIKON CORPORATION", + "IFD0:Model": "NIKON Z 50", + "Nikon:SerialNumber": "00001234", + "Nikon:InternalSerialNumber": 9876, + "Nikon:ShutterCount": 4321, + "File:FileSize": 20971520, + "Composite:GPSDateTime": "2026:08:08 06:14:00Z", + "EXIF:GPSHPositioningError": 7.5, + "XMP-xmpMM:DocumentID": "xmp.did:document", + "XMP-xmpMM:OriginalDocumentID": "xmp.did:original", + "XMP-xmpMM:DerivedFrom": {"DocumentID":"xmp.did:source"}, + "IFD0:Software": "Camera Firmware 1.0", + "XMP-x:XMPToolkit": "Image::ExifTool 13.59" } ] """ @@ -100,6 +122,19 @@ struct ExifToolClientTests { #expect(metadata[0].subsecondTimeOriginal == "080") #expect(metadata[1].subsecondTimeOriginal == "66") #expect(metadata[0].gps?.altitude == -10) + #expect(metadata[0].make == "NIKON CORPORATION") + #expect(metadata[0].model == "NIKON Z 50") + #expect(metadata[0].serialNumber == "00001234") + #expect(metadata[0].internalSerialNumber == "9876") + #expect(metadata[0].shutterCount == 4321) + #expect(metadata[0].fileSize == 20_971_520) + #expect(metadata[0].gpsDateTime == "2026:08:08 06:14:00Z") + #expect(metadata[0].gpsHorizontalPositioningError == 7.5) + #expect(metadata[0].documentID == "xmp.did:document") + #expect(metadata[0].originalDocumentID == "xmp.did:original") + #expect(metadata[0].derivedFrom == #"{"DocumentID":"xmp.did:source"}"#) + #expect(metadata[0].software == "Camera Firmware 1.0") + #expect(metadata[0].xmpToolkit == "Image::ExifTool 13.59") #expect(metadata[1].gps == nil) #expect(metadata[1].gpsIsPartial) @@ -108,6 +143,8 @@ struct ExifToolClientTests { #expect(invocation.arguments.contains(secondURL.path)) #expect(!invocation.arguments.contains("/bin/sh")) #expect(invocation.arguments.contains("-EXIF:GPSLatitude#")) + #expect(invocation.arguments.contains("-ShutterCount#")) + #expect(invocation.arguments.contains("-DocumentID")) #expect(!invocation.arguments.contains("-n")) } @@ -134,6 +171,42 @@ struct ExifToolClientTests { } } + @Test("scans DNG JPEG and TIFF without granting sidecar write eligibility") + func scansReadOnlyMediaFormats() async throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let urls = ["photo.DNG", "photo.JPG", "photo.TIFF"].map(directory.appendingPathComponent) + for url in urls { try Data("scan-only".utf8).write(to: url) } + let media = try urls.map(ReadOnlyMediaFile.init(url:)) + let jsonObjects: [[String: Any]] = urls.enumerated().map { index, url in + [ + "SourceFile": url.path, + "IFD0:Make": "Camera \(index)", + "File:FileSize": 9, + ] + } + let runner = RecordingRunner(results: [ + .success( + ExecutableResult( + terminationStatus: 0, + standardOutput: try JSONSerialization.data(withJSONObject: jsonObjects), + standardError: Data() + )) + ]) + let client = ExifToolClient( + runner: runner, + configuration: ExifToolConfiguration( + executableURL: URL(fileURLWithPath: "/fake/exiftool"), + minimumVersion: try ExifToolVersion("13.59") + ) + ) + + let result = try await client.readMediaMetadata(media) + #expect(result.map(\.mediaFile.kind) == [.dng, .jpeg, .tiff]) + #expect(result.map(\.make) == ["Camera 0", "Camera 1", "Camera 2"]) + #expect(result.allSatisfy { $0.fileSize == 9 }) + } + @Test("real process runner terminates on timeout") func processTimeout() async { let runner = ProcessExecutableRunner() diff --git a/MetadataInfrastructure/Tests/MetadataInfrastructureTests/RealExifToolContractTests.swift b/MetadataInfrastructure/Tests/MetadataInfrastructureTests/RealExifToolContractTests.swift index 988cdd7..e5c9fe9 100644 --- a/MetadataInfrastructure/Tests/MetadataInfrastructureTests/RealExifToolContractTests.swift +++ b/MetadataInfrastructure/Tests/MetadataInfrastructureTests/RealExifToolContractTests.swift @@ -23,6 +23,12 @@ struct RealExifToolContractTests { #expect(metadata.subsecondTimeOriginal?.isEmpty == false) #expect(metadata.offsetTimeOriginal == "+08:00") #expect(metadata.gps == nil) + #expect(metadata.make == "NIKON CORPORATION") + #expect(metadata.model == "NIKON Z 50") + #expect(metadata.serialNumber?.isEmpty == false) + #expect(metadata.shutterCount == 46_416) + #expect(metadata.fileSize == 31_585_218) + #expect(metadata.software == "Ver.02.50") } @Test("round-trips GPS and preserves unrelated XMP") @@ -43,12 +49,18 @@ struct RealExifToolContractTests { #expect(try await client.checkVersion() >= ExifToolVersion("13.59")) let before = try await client.readSidecarMetadata(at: sidecar) + #expect(before.documentID == "xmp.did:test-document") + #expect(before.originalDocumentID == "xmp.did:test-original") + #expect(before.software == "Lightroom Classic") let gps = try GPSMetadata(latitude: -22.987_654, longitude: 113.123_456, altitude: -10.5) try await client.writeGPS(gps, to: sidecar) let after = try await client.readSidecarMetadata(at: sidecar) #expect(after.gps?.isEquivalent(to: gps) == true) #expect(after.nonGPSSemanticDigest == before.nonGPSSemanticDigest) + #expect(after.documentID == before.documentID) + #expect(after.originalDocumentID == before.originalDocumentID) + #expect(after.xmpToolkit?.contains("ExifTool 13.59") == true) let text = try String(contentsOf: xmpURL, encoding: .utf8) #expect(text.contains("Exposure2012")) #expect(text.contains("0.50")) @@ -60,7 +72,12 @@ struct RealExifToolContractTests { + xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/" + xmlns:tiff="http://ns.adobe.com/tiff/1.0/" + crs:Exposure2012="0.50" + xmpMM:DocumentID="xmp.did:test-document" + xmpMM:OriginalDocumentID="xmp.did:test-original" + tiff:Software="Lightroom Classic"/> diff --git a/MetadataInfrastructure/Tests/MetadataInfrastructureTests/V2ContractTests.swift b/MetadataInfrastructure/Tests/MetadataInfrastructureTests/V2ContractTests.swift new file mode 100644 index 0000000..67ef32e --- /dev/null +++ b/MetadataInfrastructure/Tests/MetadataInfrastructureTests/V2ContractTests.swift @@ -0,0 +1,142 @@ +import Foundation +import Testing + +@testable import MetadataInfrastructure + +@Suite("Metadata and transaction v2 contract") +struct V2ContractTests { + @Test("DNG JPEG and TIFF are scan-only and cannot become proprietary RAW targets") + func scanOnlyMediaBoundary() throws { + let directory = try v2TemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let specifications: [(String, ReadOnlyMediaKind)] = [ + ("sample.NEF", .proprietaryRaw), + ("sample.DNG", .dng), + ("sample.JPG", .jpeg), + ("sample.TIFF", .tiff), + ] + for (name, expectedKind) in specifications { + let url = directory.appendingPathComponent(name) + try Data("fixture".utf8).write(to: url) + let media = try ReadOnlyMediaFile(url: url) + #expect(media.kind == expectedKind) + if expectedKind == .proprietaryRaw { + let raw = try ReadOnlyRawFile(mediaFile: media) + #expect(try SidecarURL(for: raw).url.pathExtension == "xmp") + } else { + #expect(throws: MetadataInfrastructureError.self) { + try ReadOnlyRawFile(mediaFile: media) + } + } + } + } + + @Test("existing sidecar lookup is case insensitive") + func existingSidecarLookup() throws { + let directory = try v2TemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let rawURL = directory.appendingPathComponent("sample.NEF") + try Data("raw".utf8).write(to: rawURL) + let raw = try ReadOnlyRawFile(url: rawURL) + let uppercase = directory.appendingPathComponent("sample.XMP") + try Data("xmp".utf8).write(to: uppercase) + + #expect(try SidecarURL.existing(for: raw)?.url == uppercase) + } + + @Test("provenance strength is deterministic and strictly ordered") + func provenanceStrength() { + let automaticExact = provenance(source: .exactTrackPoint, verification: .automatic) + let confirmedNearest = provenance(source: .nearestTrackPoint, verification: .userConfirmed) + let manual = provenance(source: .manual, verification: .manual) + let accurate = provenance( + source: .interpolatedTrack, + verification: .automatic, + horizontalAccuracy: 5 + ) + let inaccurate = provenance( + source: .interpolatedTrack, + verification: .automatic, + horizontalAccuracy: 50 + ) + + #expect(confirmedNearest.isProvablyStronger(than: automaticExact)) + #expect(manual.isProvablyStronger(than: confirmedNearest)) + #expect(accurate.isProvablyStronger(than: inaccurate)) + #expect(!automaticExact.isProvablyStronger(than: automaticExact)) + } + + @Test("schema v2 encodes explicitly and a schema-less v1 manifest still decodes") + func manifestSchemaCompatibility() throws { + let directory = try v2TemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let rawURL = directory.appendingPathComponent("schema.NEF") + try Data("raw".utf8).write(to: rawURL) + let raw = try ReadOnlyRawFile(url: rawURL) + let item = SidecarWritePlanItem( + rawFile: raw, + sidecar: try SidecarURL(for: raw), + desiredGPS: try GPSMetadata(latitude: 22, longitude: 113), + disposition: .create, + rawPrecondition: FileFingerprint(byteCount: 3, modificationTime: .distantPast, sha256: "raw"), + sidecarPrecondition: nil, + originalNonGPSSemanticDigest: nil, + matchProvenance: provenance(source: .exactTrackPoint, verification: .automatic) + ) + let plan = SidecarWritePlan(items: [item]) + let manifest = XMPTransactionManifest(plan: plan) + let encoder = JSONEncoder() + let v2Data = try encoder.encode(manifest) + let v2Object = try #require( + try JSONSerialization.jsonObject(with: v2Data) as? [String: Any] + ) + #expect(v2Object["schemaVersion"] as? Int == 2) + + var v1Object = v2Object + v1Object.removeValue(forKey: "schemaVersion") + if var records = v1Object["records"] as? [[String: Any]], + var record = records.first, + var planItem = record["planItem"] as? [String: Any] + { + for key in [ + "matchProvenance", "existingGPS", "existingGPSOrigin", "existingMatchProvenance", + "provenanceTransactionID", + ] { + planItem.removeValue(forKey: key) + } + record["planItem"] = planItem + records[0] = record + v1Object["records"] = records + } + let v1Data = try JSONSerialization.data(withJSONObject: v1Object) + let decoded = try JSONDecoder().decode(XMPTransactionManifest.self, from: v1Data) + #expect(decoded.schemaVersion == 1) + #expect(decoded.records.first?.planItem.matchProvenance == nil) + + let reencoded = + try JSONSerialization.jsonObject(with: encoder.encode(decoded)) as? [String: Any] + #expect(reencoded?["schemaVersion"] as? Int == 2) + } +} + +private func provenance( + source: MatchProvenanceSource, + verification: MatchProvenanceVerification, + horizontalAccuracy: Double? = nil +) -> MatchProvenance { + MatchProvenance( + source: source, + verification: verification, + algorithmVersion: "test-v2", + trackFileSHA256: String(repeating: "a", count: 64), + generatedAt: Date(timeIntervalSince1970: 1_800_000_000), + horizontalAccuracyMeters: horizontalAccuracy + ) +} + +private func v2TemporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("MetadataV2Tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url +} diff --git a/MetadataInfrastructure/Tests/MetadataInfrastructureTests/XMPTransactionTests.swift b/MetadataInfrastructure/Tests/MetadataInfrastructureTests/XMPTransactionTests.swift index 98649b8..04dca1a 100644 --- a/MetadataInfrastructure/Tests/MetadataInfrastructureTests/XMPTransactionTests.swift +++ b/MetadataInfrastructure/Tests/MetadataInfrastructureTests/XMPTransactionTests.swift @@ -275,6 +275,82 @@ struct XMPTransactionTests { #expect(try Data(contentsOf: sidecar.url) == Data("Lightroom changed this".utf8)) } + @Test("only a proven stronger RawGeoSync provenance can auto-replace existing GPS") + func strongerProvenanceReplacement() async throws { + let context = try FixtureContext(rawNames: ["DSC_0013.NEF"]) + defer { context.remove() } + let raw = context.rawFiles[0] + let sidecar = try SidecarURL(for: raw) + let tool = FakeMetadataTool() + let coordinator = XMPTransactionCoordinator(metadataTool: tool, backupRoot: context.backupRoot) + let weak = MatchProvenance( + source: .nearestTrackPoint, + verification: .automatic, + algorithmVersion: "matcher-v2", + trackFileSHA256: String(repeating: "1", count: 64) + ) + let strong = MatchProvenance( + source: .exactTrackPoint, + verification: .userConfirmed, + algorithmVersion: "matcher-v2", + trackFileSHA256: String(repeating: "2", count: 64) + ) + let firstGPS = try GPSMetadata(latitude: 22, longitude: 113) + let secondGPS = try GPSMetadata(latitude: 22.1, longitude: 113.1) + let firstPlan = try await coordinator.makeWritePlan([ + SidecarWriteRequest(rawFile: raw, gps: firstGPS, matchProvenance: weak) + ]) + let firstReport = try await coordinator.apply(firstPlan) + #expect(firstReport.appliedCount == 1) + + let notStronger = try await coordinator.makeWritePlan([ + SidecarWriteRequest( + rawFile: raw, + gps: secondGPS, + existingGPSPolicy: .replaceIfStrongerProvenance, + matchProvenance: weak + ) + ]) + guard case .conflict = notStronger.items[0].disposition else { + Issue.record("同强度来源不应自动替换") + return + } + + let stronger = try await coordinator.makeWritePlan([ + SidecarWriteRequest( + rawFile: raw, + gps: secondGPS, + existingGPSPolicy: .replaceIfStrongerProvenance, + matchProvenance: strong + ) + ]) + #expect(stronger.items[0].disposition == .update) + #expect(stronger.items[0].existingGPSOrigin == .rawGeoSyncTransaction) + #expect(stronger.items[0].existingMatchProvenance == weak) + #expect(stronger.items[0].provenanceTransactionID == firstReport.transactionID) + _ = try await coordinator.apply(stronger) + + let external = FixtureSidecar(gps: secondGPS, semantic: "external-xmp-edit") + try JSONEncoder().encode(external).write(to: sidecar.url) + let unknownExternal = try await coordinator.makeWritePlan([ + SidecarWriteRequest( + rawFile: raw, + gps: try GPSMetadata(latitude: 22.2, longitude: 113.2), + existingGPSPolicy: .replaceIfStrongerProvenance, + matchProvenance: MatchProvenance( + source: .manual, + verification: .manual, + algorithmVersion: "matcher-v2" + ) + ) + ]) + #expect(unknownExternal.items[0].existingGPSOrigin == .externalSidecar) + guard case .conflict = unknownExternal.items[0].disposition else { + Issue.record("未知外部 XMP 即使新来源更强也必须受保护") + return + } + } + @Test("cleanup retains only the latest ten batches younger than thirty days") func backupRetention() async throws { let context = try FixtureContext(rawNames: []) diff --git a/README.md b/README.md index 3a7f104..6dcb99f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ RawGeoSync 是一款离线 macOS 应用。它把相机照片的拍摄时间与 GPX 轨迹进行匹配,在用户预览和确认后,将 GPS 写入 Lightroom 可读取的 XMP sidecar。应用永远不修改相机 RAW 文件。 -## 首版能力 +## 核心能力 - 读取“一生足迹”导出的 GPX 1.0/1.1 轨迹。 - 读取 Nikon Z50 NEF 的原始拍摄时间,并对常见 RAW、DNG、JPEG、TIFF 提供实验性扫描。 @@ -12,6 +12,8 @@ RawGeoSync 是一款离线 macOS 应用。它把相机照片的拍摄时间与 G - 原子创建或合并同名 XMP,保留 Lightroom 已有编辑。 - 支持幂等写入、冲突检测、事务记录和安全撤销。 +v0.2 的设计在此基础上引入可追溯的多来源证据链:照片自带 GPS、GPX、相机定位、同一拍摄 burst、照片序列、跨相机锚点和活动区都可以提供候选。弱候选只补足缺失,不能覆盖强候选;冲突、传播跳数和用户确认会保留在本地事务记录中。详细规则见 [ADR 0002](Docs/Decisions/0002-matching-v2.md)。 + ## 数据安全原则 - RAW 只读,写入接口只接受 XMP sidecar 目标。 @@ -36,19 +38,29 @@ sudo xcode-select -s /Applications/Xcode.app/Contents/Developer 开发命令: ```sh +./Scripts/repository-policy-check.sh ./Scripts/test.sh ./Scripts/build.sh ``` -使用当前真实样本做只读验收: +运行与 CI 等价的 Debug/Release、静态分析和仓库策略门禁: + +```sh +./Scripts/ci.sh +``` + +使用未提交的真实数据做只读全量回归: ```sh -RAWGEOSYNC_GPX_PATH='/Users/simplechen/Downloads/2026.01.01-2026.12.31.gpx' \ -RAWGEOSYNC_PHOTO_DIR='/Users/simplechen/Picture/2026-8-8 我们四在东莞/Z50' \ -./Scripts/real-sample-smoke.sh +./Scripts/real-data-regression.sh \ + --gpx-dir "$GPX_DIR" \ + --photo-dir "$PHOTO_DIR" \ + --cli "$REGRESSION_CLI" ``` -最终 Release 应用安装到 `~/Applications/RawGeoSync.app`,Debug 构建位于 `.local/Debug`。Xcode DerivedData 使用系统临时目录并在命令结束后清理,避免在项目内留下几十 GB 的稀疏编译缓存。真实样本临时副本仍只能放在被 Git 忽略的 `.local/tmp`,不得写入用户原始照片目录。 +回归接口接收 GPX 目录和照片目录,使用 macOS sandbox 拒绝源目录写入,并在运行前后比较逐文件 SHA-256 与基础元数据;报告默认只写入被 Git 忽略的 `.local/real-regression/`。找不到 v2 CLI 或 CLI 明确返回“能力不可用”时脚本会输出 `SKIP`,其他能力探测错误会失败;发布验收加 `--require-capability`,不得把跳过当作通过。详细契约和性能门槛见 [测试规范](Docs/TESTING.md)。 + +最终 Release 应用安装到 `~/Applications/RawGeoSync.app`,Debug 构建位于 `.local/Debug`。Xcode DerivedData 使用系统临时目录并在命令结束后清理,避免在项目内留下几十 GB 的稀疏编译缓存。真实样本和本地报告只能放在被 Git 忽略的 `.local/` 或用户自选目录,不得写入或复制回原始照片目录。 ## 使用建议 @@ -56,14 +68,14 @@ RAWGEOSYNC_PHOTO_DIR='/Users/simplechen/Picture/2026-8-8 我们四在东莞/Z50' 分析完成后,只有“可靠”结果默认勾选写入;停留候选、最近点和其他待确认结果必须按区间复核并主动勾选。写入前应用会展示创建、更新、已应用与冲突数量。撤销仅在 sidecar 未被 Lightroom 等程序继续修改时执行,避免抹掉后续编辑。 -“全选照片”直接切换当前“全部 / 可靠 / 待确认 / 未匹配”筛选结果左侧的写入复选框,不是表格行选择。未匹配照片可以预先勾选,但在用户为其指定有效坐标之前仍会安全跳过;已有 GPS 的照片也会被勾选,并在写入预检中明确列为更新或冲突。 +“全选照片”直接切换当前“全部 / 可靠 / 待确认 / 粗略 / 未匹配”筛选结果左侧的写入复选框,不是表格行选择。未匹配照片可以预先勾选,但在用户为其指定有效坐标之前仍会安全跳过;已有 GPS 的照片也会被勾选,并在写入预检中明确列为更新或冲突。 本机 Release 应用位于 `~/Applications/RawGeoSync.app`。将应用安装到不受桌面 iCloud FileProvider 管理的用户应用程序目录,可以避免隔离属性被云端元数据反复恢复;构建脚本还会生成仅供本机运行的临时签名。若未来面向他人分发,仍需另行配置 Developer ID、Hardened Runtime 和 Apple 公证。可通过 `RAWGEOSYNC_INSTALL_DIR` 自定义安装目录。 日常启动可以在 Finder 中双击该 `.app`,或在终端执行: ```sh -open '/Users/simplechen/Applications/RawGeoSync.app' +open "$HOME/Applications/RawGeoSync.app" ``` 如果源码发生变化,在项目目录执行 `./Scripts/build-release.sh` 即会重新构建并更新用户“应用程序”目录中的版本,之后可从 Finder、Spotlight 或启动台打开。 @@ -79,5 +91,7 @@ RawGeoSync 使用 MIT License。内置 ExifTool 及其 Perl 库遵循上游各 - [贡献指南](CONTRIBUTING.md) - [安全策略](SECURITY.md) - [隐私说明](Docs/PRIVACY.md) +- [测试与验收](Docs/TESTING.md) - [发布清单](Docs/RELEASE.md) +- [匹配 v2 决策](Docs/Decisions/0002-matching-v2.md) - [更新日志](CHANGELOG.md) diff --git a/RawGeoCore/Sources/RawGeoCore/ClockSuggestionEngine.swift b/RawGeoCore/Sources/RawGeoCore/ClockSuggestionEngine.swift new file mode 100644 index 0000000..c78ab5d --- /dev/null +++ b/RawGeoCore/Sources/RawGeoCore/ClockSuggestionEngine.swift @@ -0,0 +1,82 @@ +import Foundation + +/// Produces advisory clock corrections only. Callers must explicitly accept a +/// suggestion before applying it to capture-time normalization. +public struct ClockSuggestionEngine: Sendable { + public let minimumEvidenceCount: Int + public let maximumReferenceAccuracySeconds: TimeInterval + + public init( + minimumEvidenceCount: Int = 3, + maximumReferenceAccuracySeconds: TimeInterval = 60 + ) { + self.minimumEvidenceCount = minimumEvidenceCount + self.maximumReferenceAccuracySeconds = maximumReferenceAccuracySeconds + } + + public func suggest(from observations: [ClockReferenceObservation]) -> [ClockSuggestion] { + let eligible = observations.filter { + ($0.referenceAccuracySeconds ?? 0) <= maximumReferenceAccuracySeconds + } + let grouped = Dictionary(grouping: eligible, by: \.cameraID) + return grouped.keys.sorted(by: { $0.rawValue < $1.rawValue }).compactMap { cameraID in + suggestion(cameraID: cameraID, observations: grouped[cameraID, default: []]) + } + } + + private func suggestion( + cameraID: CameraID, + observations: [ClockReferenceObservation] + ) -> ClockSuggestion? { + guard observations.count >= minimumEvidenceCount else { return nil } + let samples = observations.map { observation in + Sample( + observation: observation, + delta: observation.cameraCaptureTimeUTC.timeIntervalSince(observation.referenceTimeUTC) + ) + } + let initialMedian = median(samples.map(\.delta)) + let initialMAD = median(samples.map { abs($0.delta - initialMedian) }) + let robustSigma = initialMAD * 1.4826 + let rejectionThreshold = max(30, robustSigma * 3) + let accepted = samples.filter { abs($0.delta - initialMedian) <= rejectionThreshold } + guard accepted.count >= minimumEvidenceCount else { return nil } + + let finalMedian = median(accepted.map(\.delta)) + let residual = median(accepted.map { abs($0.delta - finalMedian) }) + let confidence: ClockSuggestionConfidence + if accepted.count >= 10, residual <= 2 { + confidence = .high + } else if residual <= 10 { + confidence = .medium + } else { + confidence = .low + } + + return ClockSuggestion( + cameraID: cameraID, + cameraAheadBySeconds: finalMedian, + confidence: confidence, + evidenceCount: accepted.count, + rejectedOutlierCount: samples.count - accepted.count, + medianAbsoluteResidualSeconds: residual, + method: .robustMedian, + evidenceIDs: accepted.map(\.observation.id).sorted() + ) + } + + private func median(_ values: [Double]) -> Double { + let sorted = values.sorted() + guard !sorted.isEmpty else { return 0 } + let middle = sorted.count / 2 + if sorted.count.isMultiple(of: 2) { + return (sorted[middle - 1] + sorted[middle]) / 2 + } + return sorted[middle] + } + + private struct Sample: Sendable { + let observation: ClockReferenceObservation + let delta: TimeInterval + } +} diff --git a/RawGeoCore/Sources/RawGeoCore/LocationInference.swift b/RawGeoCore/Sources/RawGeoCore/LocationInference.swift new file mode 100644 index 0000000..0c09576 --- /dev/null +++ b/RawGeoCore/Sources/RawGeoCore/LocationInference.swift @@ -0,0 +1,986 @@ +import Foundation + +/// Additive v2 inference pipeline. It never mutates assets and always returns +/// candidates and provenance in deterministic order. +public struct DeterministicLocationEngine: Sendable { + public let policy: LocationRulePolicy + public let matcherConfiguration: GeoMatcherConfiguration + + public init( + policy: LocationRulePolicy = .v2, + matcherConfiguration: GeoMatcherConfiguration = .default + ) { + self.policy = policy + self.matcherConfiguration = matcherConfiguration + } + + public func resolve(_ input: LocationInferenceInput) -> [LocationResolution] { + let assets = input.assets.sorted(by: assetOrder) + let assetByID = Dictionary(uniqueKeysWithValues: assets.map { ($0.id, $0) }) + let observationsByAsset = Dictionary(grouping: input.observations, by: \.assetID) + let regionsByActivity = Dictionary(grouping: input.activityRegions, by: \.activityID) + var candidatesByAsset: [CaptureAssetID: [LocationCandidate]] = [:] + + for asset in assets { + candidatesByAsset[asset.id, default: []].append( + contentsOf: observationCandidates( + for: asset, + observations: observationsByAsset[asset.id, default: []] + ) + ) + } + addRelatedAssetCandidates( + relations: input.assetRelations, + observations: input.observations, + assets: assetByID, + candidatesByAsset: &candidatesByAsset + ) + + for asset in assets { + candidatesByAsset[asset.id, default: []].append( + contentsOf: trajectoryCandidates(for: asset, corpus: input.trajectoryCorpus) + ) + } + + let primaryAnchors = makePrimaryAnchors( + assets: assets, + candidatesByAsset: candidatesByAsset + ) + let anchorIndex = makeAnchorIndex(primaryAnchors) + for asset in assets { + let activityAnchors = asset.activityID.flatMap { anchorIndex.byActivity[$0] } ?? [] + let streamAnchors = anchorStreamKey(for: asset).flatMap { anchorIndex.byStream[$0] } ?? [] + candidatesByAsset[asset.id, default: []].append( + contentsOf: propagationCandidates( + for: asset, + sameStreamAnchors: streamAnchors, + activityAnchors: activityAnchors + ) + ) + candidatesByAsset[asset.id, default: []].append( + contentsOf: regionCandidates( + for: asset, + regions: asset.activityID.flatMap { regionsByActivity[$0] } ?? [] + ) + ) + } + + return assets.map { asset in + resolve( + asset: asset, + candidates: deduplicated(candidatesByAsset[asset.id, default: []]) + ) + } + } + + // MARK: Direct and related observations + + private func observationCandidates( + for asset: CaptureAsset, + observations: [AssetLocationObservation] + ) -> [LocationCandidate] { + observations.sorted { $0.id < $1.id }.compactMap { observation in + guard observation.coordinate.isValid else { return nil } + let age = fixAge(for: observation, asset: asset) + let sourceKind: LocationSourceKind + let evidenceKind: LocationEvidenceKind + let ruleID: String + let requiresConfirmation: Bool + + switch observation.kind { + case .manual: + sourceKind = .manualOverride + evidenceKind = .manualObservation + ruleID = "v2.manual-override" + requiresConfirmation = false + case .directSensor: + if let age, age > policy.directFixMaximumAgeSeconds { return nil } + sourceKind = .directSensor + evidenceKind = .directObservation + ruleID = "v2.direct-sensor-fresh" + requiresConfirmation = (observation.horizontalAccuracyMeters ?? 0) > 500 + case .cameraEmbedded: + guard let age, age <= policy.embeddedFreshFixMaximumAgeSeconds else { return nil } + sourceKind = .embeddedFreshFix + evidenceKind = .embeddedFix + ruleID = "v2.embedded-fix-fresh" + requiresConfirmation = (observation.horizontalAccuracyMeters ?? 0) > 500 + case .sidecar, .renderedDerivative: + sourceKind = .sameAsset + evidenceKind = .relatedAssetObservation + ruleID = "v2.same-asset-observation" + requiresConfirmation = observation.isCircular + } + + let granularity = spatialGranularity( + horizontalAccuracyMeters: observation.horizontalAccuracyMeters + ) + let confidence: LocationDecisionConfidence + if sourceKind == .manualOverride { + confidence = .manual + } else if requiresConfirmation || granularity == .veryCoarse { + confidence = .low + } else { + confidence = .high + } + let evidence = LocationEvidence( + id: "observation:\(observation.id)", + kind: evidenceKind, + sourceID: observation.id, + assetIDs: [asset.id], + coordinate: observation.coordinate, + observedAtUTC: observation.gpsTimestampUTC ?? observation.observedAtUTC, + fixAgeSeconds: age, + horizontalAccuracyMeters: observation.horizontalAccuracyMeters, + estimatedRadiusMeters: nil, + hopCount: 0, + isCircular: observation.isCircular + ) + return candidate( + asset: asset, + coordinate: observation.coordinate, + elevationMeters: observation.elevationMeters, + sourceKind: sourceKind, + granularity: granularity, + confidence: confidence, + radius: nil, + requiresConfirmation: requiresConfirmation, + ruleID: ruleID, + evidence: [evidence] + ) + } + } + + private func addRelatedAssetCandidates( + relations: [AssetRelation], + observations: [AssetLocationObservation], + assets: [CaptureAssetID: CaptureAsset], + candidatesByAsset: inout [CaptureAssetID: [LocationCandidate]] + ) { + let observationsByAsset = Dictionary(grouping: observations, by: \.assetID) + for relation in relations.sorted(by: { $0.id < $1.id }) { + let pairs: [(source: CaptureAssetID, target: CaptureAssetID)] + if relation.kind == .sameAsset { + pairs = [ + (relation.sourceAssetID, relation.targetAssetID), + (relation.targetAssetID, relation.sourceAssetID), + ] + } else { + pairs = [(relation.sourceAssetID, relation.targetAssetID)] + } + + for pair in pairs { + guard let target = assets[pair.target] else { continue } + for observation in observationsByAsset[pair.source, default: []].sorted(by: { + $0.id < $1.id + }) where observation.coordinate.isValid { + let granularity = spatialGranularity( + horizontalAccuracyMeters: observation.horizontalAccuracyMeters + ) + let evidence = LocationEvidence( + id: "relation:\(relation.id):\(observation.id)", + kind: .relatedAssetObservation, + sourceID: relation.id, + assetIDs: [pair.source, pair.target], + coordinate: observation.coordinate, + observedAtUTC: observation.gpsTimestampUTC ?? observation.observedAtUTC, + horizontalAccuracyMeters: observation.horizontalAccuracyMeters, + estimatedRadiusMeters: nil, + hopCount: 1, + isCircular: observation.isCircular, + note: relation.kind.rawValue + ) + candidatesByAsset[target.id, default: []].append( + candidate( + asset: target, + coordinate: observation.coordinate, + elevationMeters: observation.elevationMeters, + sourceKind: .sameAsset, + granularity: granularity, + confidence: observation.isCircular ? .low : .high, + radius: nil, + requiresConfirmation: observation.isCircular, + ruleID: "v2.same-asset-relation", + evidence: [evidence] + ) + ) + } + } + } + } + + // MARK: Independent trajectory sources + + private func trajectoryCandidates( + for asset: CaptureAsset, + corpus: TrajectoryCorpus + ) -> [LocationCandidate] { + let photo = PhotoCapture(id: asset.id.rawValue, captureTimeUTC: asset.captureTimeUTC) + let matcher = GeoMatcher(configuration: matcherConfiguration) + var result: [LocationCandidate] = [] + + // TrajectoryCorpusBuilder 已经保证 session 顺序稳定。逐张照片再次排序会在 + // 大型年度轨迹上制造大量短命数组,显著放大批量推断的时间和内存开销。 + for session in corpus.sessions { + guard let startTime = session.startTimeUTC, let endTime = session.endTimeUTC else { + continue + } + let tolerance = matcherConfiguration.nearestToleranceSeconds + guard asset.captureTimeUTC >= startTime.addingTimeInterval(-tolerance), + asset.captureTimeUTC <= endTime.addingTimeInterval(tolerance) + else { continue } + guard let legacy = matcher.match(photos: [photo], track: session.normalizedTrack).first, + let coordinate = legacy.coordinate, + let legacyCandidate = legacy.candidates.first + else { + continue + } + + let isEmbedded = session.sourceKind == .embeddedCameraFixes + let sourceKind: LocationSourceKind + let evidenceKind: LocationEvidenceKind + let granularity: LocationGranularity + let confidence: LocationDecisionConfidence + let radius: Double? + let requiresConfirmation: Bool + + switch legacy.mode { + case .exact: + sourceKind = isEmbedded ? .embeddedTrackFix : .gpxExact + evidenceKind = isEmbedded ? .embeddedFix : .trajectoryPoint + granularity = .precise + confidence = .high + radius = nil + requiresConfirmation = false + case .reliableInterpolation: + sourceKind = isEmbedded ? .embeddedTrackFix : .gpxInterpolated + evidenceKind = .trajectoryInterval + granularity = .precise + confidence = .high + radius = nil + requiresConfirmation = false + case .reviewInterpolation: + sourceKind = isEmbedded ? .embeddedTrackFix : .gpxInterpolated + evidenceKind = .trajectoryInterval + granularity = .coarse + confidence = .low + radius = nil + requiresConfirmation = true + case .stayCandidate: + sourceKind = .stationaryBounded + evidenceKind = .stationaryBounds + granularity = .coarse + confidence = .low + radius = GeoMath.distance( + from: legacyCandidate.startPoint.coordinate, + to: legacyCandidate.endPoint?.coordinate ?? legacyCandidate.startPoint.coordinate + ) + requiresConfirmation = true + case .nearest: + sourceKind = isEmbedded ? .embeddedTrackFix : .gpxInterpolated + evidenceKind = isEmbedded ? .embeddedFix : .trajectoryPoint + granularity = .coarse + confidence = .low + radius = nil + requiresConfirmation = true + case .ambiguous, .unmatched: + continue + } + + let evidence = LocationEvidence( + id: "track:\(session.sourceID.rawValue):\(session.id):\(legacyCandidate.reason.rawValue)", + kind: evidenceKind, + sourceID: session.sourceID.rawValue, + assetIDs: [asset.id], + coordinate: coordinate, + observedAtUTC: legacyCandidate.startPoint.timestamp, + horizontalAccuracyMeters: maximumAccuracy(of: legacyCandidate), + estimatedRadiusMeters: radius, + hopCount: 0, + note: legacyCandidate.reason.rawValue + ) + result.append( + candidate( + asset: asset, + coordinate: coordinate, + elevationMeters: legacy.elevationMeters, + sourceKind: sourceKind, + granularity: granularity, + confidence: confidence, + radius: radius, + requiresConfirmation: requiresConfirmation, + sourcePriority: session.sourcePriority, + ruleID: "v2.trajectory-\(legacy.mode.rawValue)", + evidence: [evidence] + ) + ) + } + return result + } + + // MARK: Single-hop propagation + + private struct Anchor: Sendable { + let asset: CaptureAsset + let candidate: LocationCandidate + } + + private struct AnchorStreamKey: Hashable, Sendable { + let activityID: ActivityID + let cameraID: CameraID + } + + private struct AnchorIndex: Sendable { + let byActivity: [ActivityID: [Anchor]] + let byStream: [AnchorStreamKey: [Anchor]] + } + + private func makePrimaryAnchors( + assets: [CaptureAsset], + candidatesByAsset: [CaptureAssetID: [LocationCandidate]] + ) -> [Anchor] { + assets.compactMap { asset in + let candidates = candidatesByAsset[asset.id, default: []] + .filter { + sourceRank($0.sourceKind) <= sourceRank(.embeddedTrackFix) + && !$0.requiresConfirmation + && ($0.confidence == .high || $0.confidence == .manual) + } + .sorted(by: candidateOrder) + guard let selected = candidates.first else { return nil } + return Anchor(asset: asset, candidate: selected) + } + } + + private func propagationCandidates( + for asset: CaptureAsset, + sameStreamAnchors: [Anchor], + activityAnchors: [Anchor] + ) -> [LocationCandidate] { + let streamWindow = max( + max(policy.burstWindowSeconds, policy.sequenceWindowSeconds), + policy.stationaryMaximumSpanSeconds + ) + let nearbyStreamAnchors = anchors( + in: sameStreamAnchors, + around: asset.captureTimeUTC, + windowSeconds: streamWindow + ) + let nearbyActivityAnchors = anchors( + in: activityAnchors, + around: asset.captureTimeUTC, + windowSeconds: policy.crossCameraWindowSeconds + ) + var result: [LocationCandidate] = [] + if let burst = burstCandidate(for: asset, anchors: nearbyStreamAnchors) { + result.append(burst) + } + if let sequence = sequenceCandidate(for: asset, anchors: nearbyStreamAnchors) { + result.append(sequence) + } + if let stationary = stationaryCandidate(for: asset, anchors: nearbyStreamAnchors) { + result.append(stationary) + } + if let crossCamera = crossCameraCandidate(for: asset, anchors: nearbyActivityAnchors) { + result.append(crossCamera) + } + return result + } + + /// AnchorIndex 中的数组按时间升序排列。二分截取规则所需的最大时间窗口, + /// 避免对同一活动里的全部锚点为每张照片重复做字符串和日期比较。 + private func anchors( + in sortedAnchors: [Anchor], + around time: Date, + windowSeconds: TimeInterval + ) -> ArraySlice { + let lowerTime = time.addingTimeInterval(-windowSeconds) + let upperTime = time.addingTimeInterval(windowSeconds) + + var lower = 0 + var upper = sortedAnchors.count + while lower < upper { + let middle = (lower + upper) / 2 + if sortedAnchors[middle].asset.captureTimeUTC < lowerTime { + lower = middle + 1 + } else { + upper = middle + } + } + let lowerBound = lower + + lower = lowerBound + upper = sortedAnchors.count + while lower < upper { + let middle = (lower + upper) / 2 + if sortedAnchors[middle].asset.captureTimeUTC <= upperTime { + lower = middle + 1 + } else { + upper = middle + } + } + return sortedAnchors[lowerBound..) + -> LocationCandidate? + { + guard let cameraID = asset.camera?.id, let activityID = asset.activityID else { return nil } + let eligible = anchors.filter { anchor in + guard anchor.asset.id != asset.id, + anchor.asset.camera?.id == cameraID, + anchor.asset.activityID == activityID, + abs(anchor.asset.captureTimeUTC.timeIntervalSince(asset.captureTimeUTC)) + <= policy.burstWindowSeconds + else { return false } + if let targetSequence = asset.sequenceNumber, + let anchorSequence = anchor.asset.sequenceNumber + { + return abs(targetSequence - anchorSequence) <= policy.burstMaximumSequenceGap + } + return true + } + guard !eligible.isEmpty, + let dispersion = boundedDispersion( + eligible.map(\.candidate.coordinate), + maximumAllowed: policy.burstMaximumAnchorDispersionMeters + ) + else { return nil } + + let ordered = eligible.sorted { anchorOrder($0, $1, targetTime: asset.captureTimeUTC) } + guard let selected = ordered.first else { return nil } + let representativeAnchors = Array(ordered.prefix(3)) + let radius: Double? = dispersion > 0 ? dispersion : nil + return propagatedCandidate( + asset: asset, + selected: selected, + agreeing: representativeAnchors, + sourceKind: .burstPropagation, + evidenceKind: .temporalNeighbor, + granularity: .coarse, + confidence: .medium, + radius: radius, + requiresConfirmation: true, + ruleID: "v2.burst-single-hop" + ) + } + + private func sequenceCandidate(for asset: CaptureAsset, anchors: ArraySlice) + -> LocationCandidate? + { + guard let cameraID = asset.camera?.id, let activityID = asset.activityID else { return nil } + let eligible = anchors.filter { anchor in + guard anchor.asset.id != asset.id, + anchor.asset.camera?.id == cameraID, + anchor.asset.activityID == activityID, + abs(anchor.asset.captureTimeUTC.timeIntervalSince(asset.captureTimeUTC)) + <= policy.sequenceWindowSeconds + else { return false } + if let targetSequence = asset.sequenceNumber, + let anchorSequence = anchor.asset.sequenceNumber + { + return abs(targetSequence - anchorSequence) <= policy.sequenceMaximumGap + } + return true + } + guard !eligible.isEmpty else { return nil } + let before = eligible.filter { $0.asset.captureTimeUTC <= asset.captureTimeUTC } + .max(by: { $0.asset.captureTimeUTC < $1.asset.captureTimeUTC }) + let after = eligible.filter { $0.asset.captureTimeUTC >= asset.captureTimeUTC } + .min(by: { $0.asset.captureTimeUTC < $1.asset.captureTimeUTC }) + + if let before, let after, before.asset.id != after.asset.id { + let distance = GeoMath.distance( + from: before.candidate.coordinate, + to: after.candidate.coordinate + ) + guard distance <= policy.sequenceMaximumAnchorDispersionMeters else { return nil } + let span = after.asset.captureTimeUTC.timeIntervalSince(before.asset.captureTimeUTC) + let fraction = + span > 0 + ? asset.captureTimeUTC.timeIntervalSince(before.asset.captureTimeUTC) / span : 0 + let coordinate = GeoMath.interpolate( + from: before.candidate.coordinate, + to: after.candidate.coordinate, + fraction: fraction + ) + let radius: Double? = distance + return candidate( + asset: asset, + coordinate: coordinate, + sourceKind: .sequencePropagation, + granularity: .coarse, + confidence: .medium, + radius: radius, + requiresConfirmation: true, + ruleID: "v2.sequence-bounded-single-hop", + evidence: neighborEvidence( + asset: asset, anchors: [before, after], kind: .sequenceNeighbor, + radius: radius, note: "bounded" + ) + + inheritedEvidence(from: [before, after]) + ) + } + + guard + let selected = eligible.sorted(by: { + anchorOrder($0, $1, targetTime: asset.captureTimeUTC) + }).first + else { return nil } + return propagatedCandidate( + asset: asset, + selected: selected, + agreeing: [selected], + sourceKind: .sequencePropagation, + evidenceKind: .sequenceNeighbor, + granularity: .coarse, + confidence: .low, + radius: nil, + requiresConfirmation: true, + ruleID: "v2.sequence-one-sided-single-hop" + ) + } + + private func stationaryCandidate(for asset: CaptureAsset, anchors: ArraySlice) + -> LocationCandidate? + { + guard let cameraID = asset.camera?.id, let activityID = asset.activityID else { return nil } + let sameStream = anchors.filter { + $0.asset.id != asset.id + && $0.asset.camera?.id == cameraID + && $0.asset.activityID == activityID + } + guard + let before = sameStream.filter({ $0.asset.captureTimeUTC < asset.captureTimeUTC }) + .max(by: { $0.asset.captureTimeUTC < $1.asset.captureTimeUTC }), + let after = sameStream.filter({ $0.asset.captureTimeUTC > asset.captureTimeUTC }) + .min(by: { $0.asset.captureTimeUTC < $1.asset.captureTimeUTC }) + else { return nil } + + let span = after.asset.captureTimeUTC.timeIntervalSince(before.asset.captureTimeUTC) + let distance = GeoMath.distance( + from: before.candidate.coordinate, + to: after.candidate.coordinate + ) + guard span <= policy.stationaryMaximumSpanSeconds, + distance <= policy.stationaryMaximumAnchorDistanceMeters + else { return nil } + + let fraction = asset.captureTimeUTC.timeIntervalSince(before.asset.captureTimeUTC) / span + let coordinate = GeoMath.interpolate( + from: before.candidate.coordinate, + to: after.candidate.coordinate, + fraction: fraction + ) + let radius: Double? = distance + return candidate( + asset: asset, + coordinate: coordinate, + sourceKind: .stationaryBounded, + granularity: .coarse, + confidence: .medium, + radius: radius, + requiresConfirmation: true, + ruleID: "v2.stationary-bounded-single-hop", + evidence: neighborEvidence( + asset: asset, anchors: [before, after], kind: .stationaryBounds, + radius: radius, note: "bracketing anchors" + ) + + inheritedEvidence(from: [before, after]) + ) + } + + private func crossCameraCandidate(for asset: CaptureAsset, anchors: ArraySlice) + -> LocationCandidate? + { + guard let cameraID = asset.camera?.id, let activityID = asset.activityID else { return nil } + let eligible = anchors.filter { + $0.asset.id != asset.id + && $0.asset.camera?.id != nil + && $0.asset.camera?.id != cameraID + && $0.asset.activityID == activityID + && abs($0.asset.captureTimeUTC.timeIntervalSince(asset.captureTimeUTC)) + <= policy.crossCameraWindowSeconds + } + guard !eligible.isEmpty, + let dispersion = boundedDispersion( + eligible.map(\.candidate.coordinate), + maximumAllowed: policy.crossCameraMaximumAnchorDispersionMeters + ) + else { return nil } + + let hasStrongAnchor = eligible.contains { + sourceRank($0.candidate.sourceKind) <= sourceRank(.gpxInterpolated) + } + let independentCameras = Set(eligible.compactMap { $0.asset.camera?.id }).count + let independentSources = Set(eligible.map { $0.candidate.sourceKind }).count + guard hasStrongAnchor || independentCameras >= 2 || independentSources >= 2 else { return nil } + + let ordered = eligible.sorted { anchorOrder($0, $1, targetTime: asset.captureTimeUTC) } + var seenCameras: Set = [] + let representativeAnchors = ordered.filter { anchor in + guard let cameraID = anchor.asset.camera?.id else { return false } + return seenCameras.insert(cameraID).inserted + } + .prefix(3) + guard let selected = representativeAnchors.first else { return nil } + let radius: Double? = dispersion > 0 ? dispersion : nil + return propagatedCandidate( + asset: asset, + selected: selected, + agreeing: Array(representativeAnchors), + sourceKind: .crossCamera, + evidenceKind: .crossCameraNeighbor, + granularity: .coarse, + confidence: .low, + radius: radius, + requiresConfirmation: true, + ruleID: "v2.cross-camera-single-hop" + ) + } + + private func propagatedCandidate( + asset: CaptureAsset, + selected: Anchor, + agreeing: [Anchor], + sourceKind: LocationSourceKind, + evidenceKind: LocationEvidenceKind, + granularity: LocationGranularity, + confidence: LocationDecisionConfidence, + radius: Double?, + requiresConfirmation: Bool, + ruleID: String + ) -> LocationCandidate { + candidate( + asset: asset, + coordinate: selected.candidate.coordinate, + elevationMeters: selected.candidate.elevationMeters, + sourceKind: sourceKind, + granularity: granularity, + confidence: confidence, + radius: radius, + requiresConfirmation: requiresConfirmation, + ruleID: ruleID, + evidence: neighborEvidence( + asset: asset, anchors: agreeing, kind: evidenceKind, + radius: radius, note: "single-hop" + ) + + inheritedEvidence(from: agreeing) + ) + } + + private func inheritedEvidence(from anchors: [Anchor]) -> [LocationEvidence] { + var seen: Set = [] + return + anchors + .flatMap(\.candidate.evidence) + .sorted { $0.id < $1.id } + .filter { seen.insert($0.id).inserted } + } + + private func neighborEvidence( + asset: CaptureAsset, + anchors: [Anchor], + kind: LocationEvidenceKind, + radius: Double?, + note: String + ) -> [LocationEvidence] { + anchors.sorted(by: { $0.asset.id.rawValue < $1.asset.id.rawValue }).map { anchor in + LocationEvidence( + id: "neighbor:\(anchor.asset.id.rawValue):\(anchor.candidate.id)", + kind: kind, + sourceID: anchor.candidate.id, + assetIDs: [asset.id, anchor.asset.id], + coordinate: anchor.candidate.coordinate, + observedAtUTC: anchor.asset.captureTimeUTC, + estimatedRadiusMeters: radius, + hopCount: 1, + isCircular: false, + note: note + ) + } + } + + // MARK: Region fallback and resolution + + private func regionCandidates( + for asset: CaptureAsset, + regions: [ActivityRegion] + ) -> [LocationCandidate] { + guard let activityID = asset.activityID else { return [] } + return regions.filter { + $0.activityID == activityID + && $0.coordinate.isValid + && $0.contains(asset.captureTimeUTC) + } + .sorted { $0.id < $1.id } + .map { region in + let isUserPin = region.source == .userPin + let evidence = LocationEvidence( + id: "region:\(region.id)", + kind: .regionPrior, + sourceID: region.id, + assetIDs: [asset.id], + coordinate: region.coordinate, + estimatedRadiusMeters: max(region.radiusMeters, 1), + hopCount: 0, + note: region.label + ) + return candidate( + asset: asset, + coordinate: region.coordinate, + sourceKind: .activityRegion, + granularity: region.radiusMeters <= 1_000 ? .coarse : .veryCoarse, + confidence: isUserPin ? .manual : .low, + radius: max(region.radiusMeters, 1), + requiresConfirmation: !isUserPin, + ruleID: "v2.activity-region-\(region.source.rawValue)", + evidence: [evidence] + ) + } + } + + private func resolve(asset: CaptureAsset, candidates: [LocationCandidate]) + -> LocationResolution + { + let sorted = candidates.sorted(by: candidateOrder) + guard let selected = sorted.first else { + return LocationResolution( + assetID: asset.id, + status: .unresolved, + selectedCandidate: nil, + candidates: [], + reasons: [.noCandidate], + ruleVersion: policy.version + ) + } + + if selected.sourceKind != .manualOverride { + let comparable = sorted.filter { + isComparableStrong($0) && isComparableStrong(selected) + } + let maximumConflict = + comparable.map { + GeoMath.distance(from: selected.coordinate, to: $0.coordinate) + }.max() ?? 0 + if maximumConflict > policy.comparableCandidateConflictMeters { + return LocationResolution( + assetID: asset.id, + status: .conflict, + selectedCandidate: nil, + candidates: sorted, + reasons: [.comparableStrongCandidatesConflict], + maximumComparableConflictMeters: maximumConflict, + ruleVersion: policy.version + ) + } + } + + let needsReview = + selected.requiresConfirmation + || (selected.confidence != .high && selected.confidence != .manual) + return LocationResolution( + assetID: asset.id, + status: needsReview ? .review : .resolved, + selectedCandidate: selected, + candidates: sorted, + reasons: [needsReview ? .weakEvidenceNeedsReview : .selectedHighestPriority], + ruleVersion: policy.version + ) + } + + // MARK: Deterministic helpers + + private func candidate( + asset: CaptureAsset, + coordinate: GeoCoordinate, + elevationMeters: Double? = nil, + sourceKind: LocationSourceKind, + granularity: LocationGranularity, + confidence: LocationDecisionConfidence, + radius: Double?, + requiresConfirmation: Bool, + sourcePriority: Int = 100, + ruleID: String, + evidence: [LocationEvidence] + ) -> LocationCandidate { + let sortedEvidence = evidence.sorted { $0.id < $1.id } + let evidenceKey = sortedEvidence.map(\.id).joined(separator: "+") + return LocationCandidate( + id: "\(asset.id.rawValue)|\(sourceKind.rawValue)|\(ruleID)|\(evidenceKey)", + assetID: asset.id, + coordinate: coordinate, + elevationMeters: elevationMeters, + sourceKind: sourceKind, + granularity: granularity, + confidence: confidence, + estimatedRadiusMeters: radius.map { max(1, $0) }, + requiresConfirmation: requiresConfirmation, + sourcePriority: sourcePriority, + ruleID: ruleID, + evidence: sortedEvidence + ) + } + + private func fixAge( + for observation: AssetLocationObservation, + asset: CaptureAsset + ) -> TimeInterval? { + guard let fixTime = observation.gpsTimestampUTC ?? observation.observedAtUTC else { + return nil + } + return abs(asset.captureTimeUTC.timeIntervalSince(fixTime)) + } + + private func spatialGranularity( + horizontalAccuracyMeters: Double? + ) -> LocationGranularity { + guard let accuracy = horizontalAccuracyMeters else { return .coarse } + if accuracy <= 25 { return .precise } + if accuracy <= 500 { return .coarse } + return .veryCoarse + } + + private func maximumAccuracy(of candidate: MatchCandidate) -> Double? { + [ + candidate.startPoint.horizontalAccuracyMeters, + candidate.endPoint?.horizontalAccuracyMeters, + ].compactMap { $0 }.max() + } + + private func boundedDispersion( + _ coordinates: [GeoCoordinate], + maximumAllowed: Double + ) -> Double? { + guard coordinates.count > 1 else { return 0 } + let latitudes = coordinates.map(\.latitude) + let longitudes = coordinates.map(\.longitude) + guard let minimumLatitude = latitudes.min(), let maximumLatitude = latitudes.max(), + let minimumLongitude = longitudes.min(), let maximumLongitude = longitudes.max() + else { return 0 } + if maximumLongitude - minimumLongitude <= 180 { + let conservativeDiagonal = GeoMath.distance( + from: GeoCoordinate(latitude: minimumLatitude, longitude: minimumLongitude), + to: GeoCoordinate(latitude: maximumLatitude, longitude: maximumLongitude) + ) + if conservativeDiagonal <= maximumAllowed { + return conservativeDiagonal + } + } + var maximum = 0.0 + for first in 0..<(coordinates.count - 1) { + for second in (first + 1).. [LocationCandidate] { + var seen: Set = [] + return candidates.sorted(by: candidateOrder).filter { seen.insert($0.id).inserted } + } + + private func isComparableStrong(_ candidate: LocationCandidate) -> Bool { + sourceRank(candidate.sourceKind) <= sourceRank(.embeddedTrackFix) + && !candidate.requiresConfirmation + } + + private func sourceRank(_ source: LocationSourceKind) -> Int { + switch source { + case .manualOverride: 0 + case .directSensor: 10 + case .sameAsset: 20 + case .gpxExact: 30 + case .gpxInterpolated: 40 + case .embeddedFreshFix: 50 + case .embeddedTrackFix: 60 + case .burstPropagation: 70 + case .sequencePropagation: 80 + case .stationaryBounded: 80 + case .crossCamera: 90 + case .activityRegion: 100 + } + } + + private func granularityRank(_ granularity: LocationGranularity) -> Int { + switch granularity { + case .exact: 0 + case .precise: 10 + case .coarse: 20 + case .veryCoarse: 30 + } + } + + private func candidateOrder(_ left: LocationCandidate, _ right: LocationCandidate) -> Bool { + let leftIsCircular = left.evidence.contains(where: \.isCircular) + let rightIsCircular = right.evidence.contains(where: \.isCircular) + if leftIsCircular != rightIsCircular { + return !leftIsCircular + } + let leftRank = sourceRank(left.sourceKind) + let rightRank = sourceRank(right.sourceKind) + if leftRank != rightRank { return leftRank < rightRank } + if left.sourcePriority != right.sourcePriority { + return left.sourcePriority < right.sourcePriority + } + if left.requiresConfirmation != right.requiresConfirmation { + return !left.requiresConfirmation + } + let leftGranularity = granularityRank(left.granularity) + let rightGranularity = granularityRank(right.granularity) + if leftGranularity != rightGranularity { return leftGranularity < rightGranularity } + if left.estimatedRadiusMeters != right.estimatedRadiusMeters { + switch (left.estimatedRadiusMeters, right.estimatedRadiusMeters) { + case (let left?, let right?): return left < right + case (_?, nil): return true + case (nil, _?): return false + case (nil, nil): break + } + } + return left.id < right.id + } + + private func assetOrder(_ left: CaptureAsset, _ right: CaptureAsset) -> Bool { + if left.captureTimeUTC != right.captureTimeUTC { + return left.captureTimeUTC < right.captureTimeUTC + } + return left.id.rawValue < right.id.rawValue + } + + private func makeAnchorIndex(_ anchors: [Anchor]) -> AnchorIndex { + var byActivity: [ActivityID: [Anchor]] = [:] + var byStream: [AnchorStreamKey: [Anchor]] = [:] + for anchor in anchors { + if let activityID = anchor.asset.activityID { + byActivity[activityID, default: []].append(anchor) + } + if let key = anchorStreamKey(for: anchor.asset) { + byStream[key, default: []].append(anchor) + } + } + for key in Array(byActivity.keys) { + byActivity[key]?.sort { $0.asset.captureTimeUTC < $1.asset.captureTimeUTC } + } + for key in Array(byStream.keys) { + byStream[key]?.sort { $0.asset.captureTimeUTC < $1.asset.captureTimeUTC } + } + return AnchorIndex(byActivity: byActivity, byStream: byStream) + } + + private func anchorStreamKey(for asset: CaptureAsset) -> AnchorStreamKey? { + guard let activityID = asset.activityID, let cameraID = asset.camera?.id else { return nil } + return AnchorStreamKey(activityID: activityID, cameraID: cameraID) + } + + private func anchorOrder(_ left: Anchor, _ right: Anchor, targetTime: Date) -> Bool { + if candidateOrder(left.candidate, right.candidate) { return true } + if candidateOrder(right.candidate, left.candidate) { return false } + let leftDelta = abs(left.asset.captureTimeUTC.timeIntervalSince(targetTime)) + let rightDelta = abs(right.asset.captureTimeUTC.timeIntervalSince(targetTime)) + if leftDelta != rightDelta { return leftDelta < rightDelta } + return left.asset.id.rawValue < right.asset.id.rawValue + } +} diff --git a/RawGeoCore/Sources/RawGeoCore/TrajectoryCorpus.swift b/RawGeoCore/Sources/RawGeoCore/TrajectoryCorpus.swift new file mode 100644 index 0000000..86a003d --- /dev/null +++ b/RawGeoCore/Sources/RawGeoCore/TrajectoryCorpus.swift @@ -0,0 +1,233 @@ +import Foundation + +/// Splits logical trajectory sources into matchable sessions. Missing coverage and +/// flight-speed legs are explicit relations, so no interpolation crosses them. +public struct TrajectoryCorpusBuilder: Sendable { + public let policy: LocationRulePolicy + + public init(policy: LocationRulePolicy = .v2) { + self.policy = policy + } + + public func build(sources: [TrajectoryLogicalSource]) -> TrajectoryCorpus { + let sortedSources = sources.sorted { + if $0.priority != $1.priority { return $0.priority < $1.priority } + return $0.id.rawValue < $1.id.rawValue + } + var allSessions: [TrajectorySession] = [] + var allRelations: [TrajectoryRelation] = [] + + for source in sortedSources { + let result = sessions(for: source) + allSessions.append(contentsOf: result.sessions) + allRelations.append(contentsOf: result.relations) + } + + allSessions.sort(by: sessionOrder) + allRelations.sort { $0.id < $1.id } + return TrajectoryCorpus( + sources: sortedSources, + sessions: allSessions, + relations: allRelations + ) + } + + private func sessions(for source: TrajectoryLogicalSource) -> ( + sessions: [TrajectorySession], relations: [TrajectoryRelation] + ) { + let segments = source.track.segments.sorted { + let left = $0.points.first?.timestamp ?? .distantFuture + let right = $1.points.first?.timestamp ?? .distantFuture + if left != right { return left < right } + return $0.id < $1.id + } + var sessions: [TrajectorySession] = [] + var relations: [TrajectoryRelation] = [] + var previousSegmentLastSession: TrajectorySession? + + for segment in segments where !segment.points.isEmpty { + var segmentSessions: [TrajectorySession] = [] + var currentPoints = [segment.points[0]] + var currentIntervals: [TrackInterval] = [] + var runIndex = 0 + + func makeSession() -> TrajectorySession { + TrajectorySession( + id: "\(source.id.rawValue)#\(segment.id).\(runIndex)", + sourceID: source.id, + sourceKind: source.kind, + sourcePriority: source.priority, + sourceSegmentID: segment.id, + points: currentPoints, + intervals: currentIntervals, + mobility: .ground + ) + } + + for interval in segment.intervals { + let boundaryKind: TrajectoryRelationKind? + if interval.impliedSpeedMetersPerSecond + >= policy.flightBoundarySpeedMetersPerSecond + { + boundaryKind = .flightBoundary + } else if interval.kind == .gap { + boundaryKind = .missingCoverage + } else { + boundaryKind = nil + } + + guard let boundaryKind else { + currentIntervals.append(interval) + currentPoints.append(interval.end) + continue + } + + let left = makeSession() + segmentSessions.append(left) + runIndex += 1 + currentPoints = [interval.end] + currentIntervals = [] + let rightID = "\(source.id.rawValue)#\(segment.id).\(runIndex)" + relations.append( + relation( + sourceID: source.id, + leftSessionID: left.id, + rightSessionID: rightID, + kind: boundaryKind, + start: interval.start, + end: interval.end + ) + ) + } + + let last = makeSession() + segmentSessions.append(last) + + if let previous = previousSegmentLastSession, + let next = segmentSessions.first, + let previousPoint = previous.points.last, + let nextPoint = next.points.first + { + relations.append( + relation( + sourceID: source.id, + leftSessionID: previous.id, + rightSessionID: next.id, + kind: .sourceSegmentBoundary, + start: previousPoint, + end: nextPoint + ) + ) + } + + sessions.append(contentsOf: segmentSessions) + previousSegmentLastSession = segmentSessions.last + } + + return (sessions, relations) + } + + private func relation( + sourceID: TrajectorySourceID, + leftSessionID: String, + rightSessionID: String, + kind: TrajectoryRelationKind, + start: TrackPoint, + end: TrackPoint + ) -> TrajectoryRelation { + let duration = end.timestamp.timeIntervalSince(start.timestamp) + let distance = GeoMath.distance(from: start.coordinate, to: end.coordinate) + return TrajectoryRelation( + id: "\(sourceID.rawValue):\(leftSessionID)>\(rightSessionID):\(kind.rawValue)", + sourceID: sourceID, + leftSessionID: leftSessionID, + rightSessionID: rightSessionID, + kind: kind, + durationSeconds: duration, + distanceMeters: distance, + impliedSpeedMetersPerSecond: duration > 0 ? distance / duration : nil + ) + } + + private func sessionOrder(_ left: TrajectorySession, _ right: TrajectorySession) -> Bool { + let leftTime = left.startTimeUTC ?? .distantFuture + let rightTime = right.startTimeUTC ?? .distantFuture + if leftTime != rightTime { return leftTime < rightTime } + if left.sourcePriority != right.sourcePriority { + return left.sourcePriority < right.sourcePriority + } + return left.id < right.id + } +} + +/// Converts de-duplicated embedded camera fixes into a sparse logical track. +/// Fix time, not photo capture time, is the trajectory timestamp. +public struct EmbeddedFixTrajectoryBuilder: Sendable { + public let normalizer: TrackNormalizer + + public init(normalizer: TrackNormalizer = TrackNormalizer()) { + self.normalizer = normalizer + } + + public func makeSource( + id: TrajectorySourceID, + displayName: String? = nil, + priority: Int = 200, + observations: [AssetLocationObservation] + ) -> TrajectoryLogicalSource? { + let eligible = observations.filter { + $0.kind == .cameraEmbedded + && $0.coordinate.isValid + && $0.gpsTimestampUTC != nil + } + let sorted = eligible.sorted { + let leftTime = $0.gpsTimestampUTC ?? .distantFuture + let rightTime = $1.gpsTimestampUTC ?? .distantFuture + if leftTime != rightTime { return leftTime < rightTime } + return $0.id < $1.id + } + + var seen: Set = [] + var points: [TrackPoint] = [] + for observation in sorted { + guard let timestamp = observation.gpsTimestampUTC else { continue } + let key = FixKey(timestamp: timestamp, coordinate: observation.coordinate) + guard seen.insert(key).inserted else { continue } + points.append( + TrackPoint( + timestamp: timestamp, + coordinate: observation.coordinate, + elevationMeters: observation.elevationMeters, + horizontalAccuracyMeters: observation.horizontalAccuracyMeters, + source: TrackPointSource(trackIndex: 0, segmentIndex: 0, pointIndex: points.count) + ) + ) + } + guard !points.isEmpty else { return nil } + + let document = GPXDocument( + version: nil, + creator: "RawGeoCore.EmbeddedFixTrajectoryBuilder", + segments: [GPXTrackSegment(trackIndex: 0, segmentIndex: 0, points: points)] + ) + return TrajectoryLogicalSource( + id: id, + displayName: displayName, + kind: .embeddedCameraFixes, + priority: priority, + track: normalizer.normalize(document) + ) + } + + private struct FixKey: Hashable { + let timestampMilliseconds: Int64 + let latitudeNanodegrees: Int64 + let longitudeNanodegrees: Int64 + + init(timestamp: Date, coordinate: GeoCoordinate) { + timestampMilliseconds = Int64((timestamp.timeIntervalSince1970 * 1_000).rounded()) + latitudeNanodegrees = Int64((coordinate.latitude * 1_000_000_000).rounded()) + longitudeNanodegrees = Int64((coordinate.longitude * 1_000_000_000).rounded()) + } + } +} diff --git a/RawGeoCore/Sources/RawGeoCore/V2Models.swift b/RawGeoCore/Sources/RawGeoCore/V2Models.swift new file mode 100644 index 0000000..1a8e621 --- /dev/null +++ b/RawGeoCore/Sources/RawGeoCore/V2Models.swift @@ -0,0 +1,742 @@ +import Foundation + +// MARK: - Assets and cameras + +public struct CaptureAssetID: RawRepresentable, Hashable, Sendable, Codable, + ExpressibleByStringLiteral, CustomStringConvertible +{ + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } + + public var description: String { rawValue } +} + +public struct CameraID: RawRepresentable, Hashable, Sendable, Codable, + ExpressibleByStringLiteral, CustomStringConvertible +{ + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } + + public var description: String { rawValue } +} + +public struct ActivityID: RawRepresentable, Hashable, Sendable, Codable, + ExpressibleByStringLiteral, CustomStringConvertible +{ + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } + + public var description: String { rawValue } +} + +public struct CameraIdentity: Hashable, Sendable, Codable, Identifiable { + public let id: CameraID + public let make: String? + public let model: String? + public let serialNumber: String? + public let internalSerialNumber: String? + + public init( + id: CameraID, + make: String? = nil, + model: String? = nil, + serialNumber: String? = nil, + internalSerialNumber: String? = nil + ) { + self.id = id + self.make = make + self.model = model + self.serialNumber = serialNumber + self.internalSerialNumber = internalSerialNumber + } +} + +public enum CaptureAssetKind: String, Hashable, Sendable, Codable { + case raw + case originalDNG + case derivedDNG + case rendered + case sidecar +} + +public enum CaptureTimePrecision: String, Hashable, Sendable, Codable { + case subsecond + case second +} + +public struct CaptureAsset: Hashable, Sendable, Codable, Identifiable { + public let id: CaptureAssetID + public let relativePath: String + public let kind: CaptureAssetKind + public let activityID: ActivityID? + public let camera: CameraIdentity? + public let captureTimeUTC: Date + public let captureTimePrecision: CaptureTimePrecision + public let sequenceNumber: Int? + public let shutterCount: Int? + + public init( + id: CaptureAssetID, + relativePath: String, + kind: CaptureAssetKind = .raw, + activityID: ActivityID? = nil, + camera: CameraIdentity? = nil, + captureTimeUTC: Date, + captureTimePrecision: CaptureTimePrecision = .second, + sequenceNumber: Int? = nil, + shutterCount: Int? = nil + ) { + self.id = id + self.relativePath = relativePath + self.kind = kind + self.activityID = activityID + self.camera = camera + self.captureTimeUTC = captureTimeUTC + self.captureTimePrecision = captureTimePrecision + self.sequenceNumber = sequenceNumber + self.shutterCount = shutterCount + } +} + +public enum AssetRelationKind: String, Hashable, Sendable, Codable { + case sameAsset + case derivedFrom + case sidecarOf +} + +public struct AssetRelation: Hashable, Sendable, Codable, Identifiable { + public let id: String + public let sourceAssetID: CaptureAssetID + public let targetAssetID: CaptureAssetID + public let kind: AssetRelationKind + + public init( + id: String, + sourceAssetID: CaptureAssetID, + targetAssetID: CaptureAssetID, + kind: AssetRelationKind + ) { + self.id = id + self.sourceAssetID = sourceAssetID + self.targetAssetID = targetAssetID + self.kind = kind + } +} + +// MARK: - Observations and regions + +public enum AssetLocationObservationKind: String, Hashable, Sendable, Codable { + case directSensor + case cameraEmbedded + case sidecar + case renderedDerivative + case manual +} + +public struct AssetLocationObservation: Hashable, Sendable, Codable, Identifiable { + public let id: String + public let assetID: CaptureAssetID + public let coordinate: GeoCoordinate + public let elevationMeters: Double? + public let observedAtUTC: Date? + public let gpsTimestampUTC: Date? + public let horizontalAccuracyMeters: Double? + public let kind: AssetLocationObservationKind + public let isCircular: Bool + + public init( + id: String, + assetID: CaptureAssetID, + coordinate: GeoCoordinate, + elevationMeters: Double? = nil, + observedAtUTC: Date? = nil, + gpsTimestampUTC: Date? = nil, + horizontalAccuracyMeters: Double? = nil, + kind: AssetLocationObservationKind, + isCircular: Bool = false + ) { + self.id = id + self.assetID = assetID + self.coordinate = coordinate + self.elevationMeters = elevationMeters + self.observedAtUTC = observedAtUTC + self.gpsTimestampUTC = gpsTimestampUTC + self.horizontalAccuracyMeters = horizontalAccuracyMeters + self.kind = kind + self.isCircular = isCircular + } +} + +public enum ActivityRegionSource: String, Hashable, Sendable, Codable { + case userPin + case placeName + case learned +} + +public struct ActivityRegion: Hashable, Sendable, Codable, Identifiable { + public let id: String + public let activityID: ActivityID + public let coordinate: GeoCoordinate + public let radiusMeters: Double + public let source: ActivityRegionSource + public let label: String? + public let activeFromUTC: Date? + public let activeToUTC: Date? + + public init( + id: String, + activityID: ActivityID, + coordinate: GeoCoordinate, + radiusMeters: Double, + source: ActivityRegionSource, + label: String? = nil, + activeFromUTC: Date? = nil, + activeToUTC: Date? = nil + ) { + self.id = id + self.activityID = activityID + self.coordinate = coordinate + self.radiusMeters = radiusMeters + self.source = source + self.label = label + self.activeFromUTC = activeFromUTC + self.activeToUTC = activeToUTC + } + + public func contains(_ timestamp: Date) -> Bool { + if let activeFromUTC, timestamp < activeFromUTC { return false } + if let activeToUTC, timestamp > activeToUTC { return false } + return true + } +} + +// MARK: - Trajectory corpus + +public struct TrajectorySourceID: RawRepresentable, Hashable, Sendable, Codable, + ExpressibleByStringLiteral, CustomStringConvertible +{ + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } + + public var description: String { rawValue } +} + +public enum TrajectorySourceKind: String, Hashable, Sendable, Codable { + case gpx + case embeddedCameraFixes + case directSensorTrack + case manualTrack +} + +public struct TrajectoryLogicalSource: Hashable, Sendable, Codable, Identifiable { + public let id: TrajectorySourceID + public let displayName: String? + public let kind: TrajectorySourceKind + public let priority: Int + public let track: NormalizedTrack + + public init( + id: TrajectorySourceID, + displayName: String? = nil, + kind: TrajectorySourceKind, + priority: Int = 100, + track: NormalizedTrack + ) { + self.id = id + self.displayName = displayName + self.kind = kind + self.priority = priority + self.track = track + } +} + +public enum TrajectoryMobility: String, Hashable, Sendable, Codable { + case ground + case unknown +} + +public struct TrajectorySession: Hashable, Sendable, Codable, Identifiable { + public let id: String + public let sourceID: TrajectorySourceID + public let sourceKind: TrajectorySourceKind + public let sourcePriority: Int + public let sourceSegmentID: Int + public let points: [TrackPoint] + public let intervals: [TrackInterval] + public let mobility: TrajectoryMobility + + public init( + id: String, + sourceID: TrajectorySourceID, + sourceKind: TrajectorySourceKind, + sourcePriority: Int, + sourceSegmentID: Int, + points: [TrackPoint], + intervals: [TrackInterval], + mobility: TrajectoryMobility = .ground + ) { + self.id = id + self.sourceID = sourceID + self.sourceKind = sourceKind + self.sourcePriority = sourcePriority + self.sourceSegmentID = sourceSegmentID + self.points = points + self.intervals = intervals + self.mobility = mobility + } + + public var startTimeUTC: Date? { points.first?.timestamp } + public var endTimeUTC: Date? { points.last?.timestamp } + + public var normalizedTrack: NormalizedTrack { + NormalizedTrack( + segments: [ + NormalizedTrackSegment( + id: sourceSegmentID, + sourceTrackIndex: points.first?.source.trackIndex ?? 0, + sourceSegmentIndex: points.first?.source.segmentIndex ?? 0, + points: points, + intervals: intervals + ) + ], + warnings: [] + ) + } +} + +public enum TrajectoryRelationKind: String, Hashable, Sendable, Codable { + case missingCoverage + case flightBoundary + case sourceSegmentBoundary +} + +public struct TrajectoryRelation: Hashable, Sendable, Codable, Identifiable { + public let id: String + public let sourceID: TrajectorySourceID + public let leftSessionID: String + public let rightSessionID: String + public let kind: TrajectoryRelationKind + public let durationSeconds: TimeInterval + public let distanceMeters: Double + public let impliedSpeedMetersPerSecond: Double? + + public init( + id: String, + sourceID: TrajectorySourceID, + leftSessionID: String, + rightSessionID: String, + kind: TrajectoryRelationKind, + durationSeconds: TimeInterval, + distanceMeters: Double, + impliedSpeedMetersPerSecond: Double? + ) { + self.id = id + self.sourceID = sourceID + self.leftSessionID = leftSessionID + self.rightSessionID = rightSessionID + self.kind = kind + self.durationSeconds = durationSeconds + self.distanceMeters = distanceMeters + self.impliedSpeedMetersPerSecond = impliedSpeedMetersPerSecond + } +} + +public struct TrajectoryCorpus: Hashable, Sendable, Codable { + public let sources: [TrajectoryLogicalSource] + public let sessions: [TrajectorySession] + public let relations: [TrajectoryRelation] + + public init( + sources: [TrajectoryLogicalSource], + sessions: [TrajectorySession], + relations: [TrajectoryRelation] + ) { + self.sources = sources + self.sessions = sessions + self.relations = relations + } +} + +// MARK: - Candidates, provenance, and results + +public enum LocationSourceKind: String, Hashable, Sendable, Codable { + case manualOverride + case directSensor + case sameAsset + case gpxExact + case gpxInterpolated + case embeddedFreshFix + case embeddedTrackFix + case burstPropagation + case sequencePropagation + case stationaryBounded + case crossCamera + case activityRegion +} + +public enum LocationEvidenceKind: String, Hashable, Sendable, Codable { + case manualObservation + case directObservation + case relatedAssetObservation + case trajectoryPoint + case trajectoryInterval + case embeddedFix + case temporalNeighbor + case sequenceNeighbor + case stationaryBounds + case crossCameraNeighbor + case regionPrior +} + +public enum LocationGranularity: String, Hashable, Sendable, Codable { + case exact + case precise + case coarse + case veryCoarse +} + +public enum LocationDecisionConfidence: String, Hashable, Sendable, Codable { + case high + case medium + case low + case manual +} + +public struct LocationEvidence: Hashable, Sendable, Codable, Identifiable { + public let id: String + public let kind: LocationEvidenceKind + public let sourceID: String? + public let assetIDs: [CaptureAssetID] + public let coordinate: GeoCoordinate? + public let observedAtUTC: Date? + public let fixAgeSeconds: TimeInterval? + public let horizontalAccuracyMeters: Double? + public let estimatedRadiusMeters: Double? + public let hopCount: Int + public let isCircular: Bool + public let note: String? + + public init( + id: String, + kind: LocationEvidenceKind, + sourceID: String? = nil, + assetIDs: [CaptureAssetID] = [], + coordinate: GeoCoordinate? = nil, + observedAtUTC: Date? = nil, + fixAgeSeconds: TimeInterval? = nil, + horizontalAccuracyMeters: Double? = nil, + estimatedRadiusMeters: Double? = nil, + hopCount: Int = 0, + isCircular: Bool = false, + note: String? = nil + ) { + self.id = id + self.kind = kind + self.sourceID = sourceID + self.assetIDs = assetIDs + self.coordinate = coordinate + self.observedAtUTC = observedAtUTC + self.fixAgeSeconds = fixAgeSeconds + self.horizontalAccuracyMeters = horizontalAccuracyMeters + self.estimatedRadiusMeters = estimatedRadiusMeters + self.hopCount = hopCount + self.isCircular = isCircular + self.note = note + } +} + +public struct LocationCandidate: Hashable, Sendable, Codable, Identifiable { + public let id: String + public let assetID: CaptureAssetID + public let coordinate: GeoCoordinate + public let elevationMeters: Double? + public let sourceKind: LocationSourceKind + public let granularity: LocationGranularity + public let confidence: LocationDecisionConfidence + /// A conservative radius inferred from geometry, not sensor accuracy. + /// `nil` means the engine has no defensible geometric radius estimate. + public let estimatedRadiusMeters: Double? + public let requiresConfirmation: Bool + public let sourcePriority: Int + public let ruleID: String + public let evidence: [LocationEvidence] + + public init( + id: String, + assetID: CaptureAssetID, + coordinate: GeoCoordinate, + elevationMeters: Double? = nil, + sourceKind: LocationSourceKind, + granularity: LocationGranularity, + confidence: LocationDecisionConfidence, + estimatedRadiusMeters: Double? = nil, + requiresConfirmation: Bool, + sourcePriority: Int = 100, + ruleID: String, + evidence: [LocationEvidence] + ) { + self.id = id + self.assetID = assetID + self.coordinate = coordinate + self.elevationMeters = elevationMeters + self.sourceKind = sourceKind + self.granularity = granularity + self.confidence = confidence + self.estimatedRadiusMeters = estimatedRadiusMeters + self.requiresConfirmation = requiresConfirmation + self.sourcePriority = sourcePriority + self.ruleID = ruleID + self.evidence = evidence + } +} + +public enum LocationResolutionStatus: String, Hashable, Sendable, Codable { + case resolved + case review + case conflict + case unresolved +} + +public enum LocationResolutionReason: String, Hashable, Sendable, Codable { + case selectedHighestPriority + case comparableStrongCandidatesConflict + case weakEvidenceNeedsReview + case noCandidate +} + +public struct LocationResolution: Hashable, Sendable, Codable, Identifiable { + public var id: CaptureAssetID { assetID } + + public let assetID: CaptureAssetID + public let status: LocationResolutionStatus + public let selectedCandidate: LocationCandidate? + public let candidates: [LocationCandidate] + public let reasons: [LocationResolutionReason] + public let maximumComparableConflictMeters: Double? + public let ruleVersion: String + + public init( + assetID: CaptureAssetID, + status: LocationResolutionStatus, + selectedCandidate: LocationCandidate?, + candidates: [LocationCandidate], + reasons: [LocationResolutionReason], + maximumComparableConflictMeters: Double? = nil, + ruleVersion: String + ) { + self.assetID = assetID + self.status = status + self.selectedCandidate = selectedCandidate + self.candidates = candidates + self.reasons = reasons + self.maximumComparableConflictMeters = maximumComparableConflictMeters + self.ruleVersion = ruleVersion + } +} + +// MARK: - Fixed v2 policy + +public struct LocationRulePolicy: Hashable, Sendable, Codable { + public let version: String + public let directFixMaximumAgeSeconds: TimeInterval + public let embeddedFreshFixMaximumAgeSeconds: TimeInterval + public let burstWindowSeconds: TimeInterval + public let burstMaximumSequenceGap: Int + public let burstMaximumAnchorDispersionMeters: Double + public let sequenceWindowSeconds: TimeInterval + public let sequenceMaximumGap: Int + public let sequenceMaximumAnchorDispersionMeters: Double + public let stationaryMaximumSpanSeconds: TimeInterval + public let stationaryMaximumAnchorDistanceMeters: Double + public let crossCameraWindowSeconds: TimeInterval + public let crossCameraMaximumAnchorDispersionMeters: Double + public let comparableCandidateConflictMeters: Double + public let trajectorySessionGapSeconds: TimeInterval + public let flightBoundarySpeedMetersPerSecond: Double + + public init( + version: String, + directFixMaximumAgeSeconds: TimeInterval, + embeddedFreshFixMaximumAgeSeconds: TimeInterval, + burstWindowSeconds: TimeInterval, + burstMaximumSequenceGap: Int, + burstMaximumAnchorDispersionMeters: Double, + sequenceWindowSeconds: TimeInterval, + sequenceMaximumGap: Int, + sequenceMaximumAnchorDispersionMeters: Double, + stationaryMaximumSpanSeconds: TimeInterval, + stationaryMaximumAnchorDistanceMeters: Double, + crossCameraWindowSeconds: TimeInterval, + crossCameraMaximumAnchorDispersionMeters: Double, + comparableCandidateConflictMeters: Double, + trajectorySessionGapSeconds: TimeInterval, + flightBoundarySpeedMetersPerSecond: Double + ) { + self.version = version + self.directFixMaximumAgeSeconds = directFixMaximumAgeSeconds + self.embeddedFreshFixMaximumAgeSeconds = embeddedFreshFixMaximumAgeSeconds + self.burstWindowSeconds = burstWindowSeconds + self.burstMaximumSequenceGap = burstMaximumSequenceGap + self.burstMaximumAnchorDispersionMeters = burstMaximumAnchorDispersionMeters + self.sequenceWindowSeconds = sequenceWindowSeconds + self.sequenceMaximumGap = sequenceMaximumGap + self.sequenceMaximumAnchorDispersionMeters = sequenceMaximumAnchorDispersionMeters + self.stationaryMaximumSpanSeconds = stationaryMaximumSpanSeconds + self.stationaryMaximumAnchorDistanceMeters = stationaryMaximumAnchorDistanceMeters + self.crossCameraWindowSeconds = crossCameraWindowSeconds + self.crossCameraMaximumAnchorDispersionMeters = crossCameraMaximumAnchorDispersionMeters + self.comparableCandidateConflictMeters = comparableCandidateConflictMeters + self.trajectorySessionGapSeconds = trajectorySessionGapSeconds + self.flightBoundarySpeedMetersPerSecond = flightBoundarySpeedMetersPerSecond + } + + public static let v2 = LocationRulePolicy( + version: "2.0", + directFixMaximumAgeSeconds: 60, + embeddedFreshFixMaximumAgeSeconds: 120, + burstWindowSeconds: 30, + burstMaximumSequenceGap: 3, + burstMaximumAnchorDispersionMeters: 200, + sequenceWindowSeconds: 300, + sequenceMaximumGap: 50, + sequenceMaximumAnchorDispersionMeters: 500, + stationaryMaximumSpanSeconds: 1_800, + stationaryMaximumAnchorDistanceMeters: 500, + crossCameraWindowSeconds: 120, + crossCameraMaximumAnchorDispersionMeters: 500, + comparableCandidateConflictMeters: 1_000, + trajectorySessionGapSeconds: 1_800, + flightBoundarySpeedMetersPerSecond: 100 + ) +} + +public struct LocationInferenceInput: Hashable, Sendable, Codable { + public let assets: [CaptureAsset] + public let trajectoryCorpus: TrajectoryCorpus + public let observations: [AssetLocationObservation] + public let assetRelations: [AssetRelation] + public let activityRegions: [ActivityRegion] + + public init( + assets: [CaptureAsset], + trajectoryCorpus: TrajectoryCorpus = TrajectoryCorpus( + sources: [], sessions: [], relations: []), + observations: [AssetLocationObservation] = [], + assetRelations: [AssetRelation] = [], + activityRegions: [ActivityRegion] = [] + ) { + self.assets = assets + self.trajectoryCorpus = trajectoryCorpus + self.observations = observations + self.assetRelations = assetRelations + self.activityRegions = activityRegions + } +} + +// MARK: - Clock suggestions + +public enum ClockReferenceKind: String, Hashable, Sendable, Codable { + case directGPS + case synchronizedAsset + case userMarker +} + +public struct ClockReferenceObservation: Hashable, Sendable, Codable, Identifiable { + public let id: String + public let assetID: CaptureAssetID + public let cameraID: CameraID + public let cameraCaptureTimeUTC: Date + public let referenceTimeUTC: Date + public let kind: ClockReferenceKind + public let referenceAccuracySeconds: TimeInterval? + + public init( + id: String, + assetID: CaptureAssetID, + cameraID: CameraID, + cameraCaptureTimeUTC: Date, + referenceTimeUTC: Date, + kind: ClockReferenceKind, + referenceAccuracySeconds: TimeInterval? = nil + ) { + self.id = id + self.assetID = assetID + self.cameraID = cameraID + self.cameraCaptureTimeUTC = cameraCaptureTimeUTC + self.referenceTimeUTC = referenceTimeUTC + self.kind = kind + self.referenceAccuracySeconds = referenceAccuracySeconds + } +} + +public enum ClockSuggestionConfidence: String, Hashable, Sendable, Codable { + case high + case medium + case low +} + +public enum ClockSuggestionMethod: String, Hashable, Sendable, Codable { + case robustMedian +} + +public struct ClockSuggestion: Hashable, Sendable, Codable, Identifiable { + public var id: CameraID { cameraID } + + public let cameraID: CameraID + /// Positive means the camera clock is ahead of the reference and should be shifted backwards. + public let cameraAheadBySeconds: TimeInterval + public let confidence: ClockSuggestionConfidence + public let evidenceCount: Int + public let rejectedOutlierCount: Int + public let medianAbsoluteResidualSeconds: TimeInterval + public let method: ClockSuggestionMethod + public let evidenceIDs: [String] + + public init( + cameraID: CameraID, + cameraAheadBySeconds: TimeInterval, + confidence: ClockSuggestionConfidence, + evidenceCount: Int, + rejectedOutlierCount: Int, + medianAbsoluteResidualSeconds: TimeInterval, + method: ClockSuggestionMethod, + evidenceIDs: [String] + ) { + self.cameraID = cameraID + self.cameraAheadBySeconds = cameraAheadBySeconds + self.confidence = confidence + self.evidenceCount = evidenceCount + self.rejectedOutlierCount = rejectedOutlierCount + self.medianAbsoluteResidualSeconds = medianAbsoluteResidualSeconds + self.method = method + self.evidenceIDs = evidenceIDs + } +} diff --git a/RawGeoCore/Tests/RawGeoCoreTests/V2InferenceTests.swift b/RawGeoCore/Tests/RawGeoCoreTests/V2InferenceTests.swift new file mode 100644 index 0000000..60f7d55 --- /dev/null +++ b/RawGeoCore/Tests/RawGeoCoreTests/V2InferenceTests.swift @@ -0,0 +1,487 @@ +import XCTest + +@testable import RawGeoCore + +final class V2InferenceTests: XCTestCase { + private let origin = GeoCoordinate(latitude: 22.5, longitude: 114.0) + private let cameraA = CameraIdentity(id: "camera-a", make: "Nikon", model: "Z5") + private let cameraB = CameraIdentity(id: "camera-b", make: "Nikon", model: "Z50") + private let cameraC = CameraIdentity(id: "camera-c", make: "Sony", model: "A6400") + + func testCorpusSeparatesFlightAndMissingCoverageAcrossLogicalSources() { + let first = point(0, origin, index: 0) + let flown = point(10, GeoCoordinate(latitude: 24.5, longitude: 114), index: 1) + let later = point(4_000, GeoCoordinate(latitude: 25.5, longitude: 114), index: 2) + let segment = NormalizedTrackSegment( + id: 7, + sourceTrackIndex: 0, + sourceSegmentIndex: 0, + points: [first, flown, later], + intervals: [ + interval(first, flown, kind: .reliableInterpolation, reason: .shortDenseInterval), + interval(flown, later, kind: .gap, reason: .longMissingCoverage), + ] + ) + let firstSource = TrajectoryLogicalSource( + id: "annual-gpx", + kind: .gpx, + priority: 10, + track: NormalizedTrack(segments: [segment], warnings: []) + ) + let secondSource = singlePointSource(id: "camera-fixes", time: 20, coordinate: origin) + + let corpus = TrajectoryCorpusBuilder().build(sources: [secondSource, firstSource]) + + XCTAssertEqual(corpus.sources.map(\.id.rawValue), ["annual-gpx", "camera-fixes"]) + XCTAssertEqual(corpus.sessions.count, 4) + XCTAssertEqual(Set(corpus.relations.map(\.kind)), [.flightBoundary, .missingCoverage]) + XCTAssertTrue( + corpus.sessions.allSatisfy { + $0.intervals.allSatisfy { interval in + interval.impliedSpeedMetersPerSecond + < LocationRulePolicy.v2.flightBoundarySpeedMetersPerSecond + && interval.kind != .gap + } + }) + } + + func testEmbeddedFixBuilderDeduplicatesAndUsesFixTime() throws { + let assetID: CaptureAssetID = "nef-1" + let duplicate = AssetLocationObservation( + id: "fix-b", + assetID: assetID, + coordinate: origin, + gpsTimestampUTC: date(50), + kind: .cameraEmbedded + ) + let first = AssetLocationObservation( + id: "fix-a", + assetID: assetID, + coordinate: origin, + observedAtUTC: date(500), + gpsTimestampUTC: date(50), + kind: .cameraEmbedded + ) + let second = AssetLocationObservation( + id: "fix-c", + assetID: "nef-2", + coordinate: GeoCoordinate(latitude: 22.5001, longitude: 114), + observedAtUTC: date(800), + gpsTimestampUTC: date(80), + kind: .cameraEmbedded + ) + + let source = try XCTUnwrap( + EmbeddedFixTrajectoryBuilder().makeSource( + id: "z5-fixes", + observations: [second, duplicate, first] + ) + ) + let points = try XCTUnwrap(source.track.segments.first).points + XCTAssertEqual(points.count, 2) + XCTAssertEqual(points.map(\.timestamp), [date(50), date(80)]) + } + + func testFreshEmbeddedFixAcceptedButStaleFixIsOnlyTrackMaterial() { + let freshAsset = asset("fresh", time: 100) + let staleAsset = asset("stale", time: 500) + let observations = [ + AssetLocationObservation( + id: "fresh-fix", + assetID: freshAsset.id, + coordinate: origin, + gpsTimestampUTC: date(40), + kind: .cameraEmbedded + ), + AssetLocationObservation( + id: "stale-fix", + assetID: staleAsset.id, + coordinate: origin, + gpsTimestampUTC: date(40), + kind: .cameraEmbedded + ), + ] + + let results = DeterministicLocationEngine().resolve( + LocationInferenceInput(assets: [staleAsset, freshAsset], observations: observations) + ) + XCTAssertEqual( + results.first(where: { $0.id == freshAsset.id })?.selectedCandidate?.sourceKind, + .embeddedFreshFix) + XCTAssertEqual(results.first(where: { $0.id == staleAsset.id })?.status, .unresolved) + } + + func testSensorAccuracyRemainsSourceDataAndNoRadiusIsInvented() throws { + let photo = asset("iphone", time: 10, camera: cameraA) + let observation = AssetLocationObservation( + id: "gps", + assetID: photo.id, + coordinate: origin, + gpsTimestampUTC: photo.captureTimeUTC, + horizontalAccuracyMeters: 18, + kind: .directSensor + ) + let result = try XCTUnwrap( + DeterministicLocationEngine().resolve( + LocationInferenceInput(assets: [photo], observations: [observation]) + ).first + ) + let selected = try XCTUnwrap(result.selectedCandidate) + XCTAssertNil(selected.estimatedRadiusMeters) + XCTAssertEqual(selected.evidence.first?.horizontalAccuracyMeters, 18) + XCTAssertNil(selected.evidence.first?.estimatedRadiusMeters) + } + + func testBurstAndStationaryFallbacksAreSingleHop() throws { + let activity: ActivityID = "day" + let anchor = asset("anchor", time: 0, camera: cameraA, activity: activity, sequence: 10) + let burst = asset("burst", time: 20, camera: cameraA, activity: activity, sequence: 11) + let before = asset("before", time: 1_000, camera: cameraA, activity: activity, sequence: 100) + let middle = asset("middle", time: 1_600, camera: cameraA, activity: activity, sequence: 150) + let after = asset("after", time: 2_200, camera: cameraA, activity: activity, sequence: 200) + let near = GeoCoordinate(latitude: 22.5003, longitude: 114) + let observations = [ + directObservation("anchor-gps", asset: anchor, coordinate: origin), + directObservation("before-gps", asset: before, coordinate: origin), + directObservation("after-gps", asset: after, coordinate: near), + ] + + let results = DeterministicLocationEngine().resolve( + LocationInferenceInput( + assets: [after, middle, burst, before, anchor], + observations: observations + ) + ) + let burstResult = try XCTUnwrap(results.first(where: { $0.id == burst.id })) + XCTAssertEqual(burstResult.selectedCandidate?.sourceKind, .burstPropagation) + XCTAssertEqual(burstResult.status, .review) + XCTAssertEqual(burstResult.selectedCandidate?.evidence.first?.hopCount, 1) + let middleResult = try XCTUnwrap(results.first(where: { $0.id == middle.id })) + XCTAssertEqual(middleResult.selectedCandidate?.sourceKind, .stationaryBounded) + XCTAssertEqual(middleResult.status, .review) + XCTAssertEqual( + middleResult.selectedCandidate?.evidence.count(where: { $0.hopCount == 1 }), + 2 + ) + XCTAssertTrue( + middleResult.selectedCandidate?.evidence.contains(where: { $0.hopCount == 0 }) == true + ) + XCTAssertNotNil(middleResult.selectedCandidate?.estimatedRadiusMeters) + } + + func testPropagationDoesNotCrossActivityOrDisagreeingPlaces() throws { + let anchor = asset("anchor", time: 0, camera: cameraA, activity: "one", sequence: 1) + let otherActivity = asset("other", time: 10, camera: cameraA, activity: "two", sequence: 2) + let target = asset("target", time: 20, camera: cameraB, activity: "one", sequence: 3) + let farAnchor = asset("far", time: 25, camera: cameraC, activity: "one", sequence: 4) + let far = GeoCoordinate(latitude: 31.2, longitude: 121.5) + let results = DeterministicLocationEngine().resolve( + LocationInferenceInput( + assets: [target, otherActivity, anchor, farAnchor], + observations: [ + directObservation("near", asset: anchor, coordinate: origin), + directObservation("far", asset: farAnchor, coordinate: far), + ] + ) + ) + + XCTAssertEqual(results.first(where: { $0.id == otherActivity.id })?.status, .unresolved) + let targetResult = try XCTUnwrap(results.first(where: { $0.id == target.id })) + XCTAssertFalse(targetResult.candidates.contains { $0.sourceKind == .crossCamera }) + } + + func testCrossCameraStrongAnchorAndRegionFallback() throws { + let anchor = asset("a", time: 0, camera: cameraA, activity: "day") + let target = asset("b", time: 60, camera: cameraB, activity: "day") + let regionOnly = asset("c", time: 5_000, camera: cameraB, activity: "region") + let results = DeterministicLocationEngine().resolve( + LocationInferenceInput( + assets: [regionOnly, target, anchor], + observations: [directObservation("gps", asset: anchor, coordinate: origin)], + activityRegions: [ + ActivityRegion( + id: "dongguan", + activityID: "region", + coordinate: origin, + radiusMeters: 15_000, + source: .placeName + ) + ] + ) + ) + XCTAssertEqual( + results.first(where: { $0.id == target.id })?.selectedCandidate?.sourceKind, + .crossCamera) + XCTAssertEqual(results.first(where: { $0.id == target.id })?.status, .review) + XCTAssertEqual( + results.first(where: { $0.id == regionOnly.id })?.selectedCandidate?.sourceKind, + .activityRegion) + XCTAssertEqual(results.first(where: { $0.id == regionOnly.id })?.status, .review) + } + + func testCrossCameraEvidenceUsesBoundedRepresentatives() throws { + let target = asset("target", time: 60, camera: cameraA, activity: "day") + let anchors = (0..<10).map { index in + asset( + "anchor-\(index)", + time: Double(50 + index), + camera: CameraIdentity(id: CameraID(rawValue: "camera-\(index + 10)")), + activity: "day" + ) + } + let observations = anchors.enumerated().map { index, anchor in + directObservation( + "gps-\(index)", + asset: anchor, + coordinate: GeoCoordinate( + latitude: origin.latitude + Double(index) * 0.000_001, + longitude: origin.longitude + ) + ) + } + + let result = try XCTUnwrap( + DeterministicLocationEngine().resolve( + LocationInferenceInput(assets: [target] + anchors, observations: observations) + ).first(where: { $0.id == target.id }) + ) + + XCTAssertEqual(result.selectedCandidate?.sourceKind, .crossCamera) + XCTAssertLessThanOrEqual(result.selectedCandidate?.evidence.count ?? .max, 6) + } + + func testComparableStrongTracksConflictButWeakRegionCannotOverrideDirectSensor() throws { + let photo = asset("photo", time: 100, camera: cameraA, activity: "day") + let shanghai = GeoCoordinate(latitude: 31.2, longitude: 121.5) + let sourceOne = singlePointSource(id: "gpx-a", time: 100, coordinate: origin) + let sourceTwo = singlePointSource(id: "gpx-b", time: 100, coordinate: shanghai) + let corpus = TrajectoryCorpusBuilder().build(sources: [sourceTwo, sourceOne]) + + let conflict = try XCTUnwrap( + DeterministicLocationEngine().resolve( + LocationInferenceInput(assets: [photo], trajectoryCorpus: corpus) + ).first + ) + XCTAssertEqual(conflict.status, .conflict) + XCTAssertNil(conflict.selectedCandidate) + XCTAssertGreaterThan(conflict.maximumComparableConflictMeters ?? 0, 1_000) + + let direct = directObservation("direct", asset: photo, coordinate: origin) + let resolved = try XCTUnwrap( + DeterministicLocationEngine().resolve( + LocationInferenceInput( + assets: [photo], + observations: [direct], + activityRegions: [ + ActivityRegion( + id: "weak-shanghai", + activityID: "day", + coordinate: shanghai, + radiusMeters: 10_000, + source: .placeName + ) + ] + ) + ).first + ) + XCTAssertEqual(resolved.status, .resolved) + XCTAssertEqual(resolved.selectedCandidate?.sourceKind, .directSensor) + } + + func testCircularSameAssetEvidenceDoesNotOverrideIndependentTrack() throws { + let photo = asset("photo", time: 100, camera: cameraA, activity: "day") + let circular = AssetLocationObservation( + id: "generated-sidecar", + assetID: photo.id, + coordinate: GeoCoordinate(latitude: 31.2, longitude: 121.5), + observedAtUTC: photo.captureTimeUTC, + kind: .sidecar, + isCircular: true + ) + let source = singlePointSource(id: "independent-gpx", time: 100, coordinate: origin) + let corpus = TrajectoryCorpusBuilder().build(sources: [source]) + + let result = try XCTUnwrap( + DeterministicLocationEngine().resolve( + LocationInferenceInput( + assets: [photo], + trajectoryCorpus: corpus, + observations: [circular] + ) + ).first + ) + + XCTAssertEqual(result.selectedCandidate?.sourceKind, .gpxExact) + XCTAssertTrue(result.candidates.contains(where: { $0.sourceKind == .sameAsset })) + } + + func testResolutionIsDeterministicAcrossInputOrdering() { + let photo = asset("photo", time: 100, camera: cameraA, activity: "day") + let observations = [ + directObservation("z", asset: photo, coordinate: origin), + directObservation("a", asset: photo, coordinate: origin), + ] + let engine = DeterministicLocationEngine() + let forward = engine.resolve( + LocationInferenceInput(assets: [photo], observations: observations) + ) + let reversed = engine.resolve( + LocationInferenceInput(assets: [photo], observations: observations.reversed()) + ) + XCTAssertEqual(forward, reversed) + } + + func testActivityRegionsOnlyApplyInsideTheirPhotoSession() throws { + let early = asset("early", time: 100, camera: cameraA, activity: "travel-day") + let late = asset("late", time: 300, camera: cameraA, activity: "travel-day") + let lateCoordinate = GeoCoordinate(latitude: 31.2, longitude: 121.5) + let regions = [ + ActivityRegion( + id: "early-region", + activityID: "travel-day", + coordinate: origin, + radiusMeters: 10_000, + source: .learned, + activeFromUTC: date(50), + activeToUTC: date(150) + ), + ActivityRegion( + id: "late-region", + activityID: "travel-day", + coordinate: lateCoordinate, + radiusMeters: 10_000, + source: .learned, + activeFromUTC: date(250), + activeToUTC: date(350) + ), + ] + + let results = DeterministicLocationEngine().resolve( + LocationInferenceInput(assets: [late, early], activityRegions: regions) + ) + let byAsset = Dictionary(uniqueKeysWithValues: results.map { ($0.assetID, $0) }) + XCTAssertEqual(try XCTUnwrap(byAsset[early.id]?.selectedCandidate?.coordinate), origin) + XCTAssertEqual(try XCTUnwrap(byAsset[late.id]?.selectedCandidate?.coordinate), lateCoordinate) + XCTAssertEqual(byAsset[early.id]?.candidates.count, 1) + XCTAssertEqual(byAsset[late.id]?.candidates.count, 1) + } + + func testClockSuggestionUsesRobustMedianAndRejectsOutlier() throws { + var observations = (0..<10).map { index in + ClockReferenceObservation( + id: "clock-\(index)", + assetID: CaptureAssetID(rawValue: "asset-\(index)"), + cameraID: "camera-a", + cameraCaptureTimeUTC: date(Double(index * 100 + 5)), + referenceTimeUTC: date(Double(index * 100)), + kind: .directGPS, + referenceAccuracySeconds: 1 + ) + } + observations.append( + ClockReferenceObservation( + id: "outlier", + assetID: "outlier", + cameraID: "camera-a", + cameraCaptureTimeUTC: date(2_000), + referenceTimeUTC: date(1_000), + kind: .directGPS, + referenceAccuracySeconds: 1 + ) + ) + + let suggestion = try XCTUnwrap(ClockSuggestionEngine().suggest(from: observations).first) + XCTAssertEqual(suggestion.cameraAheadBySeconds, 5, accuracy: 0.001) + XCTAssertEqual(suggestion.confidence, .high) + XCTAssertEqual(suggestion.evidenceCount, 10) + XCTAssertEqual(suggestion.rejectedOutlierCount, 1) + } + + private func asset( + _ id: String, + time: Double, + camera: CameraIdentity? = nil, + activity: ActivityID? = nil, + sequence: Int? = nil + ) -> CaptureAsset { + CaptureAsset( + id: CaptureAssetID(rawValue: id), + relativePath: id, + activityID: activity, + camera: camera, + captureTimeUTC: date(time), + sequenceNumber: sequence + ) + } + + private func directObservation( + _ id: String, + asset: CaptureAsset, + coordinate: GeoCoordinate + ) -> AssetLocationObservation { + AssetLocationObservation( + id: id, + assetID: asset.id, + coordinate: coordinate, + gpsTimestampUTC: asset.captureTimeUTC, + horizontalAccuracyMeters: 10, + kind: .directSensor + ) + } + + private func singlePointSource( + id: TrajectorySourceID, + time: Double, + coordinate: GeoCoordinate + ) -> TrajectoryLogicalSource { + let point = point(time, coordinate, index: 0) + let segment = NormalizedTrackSegment( + id: 0, + sourceTrackIndex: 0, + sourceSegmentIndex: 0, + points: [point], + intervals: [] + ) + return TrajectoryLogicalSource( + id: id, + kind: id.rawValue.contains("fix") ? .embeddedCameraFixes : .gpx, + priority: id.rawValue == "annual-gpx" ? 10 : 100, + track: NormalizedTrack(segments: [segment], warnings: []) + ) + } + + private func point( + _ time: Double, + _ coordinate: GeoCoordinate, + index: Int + ) -> TrackPoint { + TrackPoint( + timestamp: date(time), + coordinate: coordinate, + source: TrackPointSource(trackIndex: 0, segmentIndex: 0, pointIndex: index) + ) + } + + private func interval( + _ start: TrackPoint, + _ end: TrackPoint, + kind: TrackIntervalKind, + reason: TrackIntervalReason + ) -> TrackInterval { + let duration = end.timestamp.timeIntervalSince(start.timestamp) + let distance = GeoMath.distance(from: start.coordinate, to: end.coordinate) + return TrackInterval( + start: start, + end: end, + durationSeconds: duration, + distanceMeters: distance, + impliedSpeedMetersPerSecond: distance / duration, + kind: kind, + reason: reason + ) + } + + private func date(_ seconds: Double) -> Date { + Date(timeIntervalSince1970: seconds) + } +} diff --git a/RawGeoSync.xcodeproj/project.pbxproj b/RawGeoSync.xcodeproj/project.pbxproj index 9d9838b..ba6c952 100644 --- a/RawGeoSync.xcodeproj/project.pbxproj +++ b/RawGeoSync.xcodeproj/project.pbxproj @@ -27,8 +27,19 @@ A10000000000000000000118 /* LiveGeoWorkflowService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000213 /* LiveGeoWorkflowService.swift */; }; A10000000000000000000119 /* RawGeoCore in Frameworks */ = {isa = PBXBuildFile; productRef = A10000000000000000000601 /* RawGeoCore */; }; A10000000000000000000120 /* MetadataInfrastructure in Frameworks */ = {isa = PBXBuildFile; productRef = A10000000000000000000602 /* MetadataInfrastructure */; }; + A10000000000000000000121 /* WorkspaceSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000217 /* WorkspaceSelectionTests.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + A10000000000000000000901 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A10000000000000000000502 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A10000000000000000000501; + remoteInfo = RawGeoSync; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ A10000000000000000000201 /* RawGeoSyncApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RawGeoSyncApp.swift; sourceTree = ""; }; A10000000000000000000202 /* WorkflowModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkflowModels.swift; sourceTree = ""; }; @@ -45,6 +56,8 @@ A10000000000000000000214 /* ExifTool */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ExifTool; path = Vendor/ExifTool; sourceTree = SOURCE_ROOT; }; A10000000000000000000215 /* SmokeMain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmokeMain.swift; sourceTree = ""; }; A10000000000000000000216 /* RawGeoSyncSmoke */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = RawGeoSyncSmoke; sourceTree = BUILT_PRODUCTS_DIR; }; + A10000000000000000000217 /* WorkspaceSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSelectionTests.swift; sourceTree = ""; }; + A10000000000000000000218 /* RawGeoSyncAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RawGeoSyncAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -67,6 +80,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A10000000000000000000306 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -75,6 +95,7 @@ children = ( A10000000000000000000402 /* RawGeoSyncApp */, A10000000000000000000410 /* Tools */, + A10000000000000000000412 /* RawGeoSyncAppTests */, A10000000000000000000214 /* ExifTool */, A10000000000000000000407 /* Frameworks */, A10000000000000000000408 /* Products */, @@ -143,6 +164,7 @@ children = ( A10000000000000000000212 /* RawGeoSync.app */, A10000000000000000000216 /* RawGeoSyncSmoke */, + A10000000000000000000218 /* RawGeoSyncAppTests.xctest */, ); name = Products; sourceTree = ""; @@ -171,6 +193,14 @@ path = RawGeoSmoke; sourceTree = ""; }; + A10000000000000000000412 /* RawGeoSyncAppTests */ = { + isa = PBXGroup; + children = ( + A10000000000000000000217 /* WorkspaceSelectionTests.swift */, + ); + path = RawGeoSyncAppTests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -215,6 +245,23 @@ productReference = A10000000000000000000216 /* RawGeoSyncSmoke */; productType = "com.apple.product-type.tool"; }; + A10000000000000000000504 /* RawGeoSyncAppTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A10000000000000000000704 /* Build configuration list for PBXNativeTarget "RawGeoSyncAppTests" */; + buildPhases = ( + A10000000000000000000307 /* Sources */, + A10000000000000000000306 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + A10000000000000000000902 /* PBXTargetDependency */, + ); + name = RawGeoSyncAppTests; + productName = RawGeoSyncAppTests; + productReference = A10000000000000000000218 /* RawGeoSyncAppTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -228,6 +275,10 @@ A10000000000000000000501 = { CreatedOnToolsVersion = 26.3; }; + A10000000000000000000504 = { + CreatedOnToolsVersion = 26.3; + TestTargetID = A10000000000000000000501; + }; }; }; buildConfigurationList = A10000000000000000000701 /* Build configuration list for PBXProject "RawGeoSync" */; @@ -250,6 +301,7 @@ targets = ( A10000000000000000000501 /* RawGeoSync */, A10000000000000000000503 /* RawGeoSyncSmoke */, + A10000000000000000000504 /* RawGeoSyncAppTests */, ); }; /* End PBXProject section */ @@ -294,8 +346,24 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A10000000000000000000307 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A10000000000000000000121 /* WorkspaceSelectionTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + A10000000000000000000902 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A10000000000000000000501 /* RawGeoSync */; + targetProxy = A10000000000000000000901 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ A10000000000000000000801 /* Debug */ = { isa = XCBuildConfiguration; @@ -408,7 +476,7 @@ MTL_FAST_MATH = YES; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; - }; + }; name = Release; }; A10000000000000000000803 /* Debug */ = { @@ -416,7 +484,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; @@ -427,7 +495,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.1.0; + MARKETING_VERSION = 0.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.sssimplec.RawGeoSync; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = macosx; @@ -442,7 +510,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; @@ -453,7 +521,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.1.0; + MARKETING_VERSION = 0.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.sssimplec.RawGeoSync; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = macosx; @@ -488,6 +556,39 @@ }; name = Release; }; + A10000000000000000000807 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGNING_ALLOWED = NO; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = com.sssimplec.RawGeoSyncAppTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/RawGeoSync.app/Contents/MacOS/RawGeoSync"; + }; + name = Debug; + }; + A10000000000000000000808 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGNING_ALLOWED = NO; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = com.sssimplec.RawGeoSyncAppTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/RawGeoSync.app/Contents/MacOS/RawGeoSync"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -518,6 +619,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A10000000000000000000704 /* Build configuration list for PBXNativeTarget "RawGeoSyncAppTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A10000000000000000000807 /* Debug */, + A10000000000000000000808 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ diff --git a/RawGeoSync.xcodeproj/xcshareddata/xcschemes/RawGeoSync.xcscheme b/RawGeoSync.xcodeproj/xcshareddata/xcschemes/RawGeoSync.xcscheme index 21a29ad..abec189 100644 --- a/RawGeoSync.xcodeproj/xcshareddata/xcschemes/RawGeoSync.xcscheme +++ b/RawGeoSync.xcodeproj/xcshareddata/xcschemes/RawGeoSync.xcscheme @@ -21,6 +21,20 @@ ReferencedContainer = "container:RawGeoSync.xcodeproj"> + + + + + + + + + + PhotoMatch? in - guard let file = filesByPath[result.photo.id] else { return nil } - return Self.makePhotoMatch( - result: result, - file: file, - rawMetadata: metadataByPath[file.url.path], - writeAltitude: configuration.writeAltitude + let matches = writableAssets.compactMap { prepared -> PhotoMatch? in + guard let rawFile = prepared.rawFile, + let resolution = resolutionByID[prepared.asset.id] + else { return nil } + return Self.makePhotoMatchV2( + resolution: resolution, + prepared: prepared, + file: rawFile, + strategy: configuration.matchingStrategy, + writeAltitude: configuration.writeAltitude, + trackSourceDigests: trackBuild.sourceDigests ) } .sorted { if $0.capturedAt != $1.capturedAt { return $0.capturedAt < $1.capturedAt } return $0.fileName.localizedStandardCompare($1.fileName) == .orderedAscending } - let trackCoordinates = Self.visibleTrackCoordinates( - track: normalizedTrack, - captures: captures.map(\.capture) + let trackCoordinates = Self.visibleTrackCoordinatesV2( + sources: trackBuild.sources, + captureDates: writableAssets.map { $0.asset.captureTimeUTC } ) + let clockSuggestions = Self.makeClockSuggestions(from: assetBuild) continuation.yield(.progress(fraction: 1, message: "分析完成")) continuation.yield( - .completed(AnalysisSnapshot(matches: matches, trackCoordinates: trackCoordinates)) + .completed( + AnalysisSnapshot( + matches: matches, + trackCoordinates: trackCoordinates, + warnings: mediaRead.warnings + assetBuild.warnings + trackBuild.warnings, + clockSuggestions: clockSuggestions + ) + ) ) continuation.finish() } catch is CancellationError { @@ -252,45 +320,775 @@ struct LiveGeoWorkflowService: GeoWorkflowServicing { ) } - private static func photoFiles(in directory: URL) throws -> [ReadOnlyRawFile] { - let urls = try FileManager.default.contentsOfDirectory( - at: directory, - includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], - options: [.skipsHiddenFiles] - ) + private static func photoFiles(in directory: URL) throws -> [ReadOnlyMediaFile] { + guard + let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey, .isPackageKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) + else { + throw WorkflowFailure(message: "无法读取照片目录。") + } + let urls = enumerator.compactMap { $0 as? URL } return try urls - .filter { ReadOnlyRawFile.supportedExtensions.contains($0.pathExtension.lowercased()) } + .filter { ReadOnlyMediaFile.supportedExtensions.contains($0.pathExtension.lowercased()) } .sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending } - .map(ReadOnlyRawFile.init(url:)) + .map(ReadOnlyMediaFile.init(url:)) } - private static func makeCaptures( - metadata: [RawPhotoMetadata], + private static func readMediaMetadataInBatches( + client: ExifToolClient, + files: [ReadOnlyMediaFile], + batchSize: Int, + progress: (Int, Int) -> Void + ) async throws -> (metadata: [MediaMetadata], warnings: [String]) { + precondition(batchSize > 0) + var result: [MediaMetadata] = [] + var warnings: [String] = [] + result.reserveCapacity(files.count) + var start = 0 + while start < files.count { + try Task.checkCancellation() + let end = min(start + batchSize, files.count) + let batch = try await readMediaMetadataResilient( + client: client, + files: Array(files[start.. (metadata: [MediaMetadata], warnings: [String]) { + do { + return (try await client.readMediaMetadata(files), []) + } catch { + try Task.checkCancellation() + if let metadataError = error as? MetadataInfrastructureError, + case .cancelled = metadataError + { + throw CancellationError() + } + guard files.count > 1 else { + let name = files.first?.url.lastPathComponent ?? "未知文件" + return ([], ["\(name) 的元数据读取失败,已跳过该文件"]) + } + let middle = files.count / 2 + let left = try await readMediaMetadataResilient( + client: client, + files: Array(files[.. [URL] { + guard + let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey, .isPackageKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) + else { + throw WorkflowFailure(message: "无法读取 GPX 目录。") + } + return enumerator.compactMap { element -> URL? in + guard let url = element as? URL, url.pathExtension.lowercased() == "gpx" else { + return nil + } + guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]), + values.isRegularFile == true, values.isSymbolicLink != true + else { return nil } + return url.standardizedFileURL + } + .sorted { $0.path.localizedStandardCompare($1.path) == .orderedAscending } + } + + private struct PreparedAsset: Sendable { + let asset: CaptureAsset + let metadata: MediaMetadata + let rawFile: ReadOnlyRawFile? + } + + private struct AssetBuild: Sendable { + let assets: [PreparedAsset] + let observations: [AssetLocationObservation] + let relations: [AssetRelation] + let clockReferences: [ClockReferenceObservation] + let warnings: [String] + } + + private struct TrackBuild: Sendable { + let sources: [TrajectoryLogicalSource] + let sourceDigests: [String: String] + let warnings: [String] + } + + private static func makeAssets( + metadata: [MediaMetadata], + photoRootURL: URL, timeZoneIdentifier: String, - cameraClockDelta: TimeInterval - ) throws -> [(capture: PhotoCapture, file: ReadOnlyRawFile)] { - try metadata.map { item in + globalCameraClockDelta: TimeInterval, + cameraClockOffsetsByID: [String: Int] + ) -> AssetBuild { + var prepared: [PreparedAsset] = [] + var observations: [AssetLocationObservation] = [] + var clockReferences: [ClockReferenceObservation] = [] + var warnings: [String] = [] + + for item in metadata { guard let original = item.dateTimeOriginal else { - throw WorkflowFailure(message: "\(item.rawFile.url.lastPathComponent) 缺少 DateTimeOriginal。") + warnings.append("\(item.mediaFile.url.lastPathComponent) 缺少 DateTimeOriginal") + continue + } + do { + let camera = cameraIdentity(for: item) + let cameraDelta = TimeInterval( + cameraClockOffsetsByID[camera.id.rawValue] + ?? Int(globalCameraClockDelta.rounded()) + ) + let timestamp = try parsePhotoTimestamp( + original, + subsecond: item.subsecondTimeOriginal, + offset: item.offsetTimeOriginal + ) + let captureUTC = try TimeNormalizer().normalize( + timestamp, + timeZoneIdentifier: timeZoneIdentifier, + cameraClockDelta: cameraDelta + ) + let activityID = activityID( + for: item.mediaFile.url, + root: photoRootURL, + captureUTC: captureUTC, + timeZoneIdentifier: timeZoneIdentifier + ) + let assetID = CaptureAssetID(rawValue: item.mediaFile.url.path) + let asset = CaptureAsset( + id: assetID, + relativePath: relativePath(of: item.mediaFile.url, under: photoRootURL), + kind: assetKind(for: item), + activityID: activityID, + camera: camera, + captureTimeUTC: captureUTC, + captureTimePrecision: hasSubsecond(item.subsecondTimeOriginal) ? .subsecond : .second, + sequenceNumber: sequenceNumber(from: item.mediaFile.url), + shutterCount: item.shutterCount + ) + let rawFile = try? ReadOnlyRawFile(mediaFile: item.mediaFile) + prepared.append(PreparedAsset(asset: asset, metadata: item, rawFile: rawFile)) + + if let gps = item.gps { + let gpsTimestamp = parseGPSTimestamp(item.gpsDateTime) + let observationKind = observationKind(for: item) + let observation = AssetLocationObservation( + id: "metadata:\(assetID.rawValue)", + assetID: assetID, + coordinate: RawGeoCore.GeoCoordinate( + latitude: gps.latitude, + longitude: gps.longitude + ), + elevationMeters: gps.altitude, + observedAtUTC: captureUTC, + gpsTimestampUTC: gpsTimestamp, + horizontalAccuracyMeters: item.gpsHorizontalPositioningError, + kind: observationKind, + isCircular: observationKind == .renderedDerivative + ) + observations.append(observation) + + // 只有直接传感器且 fix 与快门接近时,才足以形成时钟“建议”; + // Nikon 的 GPSDateTime 常是陈旧 fix,不能把 fix age 误当时钟偏差。 + if observationKind == .directSensor, + let gpsTimestamp, + abs(captureUTC.timeIntervalSince(gpsTimestamp)) <= 60 + { + clockReferences.append( + ClockReferenceObservation( + id: observation.id, + assetID: assetID, + cameraID: camera.id, + cameraCaptureTimeUTC: captureUTC, + referenceTimeUTC: gpsTimestamp, + kind: .directGPS, + referenceAccuracySeconds: item.gpsHorizontalPositioningError + ) + ) + } + } + } catch { + warnings.append("\(item.mediaFile.url.lastPathComponent) 的拍摄时间无法解析") + } + } + + let sorted = prepared.sorted { + if $0.asset.captureTimeUTC != $1.asset.captureTimeUTC { + return $0.asset.captureTimeUTC < $1.asset.captureTimeUTC + } + return $0.asset.id.rawValue < $1.asset.id.rawValue + } + return AssetBuild( + assets: sorted, + observations: observations.sorted { $0.id < $1.id }, + relations: makeAssetRelations(sorted), + clockReferences: clockReferences.sorted { $0.id < $1.id }, + warnings: warnings + ) + } + + private static func addingSidecarEvidence( + to build: AssetBuild, + client: ExifToolClient + ) async throws -> AssetBuild { + var observations = build.observations + var warnings = build.warnings + let sidecarsByRawURL = try makeSidecarIndex( + for: build.assets.compactMap(\.rawFile), + warnings: &warnings + ) + for prepared in build.assets { + try Task.checkCancellation() + guard let rawFile = prepared.rawFile else { continue } + do { + guard let sidecar = sidecarsByRawURL[rawFile.url.standardizedFileURL] else { continue } + let metadata = try await client.readSidecarMetadata(at: sidecar) + guard let gps = metadata.gps else { continue } + observations.append( + AssetLocationObservation( + id: "sidecar:\(prepared.asset.id.rawValue)", + assetID: prepared.asset.id, + coordinate: RawGeoCore.GeoCoordinate( + latitude: gps.latitude, + longitude: gps.longitude + ), + elevationMeters: gps.altitude, + observedAtUTC: prepared.asset.captureTimeUTC, + kind: .sidecar, + // Sidecar 可能来自 RawGeoSync 自己的上一轮推断;缺少独立来源证明时 + // 只能作为循环、待确认的候选,不能压过 GPX 或传感器锚点。 + isCircular: true + ) + ) + } catch let metadataError as MetadataInfrastructureError { + if case .cancelled = metadataError { throw CancellationError() } + warnings.append("\(rawFile.url.lastPathComponent) 的相邻 XMP 无法作为证据读取") + } catch { + warnings.append("\(rawFile.url.lastPathComponent) 的相邻 XMP 无法作为证据读取") } - let timestamp = try parsePhotoTimestamp( - original, - subsecond: item.subsecondTimeOriginal, - offset: item.offsetTimeOriginal + } + return AssetBuild( + assets: build.assets, + observations: observations.sorted { $0.id < $1.id }, + relations: build.relations, + clockReferences: build.clockReferences, + warnings: warnings + ) + } + + private static func makeSidecarIndex( + for rawFiles: [ReadOnlyRawFile], + warnings: inout [String] + ) throws -> [URL: SidecarURL] { + let fileManager = FileManager.default + let rawFilesByDirectory = Dictionary(grouping: rawFiles) { + $0.url.deletingLastPathComponent().standardizedFileURL + } + var result: [URL: SidecarURL] = [:] + + for directory in rawFilesByDirectory.keys.sorted(by: { $0.path < $1.path }) { + try Task.checkCancellation() + let entries = try fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], + options: [.skipsHiddenFiles] ) - let utc = try TimeNormalizer().normalize( - timestamp, - timeZoneIdentifier: timeZoneIdentifier, - cameraClockDelta: cameraClockDelta + let sidecarsByStem = Dictionary( + grouping: entries.filter { + $0.pathExtension.caseInsensitiveCompare("xmp") == .orderedSame + } + ) { + $0.deletingPathExtension().lastPathComponent.lowercased() + } + + for rawFile in rawFilesByDirectory[directory, default: []] { + let stem = rawFile.url.deletingPathExtension().lastPathComponent.lowercased() + let candidates = sidecarsByStem[stem, default: []] + guard candidates.count <= 1 else { + warnings.append("\(rawFile.url.lastPathComponent) 同时存在多个大小写不同的 XMP,已跳过") + continue + } + guard let candidate = candidates.first else { continue } + do { + result[rawFile.url.standardizedFileURL] = try SidecarURL(existingURL: candidate) + } catch { + warnings.append("\(rawFile.url.lastPathComponent) 的相邻 XMP 不是安全的普通文件,已跳过") + } + } + } + return result + } + + private static func makeTrajectorySources(gpxFiles: [URL]) throws -> TrackBuild { + let normalizer = v2TrackNormalizer() + var sources: [TrajectoryLogicalSource] = [] + var sourceDigests: [String: String] = [:] + var warnings: [String] = [] + for (index, url) in gpxFiles.enumerated() { + let document = try GPXStreamParser().parse(url: url) + let track = normalizer.normalize(document) + if !document.warnings.isEmpty || !track.warnings.isEmpty { + warnings.append( + "\(url.lastPathComponent):解析警告 \(document.warnings.count) 项,轨迹规范化警告 \(track.warnings.count) 项" + ) + } + let sourceID = TrajectorySourceID(rawValue: "gpx-\(index)-\(url.lastPathComponent)") + sources.append( + TrajectoryLogicalSource( + id: sourceID, + displayName: url.lastPathComponent, + kind: .gpx, + priority: 100, + track: track + ) ) - return ( - PhotoCapture(id: item.rawFile.url.path, captureTimeUTC: utc), - item.rawFile + sourceDigests[sourceID.rawValue] = try sha256(of: url) + } + return TrackBuild(sources: sources, sourceDigests: sourceDigests, warnings: warnings) + } + + private static func sha256(of url: URL) throws -> String { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + var hasher = SHA256() + while true { + let data = handle.readData(ofLength: 256 * 1_024) + guard !data.isEmpty else { break } + hasher.update(data: data) + } + return hasher.finalize().map { String(format: "%02x", $0) }.joined() + } + + private static func v2TrackNormalizer() -> TrackNormalizer { + TrackNormalizer( + configuration: TrackNormalizationConfiguration( + duplicateCoordinateToleranceMeters: 30, + reliableMaximumDurationSeconds: 60, + reliableMaximumDistanceMeters: 250, + reviewMaximumDurationSeconds: 600, + stayMinimumDurationSeconds: 600, + stayMaximumDurationSeconds: 21_600, + stayMaximumDisplacementMeters: 150, + maximumImpliedSpeedMetersPerSecond: 500, + spikeMaximumLegDurationSeconds: 120, + spikeMinimumLegDistanceMeters: 500, + spikeMaximumDirectDistanceMeters: 100, + spikeMaximumDirectDistanceRatio: 0.1 ) + ) + } + + private static func cameraIdentity(for metadata: MediaMetadata) -> CameraIdentity { + let optionalComponents: [String?] = [ + metadata.make, + metadata.model, + metadata.serialNumber ?? metadata.internalSerialNumber, + ] + let components = optionalComponents.compactMap { $0 } + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + let fallback = metadata.mediaFile.url.deletingLastPathComponent().lastPathComponent + let id = components.isEmpty ? fallback : components.joined(separator: "|") + return CameraIdentity( + id: CameraID(rawValue: id), + make: metadata.make, + model: metadata.model, + serialNumber: metadata.serialNumber, + internalSerialNumber: metadata.internalSerialNumber + ) + } + + private static func assetKind(for metadata: MediaMetadata) -> CaptureAssetKind { + switch metadata.mediaFile.kind { + case .proprietaryRaw: + return .raw + case .dng: + let model = metadata.model?.lowercased() ?? "" + let isOriginalPhone = model.contains("iphone") && metadata.derivedFrom == nil + return isOriginalPhone ? .originalDNG : .derivedDNG + case .jpeg, .tiff: + return .rendered + } + } + + private static func observationKind(for metadata: MediaMetadata) + -> AssetLocationObservationKind + { + switch metadata.mediaFile.kind { + case .jpeg, .tiff: + return .renderedDerivative + case .dng where metadata.model?.localizedCaseInsensitiveContains("iPhone") == true: + return .directSensor + case .dng, .proprietaryRaw: + return .cameraEmbedded + } + } + + private struct SameAssetKey: Hashable { + let activityID: ActivityID? + let cameraID: CameraID? + let captureSecond: Int64 + let token: String + } + + private static func makeAssetRelations(_ assets: [PreparedAsset]) -> [AssetRelation] { + let groups = Dictionary(grouping: assets) { prepared in + SameAssetKey( + activityID: prepared.asset.activityID, + cameraID: prepared.asset.camera?.id, + captureSecond: Int64(prepared.asset.captureTimeUTC.timeIntervalSince1970.rounded(.down)), + token: assetToken(for: prepared.metadata.mediaFile.url) + ) + } + var relations: [AssetRelation] = [] + for group in groups.values { + let targets = group.filter { $0.rawFile != nil } + let evidenceAssets = group.filter { $0.rawFile == nil } + for target in targets { + for evidence in evidenceAssets where evidence.asset.id != target.asset.id { + relations.append( + AssetRelation( + id: "same:\(evidence.asset.id.rawValue)>\(target.asset.id.rawValue)", + sourceAssetID: evidence.asset.id, + targetAssetID: target.asset.id, + kind: .derivedFrom + ) + ) + } + } } + return relations.sorted { $0.id < $1.id } + } + + private static func makeActivityRegions( + assets: [CaptureAsset], + sources: [TrajectoryLogicalSource], + timeZone: TimeZone + ) -> [ActivityRegion] { + let points = sources.flatMap { $0.track.segments.flatMap(\.points) } + .sorted { $0.timestamp < $1.timestamp } + let grouped = Dictionary( + grouping: assets.compactMap { asset -> CaptureAsset? in + asset.activityID == nil ? nil : asset + }, by: { $0.activityID! }) + var regions: [ActivityRegion] = [] + + for activityID in grouped.keys.sorted(by: { $0.rawValue < $1.rawValue }) { + let activityAssets = grouped[activityID, default: []].sorted { + if $0.captureTimeUTC != $1.captureTimeUTC { + return $0.captureTimeUTC < $1.captureTimeUTC + } + return $0.id.rawValue < $1.id.rawValue + } + let sessions = photoSessions(activityAssets, maximumGapSeconds: 30 * 60) + for (sessionIndex, session) in sessions.enumerated() { + guard let minimum = session.first?.captureTimeUTC, + let maximum = session.last?.captureTimeUTC + else { continue } + let lower = minimum.addingTimeInterval(-3_600) + let upper = maximum.addingTimeInterval(3_600) + let nearby = points.filter { $0.timestamp >= lower && $0.timestamp <= upper } + var representative = representativeRegion(from: nearby) + var label = "照片会话前后 1 小时 GPX 的稳健代表位置" + + if representative == nil || representative!.radiusMeters > 150_000 { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + let sameDayGroundPoints = points.filter { point in + calendar.isDate(point.timestamp, inSameDayAs: minimum) + && point.speedMetersPerSecond.map { $0 < 80 } != false + } + let midpoint = minimum.addingTimeInterval( + maximum.timeIntervalSince(minimum) / 2) + if let nearest = sameDayGroundPoints.min(by: { + abs($0.timestamp.timeIntervalSince(midpoint)) + < abs($1.timestamp.timeIntervalSince(midpoint)) + }) { + let localCluster = sameDayGroundPoints.filter { + RawGeoCore.GeoMath.distance(from: nearest.coordinate, to: $0.coordinate) <= 50_000 + } + if let fallback = representativeRegion(from: localCluster) { + representative = ( + fallback.coordinate, + max(10_000, fallback.radiusMeters) + ) + label = "同一当地日、时间最近的非飞行城市定位簇(粗略)" + } + } + } + + if representative == nil, + let before = points.last(where: { $0.timestamp < minimum }), + let after = points.first(where: { $0.timestamp > maximum }), + minimum.timeIntervalSince(before.timestamp) <= 36 * 3_600, + after.timestamp.timeIntervalSince(maximum) <= 36 * 3_600, + RawGeoCore.GeoMath.distance(from: before.coordinate, to: after.coordinate) <= 1_000, + let fallback = representativeRegion(from: [before, after]) + { + representative = (fallback.coordinate, max(10_000, fallback.radiusMeters)) + label = "前后相邻日同地点定位簇(粗略)" + } + + guard let representative, representative.radiusMeters <= 150_000 else { continue } + regions.append( + ActivityRegion( + id: "region:\(activityID.rawValue):session-\(sessionIndex)", + activityID: activityID, + coordinate: representative.coordinate, + radiusMeters: representative.radiusMeters, + source: .learned, + label: label, + activeFromUTC: minimum, + activeToUTC: maximum + ) + ) + } + } + return regions + } + + private static func photoSessions( + _ sortedAssets: [CaptureAsset], + maximumGapSeconds: TimeInterval + ) -> [[CaptureAsset]] { + var sessions: [[CaptureAsset]] = [] + for asset in sortedAssets { + if let previous = sessions.last?.last, + asset.captureTimeUTC.timeIntervalSince(previous.captureTimeUTC) <= maximumGapSeconds + { + sessions[sessions.count - 1].append(asset) + } else { + sessions.append([asset]) + } + } + return sessions + } + + private static func representativeRegion(from points: [TrackPoint]) + -> (coordinate: RawGeoCore.GeoCoordinate, radiusMeters: Double)? + { + guard !points.isEmpty else { return nil } + let latitudes = points.map(\.coordinate.latitude).sorted() + let longitudes = points.map(\.coordinate.longitude).sorted() + let center = RawGeoCore.GeoCoordinate( + latitude: median(latitudes), + longitude: median(longitudes) + ) + let representative = + points.min { + RawGeoCore.GeoMath.distance(from: $0.coordinate, to: center) + < RawGeoCore.GeoMath.distance(from: $1.coordinate, to: center) + }?.coordinate ?? center + let distances = points.map { + RawGeoCore.GeoMath.distance(from: representative, to: $0.coordinate) + }.sorted() + let p90Index = min(distances.count - 1, Int((Double(distances.count - 1) * 0.9).rounded())) + return (representative, max(1, distances[p90Index])) + } + + private static func median(_ sorted: [Double]) -> Double { + guard !sorted.isEmpty else { return 0 } + let middle = sorted.count / 2 + return sorted.count.isMultiple(of: 2) + ? (sorted[middle - 1] + sorted[middle]) / 2 + : sorted[middle] + } + + private static func makeClockSuggestions(from build: AssetBuild) -> [ClockSuggestionInfo] { + var labels: [CameraID: String] = [:] + for prepared in build.assets { + guard let camera = prepared.asset.camera else { continue } + labels[camera.id] = camera.model ?? camera.id.rawValue + } + return ClockSuggestionEngine().suggest(from: build.clockReferences).compactMap { suggestion in + let seconds = Int(suggestion.cameraAheadBySeconds.rounded()) + guard abs(seconds) >= 2 else { return nil } + return ClockSuggestionInfo( + cameraID: suggestion.cameraID.rawValue, + cameraLabel: labels[suggestion.cameraID] ?? suggestion.cameraID.rawValue, + cameraAheadBySeconds: seconds, + evidenceCount: suggestion.evidenceCount, + residualSeconds: suggestion.medianAbsoluteResidualSeconds, + confidenceLabel: suggestion.confidence.rawValue + ) + } + } + + private static func activityID( + for fileURL: URL, + root: URL, + captureUTC: Date, + timeZoneIdentifier: String + ) -> ActivityID { + let relative = relativePath(of: fileURL, under: root) + let components = relative.split(separator: "/").map(String.init) + let rootName = root.lastPathComponent + let base: String + if looksLikeActivityFolder(rootName) { + base = rootName + } else if let first = components.first, components.count > 1 { + base = first + } else { + let parentName = root.deletingLastPathComponent().lastPathComponent + base = looksLikeActivityFolder(parentName) ? parentName : rootName + } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.calendar = Calendar(identifier: .gregorian) + formatter.timeZone = TimeZone(identifier: timeZoneIdentifier) ?? .gmt + formatter.dateFormat = "yyyy-MM-dd" + return ActivityID(rawValue: "\(base)|\(formatter.string(from: captureUTC))") + } + + private static func looksLikeActivityFolder(_ name: String) -> Bool { + name.range(of: #"^20\d{2}[-.]\d{1,2}[-.]\d{1,2}"#, options: .regularExpression) != nil + } + + private static func relativePath(of fileURL: URL, under root: URL) -> String { + let rootPath = root.standardizedFileURL.path.trimmingCharacters( + in: CharacterSet(charactersIn: "/")) + let filePath = fileURL.standardizedFileURL.path + let prefix = "/\(rootPath)/" + if filePath.hasPrefix(prefix) { + return String(filePath.dropFirst(prefix.count)) + } + return fileURL.lastPathComponent + } + + private static func hasSubsecond(_ value: String?) -> Bool { + value?.contains(where: \Character.isNumber) == true + } + + private static func sequenceNumber(from url: URL) -> Int? { + let stem = url.deletingPathExtension().lastPathComponent + guard let match = stem.range(of: #"\d{3,}$"#, options: .regularExpression) else { + return nil + } + return Int(stem[match]) + } + + private static func assetToken(for url: URL) -> String { + let stem = url.deletingPathExtension().lastPathComponent.uppercased() + if let match = stem.range( + of: #"(?:DSC|IMG|DSCF|DSCN|NZ5|NZ50|A6400|A7CII)[_-]?\d{3,}"#, + options: .regularExpression + ), + let digits = stem[match].range(of: #"\d{3,}$"#, options: .regularExpression) + { + return "SEQUENCE-\(stem[match][digits])" + } + return stem + } + + private static func parseGPSTimestamp(_ value: String?) -> Date? { + guard let value else { return nil } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.calendar = Calendar(identifier: .gregorian) + formatter.timeZone = .gmt + formatter.dateFormat = "yyyy:MM:dd HH:mm:ssXXXXX" + if let date = formatter.date(from: value) { return date } + formatter.dateFormat = "yyyy:MM:dd HH:mm:ss'Z'" + return formatter.date(from: value) + } + + private static func combinedDocument(_ documents: [GPXDocument]) -> GPXDocument { + var segments: [GPXTrackSegment] = [] + var warnings: [GPXWarning] = [] + for (documentIndex, document) in documents.enumerated() { + warnings.append(contentsOf: document.warnings) + for segment in document.segments { + let trackIndex = documentIndex * 100_000 + segment.trackIndex + let points = segment.points.map { point in + TrackPoint( + timestamp: point.timestamp, + coordinate: point.coordinate, + elevationMeters: point.elevationMeters, + horizontalAccuracyMeters: point.horizontalAccuracyMeters, + speedMetersPerSecond: point.speedMetersPerSecond, + courseDegrees: point.courseDegrees, + source: TrackPointSource( + trackIndex: trackIndex, + segmentIndex: segment.segmentIndex, + pointIndex: point.source.pointIndex + ) + ) + } + segments.append( + GPXTrackSegment( + trackIndex: trackIndex, + segmentIndex: segment.segmentIndex, + points: points + ) + ) + } + } + return GPXDocument( + version: "combined", creator: "RawGeoSync", segments: segments, warnings: warnings) + } + + private static func makeCaptures( + metadata: [RawPhotoMetadata], + timeZoneIdentifier: String, + cameraClockDelta: TimeInterval + ) -> ( + captures: [(capture: PhotoCapture, file: ReadOnlyRawFile)], warnings: [String] + ) { + var captures: [(capture: PhotoCapture, file: ReadOnlyRawFile)] = [] + var warnings: [String] = [] + for item in metadata { + guard let original = item.dateTimeOriginal else { + warnings.append("\(item.rawFile.url.lastPathComponent) 缺少 DateTimeOriginal") + continue + } + do { + let timestamp = try parsePhotoTimestamp( + original, + subsecond: item.subsecondTimeOriginal, + offset: item.offsetTimeOriginal + ) + let utc = try TimeNormalizer().normalize( + timestamp, + timeZoneIdentifier: timeZoneIdentifier, + cameraClockDelta: cameraClockDelta + ) + captures.append( + ( + PhotoCapture(id: item.rawFile.url.path, captureTimeUTC: utc), + item.rawFile + ) + ) + } catch { + warnings.append("\(item.rawFile.url.lastPathComponent) 的拍摄时间无法解析") + } + } + return (captures, warnings) } private static func parsePhotoTimestamp( @@ -341,6 +1139,199 @@ struct LiveGeoWorkflowService: GeoWorkflowServicing { return sign * (numbers[0] * 3_600 + numbers[1] * 60) } + private static func makePhotoMatchV2( + resolution: LocationResolution, + prepared: PreparedAsset, + file: ReadOnlyRawFile, + strategy: MatchingStrategy, + writeAltitude: Bool, + trackSourceDigests: [String: String] + ) -> PhotoMatch { + let coverageFallback = + strategy == .coverage && resolution.selectedCandidate == nil + ? resolution.candidates.first(where: { $0.sourceKind == .activityRegion }) : nil + let candidate = resolution.selectedCandidate ?? coverageFallback + let usesConflictRegionFallback = coverageFallback != nil + let evidenceCoordinates = candidate?.evidence.compactMap(\.coordinate) ?? [] + let previous = evidenceCoordinates.first.map(appCoordinate) + let next = evidenceCoordinates.dropFirst().first.map(appCoordinate) + let allowAltitude = + candidate.map { + writeAltitude && ($0.granularity == .exact || $0.granularity == .precise) + } ?? false + let coordinate = candidate.map { + GeoCoordinate( + latitude: $0.coordinate.latitude, + longitude: $0.coordinate.longitude, + altitude: allowAltitude ? $0.elevationMeters : nil + ) + } + let confidence = + usesConflictRegionFallback + ? MatchConfidence.coarse + : appConfidence(resolution: resolution, candidate: candidate) + let method = candidate.map { appMethod($0.sourceKind) } ?? .unavailable + let granularity = + candidate.map { appGranularity($0.sourceKind, $0.granularity) } + ?? .unavailable + let actualAccuracy = candidate?.evidence.compactMap(\.horizontalAccuracyMeters).min() + let sourceAccuracy: SourceLocationAccuracy = + actualAccuracy.map(SourceLocationAccuracy.meters) + ?? .notProvided + let evidenceTimes = candidate?.evidence.compactMap(\.observedAtUTC).sorted() ?? [] + let trackFileSHA256 = candidate?.evidence.compactMap { evidence in + evidence.sourceID.flatMap { trackSourceDigests[$0] } + }.first + let temporalDistance = evidenceTimes.map { + abs($0.timeIntervalSince(prepared.asset.captureTimeUTC)) + }.min() + let xmpURL = file.url.deletingPathExtension().appendingPathExtension("xmp") + let hasAdjacentXMP = FileManager.default.fileExists(atPath: xmpURL.path) + let hasExistingGPS = prepared.metadata.gps != nil || hasAdjacentXMP + let shouldAutomaticallyCheck = + confidence == .reliable && coordinate != nil && !hasExistingGPS + let evidenceSummary = + candidate.map { selected in + let kinds = Set(selected.evidence.map(\.kind.rawValue)).sorted().joined(separator: "+") + return "\(selected.ruleID) · \(kinds)" + } ?? resolution.reasons.map(\.rawValue).joined(separator: " · ") + let confirmationGroupID = candidate.flatMap { selected -> String? in + guard selected.requiresConfirmation else { return nil } + let latitude = (selected.coordinate.latitude * 10_000).rounded() / 10_000 + let longitude = (selected.coordinate.longitude * 10_000).rounded() / 10_000 + return "\(selected.ruleID)|\(latitude)|\(longitude)" + } + + return PhotoMatch( + id: prepared.asset.id.rawValue, + fileURL: file.url, + capturedAt: prepared.asset.captureTimeUTC, + previousTrackPoint: previous, + nextTrackPoint: next, + coordinate: coordinate, + confidence: confidence, + method: method, + granularity: granularity, + sourceLocationAccuracy: sourceAccuracy, + evidenceSummary: evidenceSummary, + supportSpreadMeters: candidate?.estimatedRadiusMeters, + confirmationGroupID: confirmationGroupID, + ruleVersion: resolution.ruleVersion, + sourceTimeLowerBound: evidenceTimes.first, + sourceTimeUpperBound: evidenceTimes.last, + temporalDistanceSeconds: temporalDistance, + trackFileSHA256: trackFileSHA256, + note: matchNote( + resolution: resolution, + candidate: candidate, + usesConflictRegionFallback: usesConflictRegionFallback, + hasExistingGPS: hasExistingGPS, + hasAdjacentXMP: hasAdjacentXMP + ), + isSelectedForWrite: shouldAutomaticallyCheck, + isWritableTarget: true, + hasExistingGPS: hasExistingGPS, + hasProtectedExternalXMP: false + ) + } + + private static func appConfidence( + resolution: LocationResolution, + candidate: LocationCandidate? + ) -> MatchConfidence { + guard let candidate else { return .unmatched } + switch resolution.status { + case .conflict, .unresolved: + return .unmatched + case .review: + return candidate.granularity == .veryCoarse || candidate.sourceKind == .activityRegion + ? .coarse : .review + case .resolved: + return candidate.granularity == .veryCoarse ? .coarse : .reliable + } + } + + private static func appGranularity( + _ source: LocationSourceKind, + _ granularity: LocationGranularity + ) -> SpatialGranularity { + switch source { + case .manualOverride: return .manual + case .directSensor, .embeddedFreshFix: return .sensor + case .gpxExact, .gpxInterpolated, .embeddedTrackFix: return .track + case .sameAsset, .burstPropagation, .sequencePropagation, .stationaryBounded, + .crossCamera: + return .photoCluster + case .activityRegion: + return granularity == .veryCoarse ? .region : .activity + } + } + + private static func appMethod(_ source: LocationSourceKind) -> MatchMethod { + switch source { + case .manualOverride: .manual + case .directSensor, .embeddedFreshFix: .directSensor + case .sameAsset: .sameAsset + case .gpxExact: .exact + case .gpxInterpolated: .interpolated + case .embeddedTrackFix: .auxiliaryFix + case .burstPropagation: .burstPropagation + case .sequencePropagation: .sequencePropagation + case .stationaryBounded: .stationary + case .crossCamera: .crossCamera + case .activityRegion: .activityRepresentative + } + } + + private static func matchNote( + resolution: LocationResolution, + candidate: LocationCandidate?, + usesConflictRegionFallback: Bool = false, + hasExistingGPS: Bool, + hasAdjacentXMP: Bool + ) -> String { + if usesConflictRegionFallback { + return "多个强来源位置相冲突;已提供活动区域粗略兜底,必须手动确认" + } + if resolution.status == .conflict { + return "多个强来源位置相冲突,禁止自动写入" + } + guard let candidate else { return "没有足够证据形成位置候选" } + var components: [String] = [] + if candidate.requiresConfirmation { + components.append("该候选需要确认") + } else { + components.append("已按来源优先级自动选择") + } + if let radius = candidate.estimatedRadiusMeters { + components.append("证据覆盖范围约 \(Int(radius.rounded())) 米(不是传感器精度)") + } + if hasAdjacentXMP { + components.append("相邻 XMP 将在写入前做来源与摘要保护") + } else if hasExistingGPS { + components.append("文件已有 GPS,默认不替换") + } + return components.joined(separator: ";") + } + + private static func visibleTrackCoordinatesV2( + sources: [TrajectoryLogicalSource], + captureDates: [Date] + ) -> [GeoCoordinate] { + guard let minimum = captureDates.min(), let maximum = captureDates.max() else { return [] } + let lower = minimum.addingTimeInterval(-6 * 3_600) + let upper = maximum.addingTimeInterval(6 * 3_600) + let all = sources.flatMap { $0.track.segments.flatMap(\.points) } + .filter { $0.timestamp >= lower && $0.timestamp <= upper } + .sorted { $0.timestamp < $1.timestamp } + .map(appCoordinate) + guard all.count > 5_000 else { return all } + let step = Int(ceil(Double(all.count) / 5_000.0)) + var result = stride(from: 0, to: all.count, by: step).map { all[$0] } + if let last = all.last, result.last != last { result.append(last) } + return result + } + private static func makePhotoMatch( result: PhotoMatchResult, file: ReadOnlyRawFile, @@ -370,7 +1361,7 @@ struct LiveGeoWorkflowService: GeoWorkflowServicing { case .unknown: sourceAccuracy = .notProvided } return PhotoMatch( - id: UUID(), + id: result.photo.id, fileURL: file.url, capturedAt: result.photo.captureTimeUTC, previousTrackPoint: previous, @@ -378,10 +1369,21 @@ struct LiveGeoWorkflowService: GeoWorkflowServicing { coordinate: coordinate, confidence: existingGPS ? .review : confidence, method: appMethod(result.mode), + granularity: result.coordinate == nil ? .unavailable : .track, sourceLocationAccuracy: sourceAccuracy, + evidenceSummary: result.reasonCodes.map { String(describing: $0) }.joined(separator: " · "), + supportSpreadMeters: firstCandidate.map { + guard let end = $0.endPoint else { return 0 } + return RawGeoCore.GeoMath.distance(from: $0.startPoint.coordinate, to: end.coordinate) + }, + confirmationGroupID: result.mode == .stayCandidate + ? "stay-\(firstCandidate?.segmentID ?? -1)-\(firstCandidate?.startPoint.timestamp.timeIntervalSince1970 ?? 0)" + : nil, note: existingGPS ? "文件已有 GPS,默认跳过;重新勾选写入即表示明确替换" : note(result), isSelectedForWrite: confidence == .reliable && !existingGPS, - hasExistingGPS: existingGPS + isWritableTarget: true, + hasExistingGPS: existingGPS, + hasProtectedExternalXMP: false ) } @@ -393,6 +1395,10 @@ struct LiveGeoWorkflowService: GeoWorkflowServicing { ) } + private static func appCoordinate(_ coordinate: RawGeoCore.GeoCoordinate) -> GeoCoordinate { + GeoCoordinate(latitude: coordinate.latitude, longitude: coordinate.longitude, altitude: nil) + } + private static func appMethod(_ mode: MatchMode) -> MatchMethod { switch mode { case .exact: .exact @@ -447,14 +1453,54 @@ struct LiveGeoWorkflowService: GeoWorkflowServicing { longitude: coordinate.longitude, altitude: configuration.writeAltitude ? coordinate.altitude : nil ) + let policy: ExistingGPSPolicy + if match.hasExistingGPS { + policy = match.replacementExplicitlyAuthorized ? .replace : .replaceIfStrongerProvenance + } else { + policy = .skip + } return SidecarWriteRequest( rawFile: raw, gps: gps, - existingGPSPolicy: match.hasExistingGPS ? .replace : .skip + existingGPSPolicy: policy, + matchProvenance: MatchProvenance( + source: provenanceSource(for: match.method), + verification: provenanceVerification(for: match), + algorithmVersion: match.ruleVersion, + trackFileSHA256: match.trackFileSHA256, + sourceTimeLowerBound: match.sourceTimeLowerBound, + sourceTimeUpperBound: match.sourceTimeUpperBound, + temporalDistanceSeconds: match.temporalDistanceSeconds, + horizontalAccuracyMeters: match.sourceLocationAccuracy.meters + ) ) } } + private static func provenanceSource(for method: MatchMethod) -> MatchProvenanceSource { + switch method { + case .manual: .manual + case .exact, .directSensor, .sameAsset: .exactTrackPoint + case .interpolated, .reviewInterpolation, .auxiliaryFix, .burstPropagation, + .sequencePropagation, .crossCamera: + .interpolatedTrack + case .stationary, .activityRepresentative, .regionRepresentative, .previousPoint, + .nextPoint, .midpoint: + .stationaryCandidate + case .nearest, .unavailable: .nearestTrackPoint + } + } + + private static func provenanceVerification(for match: PhotoMatch) + -> MatchProvenanceVerification + { + if match.method == .manual { return .manual } + if match.replacementExplicitlyAuthorized || match.confidence != .reliable { + return .userConfirmed + } + return .automatic + } + private static func defaultExifToolScriptURL() -> URL { if let bundled = Bundle.main.url( forResource: "exiftool", diff --git a/RawGeoSyncApp/ViewModels/WorkspaceViewModel.swift b/RawGeoSyncApp/ViewModels/WorkspaceViewModel.swift index 430e9b4..3def530 100644 --- a/RawGeoSyncApp/ViewModels/WorkspaceViewModel.swift +++ b/RawGeoSyncApp/ViewModels/WorkspaceViewModel.swift @@ -6,6 +6,8 @@ final class WorkspaceViewModel: ObservableObject { @Published var configuration = SourceConfiguration() @Published var matches: [PhotoMatch] = [] @Published var trackCoordinates: [GeoCoordinate] = [] + @Published var analysisWarnings: [String] = [] + @Published var clockSuggestions: [ClockSuggestionInfo] = [] @Published var selectedMatches: Set = [] @Published var confidenceFilter: ConfidenceFilter = .all @Published var searchText = "" @@ -51,9 +53,13 @@ final class WorkspaceViewModel: ObservableObject { var reliableCount: Int { matches.count(where: { $0.confidence == .reliable }) } var reviewCount: Int { matches.count(where: { $0.confidence == .review }) } + var coarseCount: Int { matches.count(where: { $0.confidence == .coarse }) } var unmatchedCount: Int { matches.count(where: { $0.confidence == .unmatched }) } var writableCount: Int { - matches.count(where: { $0.isSelectedForWrite && $0.coordinate != nil }) + matches.count(where: { + $0.isSelectedForWrite && $0.coordinate != nil && $0.isWritableTarget + && !$0.hasProtectedExternalXMP + }) } var checkedPhotoCount: Int { @@ -61,16 +67,23 @@ final class WorkspaceViewModel: ObservableObject { } var areAllFilteredPhotosChecked: Bool { - !filteredMatches.isEmpty && filteredMatches.allSatisfy(\.isSelectedForWrite) + let eligible = filteredMatches.filter(\.isWritableTarget) + return !eligible.isEmpty && eligible.allSatisfy(\.isSelectedForWrite) } var canApply: Bool { writableCount > 0 && !isBusy } + var hasRelatedConfirmationGroup: Bool { + matches.contains { match in + selectedMatches.contains(match.id) && match.confirmationGroupID != nil + } + } + func analyze() { guard configuration.isReady else { - errorMessage = "请先选择 GPX 文件和 RAW 文件夹。" + errorMessage = "请先选择 GPX 目录和照片目录。" return } @@ -92,6 +105,8 @@ final class WorkspaceViewModel: ObservableObject { case .completed(let snapshot): matches = snapshot.matches trackCoordinates = snapshot.trackCoordinates + analysisWarnings = snapshot.warnings + clockSuggestions = snapshot.clockSuggestions selectedMatches = [] confidenceFilter = .all stage = .analysis @@ -228,7 +243,11 @@ final class WorkspaceViewModel: ObservableObject { matches[index].coordinate = coordinate matches[index].method = method matches[index].confidence = .review + matches[index].granularity = method == .manual ? .manual : .photoCluster matches[index].sourceLocationAccuracy = .notProvided + matches[index].evidenceSummary = + method == .manual ? "用户在地图上指定" : "用户采用轨迹端点" + matches[index].supportSpreadMeters = nil matches[index].note = method == .manual ? "由用户在地图上手工指定" : "由用户批量指定为\(method.title)" matches[index].isSelectedForWrite = true } @@ -241,14 +260,40 @@ final class WorkspaceViewModel: ObservableObject { isManualPlacementEnabled = false } + func selectRelatedConfirmationGroups() { + let groupIDs = Set( + matches.compactMap { match in + selectedMatches.contains(match.id) ? match.confirmationGroupID : nil + }) + guard !groupIDs.isEmpty else { return } + selectedMatches.formUnion( + matches.compactMap { match in + guard let groupID = match.confirmationGroupID, groupIDs.contains(groupID) else { + return nil + } + return match.id + }) + } + + func confirmSelectedGroups() { + selectRelatedConfirmationGroups() + for index in matches.indices where selectedMatches.contains(matches[index].id) { + guard matches[index].isWritableTarget, matches[index].coordinate != nil else { continue } + matches[index].isSelectedForWrite = true + matches[index].note = "已批量确认:\(matches[index].note)" + } + } + func toggleFilteredPhotoCheckmarks() { let visibleIDs = Set(filteredMatches.map(\.id)) let shouldSelect = !areAllFilteredPhotosChecked for index in matches.indices { guard visibleIDs.contains(matches[index].id) else { continue } + guard matches[index].isWritableTarget else { continue } if shouldSelect { matches[index].isSelectedForWrite = true if matches[index].hasExistingGPS { + matches[index].replacementExplicitlyAuthorized = true matches[index].note = "已通过全选明确授权用匹配位置替换现有 GPS" } } else { @@ -259,12 +304,19 @@ final class WorkspaceViewModel: ObservableObject { func setWriteSelection(_ selected: Bool, for id: PhotoMatch.ID) { guard let index = matches.firstIndex(where: { $0.id == id }) else { return } + guard matches[index].isWritableTarget else { return } matches[index].isSelectedForWrite = selected if selected, matches[index].hasExistingGPS { + matches[index].replacementExplicitlyAuthorized = true matches[index].note = "已明确授权用匹配位置替换现有 GPS" } } + func acceptClockSuggestion(_ suggestion: ClockSuggestionInfo) { + configuration.cameraClockOffsetsByID[suggestion.cameraID] = suggestion.cameraAheadBySeconds + analyze() + } + func cancelCurrentOperation() { operationTask?.cancel() } @@ -280,6 +332,8 @@ final class WorkspaceViewModel: ObservableObject { configuration = SourceConfiguration() matches = [] trackCoordinates = [] + analysisWarnings = [] + clockSuggestions = [] selectedMatches = [] report = nil writePreview = nil diff --git a/RawGeoSyncApp/Views/AnalysisWorkspaceView.swift b/RawGeoSyncApp/Views/AnalysisWorkspaceView.swift index 3959ee9..f1cfe70 100644 --- a/RawGeoSyncApp/Views/AnalysisWorkspaceView.swift +++ b/RawGeoSyncApp/Views/AnalysisWorkspaceView.swift @@ -7,6 +7,46 @@ struct AnalysisWorkspaceView: View { var body: some View { VStack(spacing: 0) { analysisToolbar + if !workspace.analysisWarnings.isEmpty { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text("分析中有 \(workspace.analysisWarnings.count) 项警告") + .font(.caption.weight(.medium)) + Text(workspace.analysisWarnings.prefix(2).joined(separator: ";")) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + Spacer() + } + .padding(.horizontal, 16) + .frame(height: 34) + .background(Color.orange.opacity(0.08)) + } + if !workspace.clockSuggestions.isEmpty { + ScrollView(.horizontal) { + HStack(spacing: 12) { + Label("检测到相机时钟校正建议", systemImage: "clock.badge.exclamationmark") + .font(.caption.weight(.semibold)) + ForEach(workspace.clockSuggestions) { suggestion in + HStack(spacing: 7) { + Text( + "\(suggestion.cameraLabel):相机快 \(suggestion.cameraAheadBySeconds) 秒 · \(suggestion.evidenceCount) 个独立依据" + ) + .font(.caption) + Button("采用并重新分析") { + workspace.acceptClockSuggestion(suggestion) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + .padding(.horizontal, 16) + } + .frame(height: 40) + .background(Color.blue.opacity(0.07)) + } Divider() summaryStrip Divider() @@ -64,7 +104,7 @@ struct AnalysisWorkspaceView: View { } } .pickerStyle(.segmented) - .frame(width: 300) + .frame(width: 390) TextField("搜索文件名", text: $workspace.searchText) .textFieldStyle(.roundedBorder) @@ -86,6 +126,20 @@ struct AnalysisWorkspaceView: View { Divider().frame(height: 20) + Button("选择同组") { + workspace.selectRelatedConfirmationGroups() + } + .disabled(!workspace.hasRelatedConfirmationGroup || workspace.isBusy) + .help("展开当前照片所属的停留、连拍或活动候选批次") + + Button("采用同组") { + workspace.confirmSelectedGroups() + } + .disabled(!workspace.hasRelatedConfirmationGroup || workspace.isBusy) + .help("为同一候选批次的全部可写照片打勾") + + Divider().frame(height: 20) + Button("清除批量选择") { workspace.clearSelection() } @@ -117,6 +171,12 @@ struct AnalysisWorkspaceView: View { systemImage: "exclamationmark.triangle.fill", tint: .orange ) + MetricCard( + title: "粗略区域", + value: "\(workspace.coarseCount)", + systemImage: "map.fill", + tint: .purple + ) MetricCard( title: "将跳过", value: "\(workspace.unmatchedCount)", @@ -161,6 +221,7 @@ struct AnalysisWorkspaceView: View { ) ) .labelsHidden() + .disabled(!match.isWritableTarget) .help(match.coordinate == nil ? "已勾选;获得坐标前会安全跳过" : "勾选后纳入写入计划") } .width(42) @@ -193,7 +254,7 @@ struct AnalysisWorkspaceView: View { } .width(min: 72, ideal: 82) - TableColumn("坐标 / 源定位精度") { match in + TableColumn("坐标 / 证据") { match in VStack(alignment: .leading, spacing: 1) { Text(match.coordinate?.shortDescription ?? "—") .font(.caption.monospacedDigit()) @@ -203,9 +264,13 @@ struct AnalysisWorkspaceView: View { .foregroundStyle(.red) .lineLimit(1) } else { - Text(match.sourceLocationAccuracy.description) + Text("\(match.granularity.title) · \(match.sourceLocationAccuracy.description)") .font(.caption2) .foregroundStyle(.secondary) + Text(match.evidenceSummary) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) } } } @@ -387,6 +452,7 @@ private struct MatchMapView: View { switch match.confidence { case .reliable: return .green case .review: return .orange + case .coarse: return .purple case .unmatched: return .red } } diff --git a/RawGeoSyncApp/Views/Components/SharedComponents.swift b/RawGeoSyncApp/Views/Components/SharedComponents.swift index 3cbdc29..cafeeaa 100644 --- a/RawGeoSyncApp/Views/Components/SharedComponents.swift +++ b/RawGeoSyncApp/Views/Components/SharedComponents.swift @@ -41,6 +41,7 @@ struct ConfidenceBadge: View { switch confidence { case .reliable: .green case .review: .orange + case .coarse: .purple case .unmatched: .red } } diff --git a/RawGeoSyncApp/Views/SourceSetupView.swift b/RawGeoSyncApp/Views/SourceSetupView.swift index 8cfd01f..31996ea 100644 --- a/RawGeoSyncApp/Views/SourceSetupView.swift +++ b/RawGeoSyncApp/Views/SourceSetupView.swift @@ -1,10 +1,5 @@ import AppKit import SwiftUI -import UniformTypeIdentifiers - -extension UTType { - fileprivate static let gpx = UTType(filenameExtension: "gpx", conformingTo: .xml)! -} struct SourceSetupView: View { @EnvironmentObject private var workspace: WorkspaceViewModel @@ -35,17 +30,17 @@ struct SourceSetupView: View { HStack(spacing: 16) { SourcePickerCard( - title: "手机轨迹", - description: "支持标准 GPX;时间应为 UTC 或包含时区。", + title: "GPX 轨迹目录", + description: "自动读取目录内与照片时间窗口相关的全部 GPX。", systemImage: "point.topleft.down.to.point.bottomright.curvepath", - url: workspace.configuration.trackURL, - actionTitle: "选择 GPX…", - action: chooseTrack, - onDropURL: setTrackURL + url: workspace.configuration.gpxDirectoryURL, + actionTitle: "选择 GPX 目录…", + action: chooseGPXFolder, + onDropURL: setGPXFolderURL ) SourcePickerCard( - title: "RAW 文件夹", - description: "完整验证 Nikon NEF;其他常见 RAW/JPEG/TIFF 为实验性,原文件始终只读。", + title: "照片活动或 RAW 目录", + description: "可选择活动根目录或单个相机目录;递归发现专有 RAW 和只读证据。", systemImage: "camera.aperture", url: workspace.configuration.photoDirectoryURL, actionTitle: "选择文件夹…", @@ -55,8 +50,25 @@ struct SourceSetupView: View { } .frame(minHeight: 190) - GroupBox("时间与写入策略") { + GroupBox("匹配、时间与写入策略") { Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 16) { + GridRow { + SettingLabel( + title: "匹配策略", + detail: workspace.configuration.matchingStrategy.detail, + systemImage: "scope" + ) + Picker("", selection: $workspace.configuration.matchingStrategy) { + ForEach(MatchingStrategy.allCases) { strategy in + Text(strategy.title).tag(strategy) + } + } + .labelsHidden() + .frame(maxWidth: 270, alignment: .leading) + } + + Divider().gridCellUnsizedAxes(.horizontal) + GridRow { SettingLabel( title: "拍摄地时区", @@ -104,7 +116,7 @@ struct SourceSetupView: View { GridRow { SettingLabel( title: "输出方式", - detail: "在 RAW 同目录原子创建或合并同名 sidecar", + detail: "仅为 NEF、ARW 等专有 RAW 创建同名 sidecar", systemImage: "doc.badge.gearshape" ) Picker("", selection: $workspace.configuration.outputMode) { @@ -133,10 +145,10 @@ struct SourceSetupView: View { GridRow { SettingLabel( title: "已有坐标", - detail: "默认取消选择;在预览中重新勾选才表示明确替换", + detail: "新来源可证明更强时自动采用;未知外部 XMP 仍受保护", systemImage: "shield.checkered" ) - Text("先跳过,逐项确认") + Text("强来源优先,未知来源保护") .foregroundStyle(.secondary) .frame(maxWidth: 270, alignment: .leading) } @@ -177,17 +189,17 @@ struct SourceSetupView: View { } } - private func chooseTrack() { + private func chooseGPXFolder() { let panel = NSOpenPanel() - panel.title = "选择手机导出的 GPX 轨迹" - panel.prompt = "选择轨迹" - panel.allowedContentTypes = [.gpx] + panel.title = "选择存放 GPX 轨迹的目录" + panel.prompt = "选择目录" panel.allowsMultipleSelection = false - panel.canChooseFiles = true - panel.canChooseDirectories = false + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.canCreateDirectories = false panel.resolvesAliases = true guard panel.runModal() == .OK, let url = panel.url else { return } - setTrackURL(url) + setGPXFolderURL(url) } private func choosePhotoFolder() { @@ -203,12 +215,15 @@ struct SourceSetupView: View { setPhotoFolderURL(url) } - private func setTrackURL(_ url: URL) { - guard url.pathExtension.lowercased() == "gpx" else { - workspace.errorMessage = "请选择扩展名为 .gpx 的轨迹文件。" + private func setGPXFolderURL(_ url: URL) { + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), + isDirectory.boolValue + else { + workspace.errorMessage = "请选择包含 GPX 文件的目录。" return } - workspace.configuration.trackURL = url + workspace.configuration.gpxDirectoryURL = url } private func setPhotoFolderURL(_ url: URL) { diff --git a/RawGeoSyncAppTests/WorkspaceSelectionTests.swift b/RawGeoSyncAppTests/WorkspaceSelectionTests.swift new file mode 100644 index 0000000..8774c0e --- /dev/null +++ b/RawGeoSyncAppTests/WorkspaceSelectionTests.swift @@ -0,0 +1,86 @@ +import XCTest + +@testable import RawGeoSync + +@MainActor +final class WorkspaceSelectionTests: XCTestCase { + func testFilteredSelectAllTogglesPhotoCheckmarks() { + let workspace = WorkspaceViewModel(service: DemoGeoWorkflowService()) + workspace.matches = [ + makeMatch(id: "reliable", confidence: .reliable), + makeMatch(id: "review-a", confidence: .review), + makeMatch(id: "review-b", confidence: .review), + ] + workspace.confidenceFilter = .review + + workspace.toggleFilteredPhotoCheckmarks() + + XCTAssertFalse(workspace.matches[0].isSelectedForWrite) + XCTAssertTrue(workspace.matches[1].isSelectedForWrite) + XCTAssertTrue(workspace.matches[2].isSelectedForWrite) + XCTAssertTrue(workspace.areAllFilteredPhotosChecked) + + workspace.toggleFilteredPhotoCheckmarks() + XCTAssertFalse(workspace.matches[1].isSelectedForWrite) + XCTAssertFalse(workspace.matches[2].isSelectedForWrite) + } + + func testEvidenceOnlyAssetsAreNeverSelectedForWrite() { + let workspace = WorkspaceViewModel(service: DemoGeoWorkflowService()) + workspace.matches = [ + makeMatch(id: "raw", confidence: .coarse), + makeMatch(id: "evidence", confidence: .coarse, isWritableTarget: false), + ] + workspace.confidenceFilter = .coarse + + workspace.toggleFilteredPhotoCheckmarks() + + XCTAssertTrue(workspace.matches[0].isSelectedForWrite) + XCTAssertFalse(workspace.matches[1].isSelectedForWrite) + } + + func testConfirmGroupChecksEveryWritablePhotoInTheGroup() { + let workspace = WorkspaceViewModel(service: DemoGeoWorkflowService()) + workspace.matches = [ + makeMatch(id: "a", confidence: .review, groupID: "stay-1"), + makeMatch(id: "b", confidence: .review, groupID: "stay-1"), + makeMatch(id: "c", confidence: .review, groupID: "stay-2"), + ] + workspace.selectedMatches = ["a"] + + workspace.confirmSelectedGroups() + + XCTAssertEqual(workspace.selectedMatches, ["a", "b"]) + XCTAssertTrue(workspace.matches[0].isSelectedForWrite) + XCTAssertTrue(workspace.matches[1].isSelectedForWrite) + XCTAssertFalse(workspace.matches[2].isSelectedForWrite) + } + + private func makeMatch( + id: String, + confidence: MatchConfidence, + groupID: String? = nil, + isWritableTarget: Bool = true + ) -> PhotoMatch { + PhotoMatch( + id: id, + fileURL: URL(fileURLWithPath: "/tmp/\(id).NEF"), + capturedAt: Date(timeIntervalSince1970: 1_700_000_000), + previousTrackPoint: nil, + nextTrackPoint: nil, + coordinate: GeoCoordinate(latitude: 1, longitude: 2, altitude: nil), + confidence: confidence, + method: .stationary, + granularity: .photoCluster, + sourceLocationAccuracy: .notProvided, + evidenceSummary: "合成测试证据", + supportSpreadMeters: 50, + confirmationGroupID: groupID, + note: "测试", + isSelectedForWrite: false, + isWritableTarget: isWritableTarget, + hasExistingGPS: false, + hasProtectedExternalXMP: false + ) + } +} diff --git a/Scripts/build-release.sh b/Scripts/build-release.sh index 6387640..e446843 100755 --- a/Scripts/build-release.sh +++ b/Scripts/build-release.sh @@ -7,6 +7,7 @@ TEMP_ROOT="${TMPDIR:-/tmp}" DERIVED_DATA="$(mktemp -d "${TEMP_ROOT%/}/RawGeoSync-Release.XXXXXX")" OUTPUT_ROOT="${RAWGEOSYNC_INSTALL_DIR:-${HOME}/Applications}" FINAL_APP="$OUTPUT_ROOT/RawGeoSync.app" +PREVIOUS_APP="$OUTPUT_ROOT/.RawGeoSync.app.previous" cleanup() { case "$DERIVED_DATA" in @@ -35,12 +36,25 @@ ditto --noqtn "$DERIVED_DATA/Build/Products/Release/RawGeoSync.app" "$STAGED_APP xattr -dr com.apple.quarantine "$STAGED_APP" 2>/dev/null || true codesign --force --sign - --timestamp=none "$STAGED_APP" codesign --verify --deep --strict "$STAGED_APP" +if [[ -e "$PREVIOUS_APP" ]]; then + find "$PREVIOUS_APP" -depth -delete +fi if [[ -e "$FINAL_APP" ]]; then - find "$FINAL_APP" -depth -delete + mv "$FINAL_APP" "$PREVIOUS_APP" +fi +INSTALL_OK=0 +if mv "$STAGED_APP" "$FINAL_APP"; then + INSTALL_OK=1 fi -mv "$STAGED_APP" "$FINAL_APP" xattr -dr com.apple.quarantine "$FINAL_APP" 2>/dev/null || true -codesign --verify --deep --strict "$FINAL_APP" +if (( INSTALL_OK )) && codesign --verify --deep --strict "$FINAL_APP"; then + [[ ! -e "$PREVIOUS_APP" ]] || find "$PREVIOUS_APP" -depth -delete +else + [[ ! -e "$FINAL_APP" ]] || find "$FINAL_APP" -depth -delete + [[ ! -e "$PREVIOUS_APP" ]] || mv "$PREVIOUS_APP" "$FINAL_APP" + print -u2 "Release 应用安装验证失败;已恢复上一版本。" + exit 1 +fi print "Release 应用已更新:" print "$FINAL_APP" diff --git a/Scripts/build.sh b/Scripts/build.sh index 6938503..9531974 100755 --- a/Scripts/build.sh +++ b/Scripts/build.sh @@ -7,6 +7,7 @@ TEMP_ROOT="${TMPDIR:-/tmp}" DERIVED_DATA="$(mktemp -d "${TEMP_ROOT%/}/RawGeoSync-Debug.XXXXXX")" OUTPUT_ROOT="$PROJECT_ROOT/.local/Debug" FINAL_APP="$OUTPUT_ROOT/RawGeoSync.app" +PREVIOUS_APP="$OUTPUT_ROOT/.RawGeoSync.app.previous" cleanup() { case "$DERIVED_DATA" in @@ -35,12 +36,25 @@ ditto "$DERIVED_DATA/Build/Products/Debug/RawGeoSync.app" "$STAGED_APP" xattr -dr com.apple.quarantine "$STAGED_APP" 2>/dev/null || true codesign --force --sign - --timestamp=none "$STAGED_APP" codesign --verify --deep --strict "$STAGED_APP" +if [[ -e "$PREVIOUS_APP" ]]; then + find "$PREVIOUS_APP" -depth -delete +fi if [[ -e "$FINAL_APP" ]]; then - find "$FINAL_APP" -depth -delete + mv "$FINAL_APP" "$PREVIOUS_APP" +fi +INSTALL_OK=0 +if mv "$STAGED_APP" "$FINAL_APP"; then + INSTALL_OK=1 fi -mv "$STAGED_APP" "$FINAL_APP" xattr -dr com.apple.quarantine "$FINAL_APP" 2>/dev/null || true -codesign --verify --deep --strict "$FINAL_APP" +if (( INSTALL_OK )) && codesign --verify --deep --strict "$FINAL_APP"; then + [[ ! -e "$PREVIOUS_APP" ]] || find "$PREVIOUS_APP" -depth -delete +else + [[ ! -e "$FINAL_APP" ]] || find "$FINAL_APP" -depth -delete + [[ ! -e "$PREVIOUS_APP" ]] || mv "$PREVIOUS_APP" "$FINAL_APP" + print -u2 "Debug 应用安装验证失败;已恢复上一版本。" + exit 1 +fi print "Debug 应用已更新:" print "$FINAL_APP" diff --git a/Scripts/ci.sh b/Scripts/ci.sh new file mode 100755 index 0000000..543b0e3 --- /dev/null +++ b/Scripts/ci.sh @@ -0,0 +1,64 @@ +#!/bin/zsh +set -euo pipefail + +PROJECT_ROOT="${0:A:h:h}" +export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer" +TEMP_ROOT="${TMPDIR:-/tmp}" +DERIVED_DATA="$(mktemp -d "${TEMP_ROOT%/}/RawGeoSync-CI.XXXXXX")" + +cleanup() { + case "$DERIVED_DATA" in + "${TEMP_ROOT%/}"/RawGeoSync-CI.*) + find "$DERIVED_DATA" -depth -delete 2>/dev/null || true + ;; + esac +} +trap cleanup EXIT INT TERM + +XCODE_VERSION="$(xcodebuild -version | awk 'NR == 1 { print $2 }')" +awk -v actual="$XCODE_VERSION" -v minimum="26.3" ' + BEGIN { + split(actual, a, "."); + split(minimum, m, "."); + if ((a[1] + 0) < (m[1] + 0) || ((a[1] + 0) == (m[1] + 0) && (a[2] + 0) < (m[2] + 0))) { + exit 1; + } + } +' || { + print -u2 "CI要求Xcode 26.3或更高版本,当前为 $XCODE_VERSION" + exit 1 +} + +"$PROJECT_ROOT/Scripts/repository-policy-check.sh" +"$PROJECT_ROOT/Scripts/verify-vendor.sh" +"$PROJECT_ROOT/Scripts/format-check.sh" +"$PROJECT_ROOT/Scripts/test.sh" + +swift test \ + --configuration release \ + --package-path "$PROJECT_ROOT/RawGeoCore" \ + --scratch-path "$DERIVED_DATA/SwiftPM/RawGeoCore-Release" +swift test \ + --configuration release \ + --package-path "$PROJECT_ROOT/MetadataInfrastructure" \ + --scratch-path "$DERIVED_DATA/SwiftPM/MetadataInfrastructure-Release" + +xcodebuild \ + -project "$PROJECT_ROOT/RawGeoSync.xcodeproj" \ + -scheme RawGeoSync \ + -configuration Release \ + -destination 'platform=macOS,arch=arm64' \ + -derivedDataPath "$DERIVED_DATA/App" \ + CODE_SIGNING_ALLOWED=NO \ + build + +xcodebuild \ + -project "$PROJECT_ROOT/RawGeoSync.xcodeproj" \ + -scheme RawGeoSync \ + -configuration Debug \ + -destination 'platform=macOS,arch=arm64' \ + -derivedDataPath "$DERIVED_DATA/App" \ + CODE_SIGNING_ALLOWED=NO \ + analyze + +print "CI等价门禁全部通过" diff --git a/Scripts/real-data-regression.sh b/Scripts/real-data-regression.sh new file mode 100755 index 0000000..9b512b7 --- /dev/null +++ b/Scripts/real-data-regression.sh @@ -0,0 +1,273 @@ +#!/bin/zsh +set -euo pipefail +zmodload zsh/datetime + +PROJECT_ROOT="${0:A:h:h}" +GPX_DIRECTORY="${RAWGEOSYNC_GPX_DIR:-}" +PHOTO_DIRECTORY="${RAWGEOSYNC_PHOTO_DIR:-}" +CLI_PATH="${RAWGEOSYNC_REGRESSION_CLI:-$PROJECT_ROOT/.local/bin/rawgeosync-regression}" +OUTPUT_ROOT="${RAWGEOSYNC_REGRESSION_OUTPUT_ROOT:-$PROJECT_ROOT/.local/real-regression}" +REQUIRE_CAPABILITY=0 + +usage() { + cat <<'EOF' +用法: + Scripts/real-data-regression.sh \ + --gpx-dir \ + --photo-dir <照片目录> \ + [--cli <回归CLI>] \ + [--output-root <输出目录>] \ + [--require-capability] + +也可使用 RAWGEOSYNC_GPX_DIR、RAWGEOSYNC_PHOTO_DIR、 +RAWGEOSYNC_REGRESSION_CLI 和 RAWGEOSYNC_REGRESSION_OUTPUT_ROOT。 + +脚本只向 output-root 写入报告;仓库内输出必须已被 Git 忽略。它会用 +macOS sandbox 拒绝源目录写入,并在前后比较 SHA-256 与基础文件元数据。 + +未找到 v2 回归 CLI,或 CLI 以状态 78 明确表示能力不可用时,默认输出 +SKIP;其他能力探测错误均失败。发布门禁应加 --require-capability。 +EOF +} + +fail() { + print -u2 "错误:$1" + exit 1 +} + +capability_unavailable() { + local message="$1" + if (( REQUIRE_CAPABILITY )); then + fail "$message" + fi + print "SKIP: $message" + exit 0 +} + +validate_json_object() { + local input="$1" + local first_character + first_character="$(awk 'match($0, /[^[:space:]]/) { print substr($0, RSTART, 1); exit }' "$input")" + [[ "$first_character" == "{" ]] || return 1 + /usr/bin/plutil -convert xml1 -o /dev/null -- "$input" >/dev/null 2>&1 +} + +while (( $# > 0 )); do + case "$1" in + --gpx-dir) + (( $# >= 2 )) || fail "--gpx-dir 缺少参数" + GPX_DIRECTORY="$2" + shift 2 + ;; + --photo-dir) + (( $# >= 2 )) || fail "--photo-dir 缺少参数" + PHOTO_DIRECTORY="$2" + shift 2 + ;; + --cli) + (( $# >= 2 )) || fail "--cli 缺少参数" + CLI_PATH="$2" + shift 2 + ;; + --output-root) + (( $# >= 2 )) || fail "--output-root 缺少参数" + OUTPUT_ROOT="$2" + shift 2 + ;; + --require-capability) + REQUIRE_CAPABILITY=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "未知参数:$1" + ;; + esac +done + +[[ -n "$GPX_DIRECTORY" ]] || fail "请通过 --gpx-dir 或 RAWGEOSYNC_GPX_DIR 提供GPX目录" +[[ -n "$PHOTO_DIRECTORY" ]] || fail "请通过 --photo-dir 或 RAWGEOSYNC_PHOTO_DIR 提供照片目录" +[[ -d "$GPX_DIRECTORY" ]] || fail "GPX目录不存在或不可读" +[[ -d "$PHOTO_DIRECTORY" ]] || fail "照片目录不存在或不可读" + +GPX_DIRECTORY="${GPX_DIRECTORY:A}" +PHOTO_DIRECTORY="${PHOTO_DIRECTORY:A}" +CLI_PATH="${CLI_PATH:A}" + +[[ -z "$(find "$GPX_DIRECTORY" -type l -print -quit)" ]] \ + || fail "GPX目录包含符号链接,无法证明链接目标保持只读" +[[ -z "$(find "$PHOTO_DIRECTORY" -type l -print -quit)" ]] \ + || fail "照片目录包含符号链接,无法证明链接目标保持只读" +[[ -n "$(find "$GPX_DIRECTORY" -type f -iname '*.gpx' -print -quit)" ]] \ + || fail "GPX目录中没有 .gpx 文件" +[[ -n "$(find "$PHOTO_DIRECTORY" -type f \( \ + -iname '*.nef' -o -iname '*.nrw' -o -iname '*.arw' -o -iname '*.cr2' \ + -o -iname '*.cr3' -o -iname '*.raf' -o -iname '*.orf' -o -iname '*.rw2' \ + -o -iname '*.dng' -o -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.tif' \ + -o -iname '*.tiff' \) -print -quit)" ]] || fail "照片目录中没有受支持的照片" + +[[ -x "$CLI_PATH" ]] || capability_unavailable "未找到可执行的v2回归CLI;通过 --cli 指定" + +OUTPUT_ROOT="${OUTPUT_ROOT:A}" +validate_output_location() { + local source_root + for source_root in "$GPX_DIRECTORY" "$PHOTO_DIRECTORY"; do + case "$OUTPUT_ROOT/" in + "$source_root/"*) + fail "输出目录与输入目录存在包含关系,拒绝运行" + ;; + esac + case "$source_root/" in + "$OUTPUT_ROOT/"*) + fail "输出目录与输入目录存在包含关系,拒绝运行" + ;; + esac + done +} +validate_output_location +if [[ "$OUTPUT_ROOT" == "$PROJECT_ROOT" ]]; then + fail "输出目录不能是仓库根目录" +fi +case "$OUTPUT_ROOT/" in + "$PROJECT_ROOT/"*) + OUTPUT_RELATIVE_PATH="${OUTPUT_ROOT#"$PROJECT_ROOT/"}" + git -C "$PROJECT_ROOT" check-ignore -q -- "$OUTPUT_RELATIVE_PATH" \ + || fail "仓库内输出目录必须先被.gitignore明确忽略" + ;; +esac +mkdir -p "$OUTPUT_ROOT" +OUTPUT_ROOT="${OUTPUT_ROOT:A}" +validate_output_location + +RUN_ROOT="$(mktemp -d "${OUTPUT_ROOT%/}/run.XXXXXX")" +CAPABILITIES="$RUN_ROOT/capabilities.json" +CAPABILITY_STDERR="$RUN_ROOT/capabilities.stderr.log" + +set +e +"$CLI_PATH" capabilities --format json >"$CAPABILITIES" 2>"$CAPABILITY_STDERR" +CAPABILITY_STATUS=$? +set -e +(( CAPABILITY_STATUS == 0 )) \ + || { + if (( CAPABILITY_STATUS == 78 )); then + capability_unavailable "CLI明确报告v2回归能力不可用(报告保留在 $RUN_ROOT)" + fi + fail "CLI能力探测异常退出(状态 $CAPABILITY_STATUS;报告保留在 $RUN_ROOT)" + } +validate_json_object "$CAPABILITIES" \ + || fail "CLI能力输出不是有效JSON(报告保留在 $RUN_ROOT)" + +SCHEMA_VERSION="$(/usr/bin/plutil -extract schemaVersion raw -o - "$CAPABILITIES" 2>/dev/null || true)" +HAS_DRY_RUN="$(/usr/bin/plutil -extract features.fullCorpusDryRun raw -o - "$CAPABILITIES" 2>/dev/null || true)" +READ_ONLY_GUARANTEE="$(/usr/bin/plutil -extract guarantees.readOnlySourceDirectories raw -o - "$CAPABILITIES" 2>/dev/null || true)" +[[ "$SCHEMA_VERSION" == "1" && "$HAS_DRY_RUN" == "true" && "$READ_ONLY_GUARANTEE" == "true" ]] \ + || fail "CLI能力契约回退:缺少schemaVersion=1、fullCorpusDryRun或readOnlySourceDirectories(报告保留在 $RUN_ROOT)" + +snapshot_tree() { + local source_root="$1" + local destination="$2" + /usr/bin/perl -MFile::Find -MDigest::SHA -MJSON::PP -e ' + use strict; + use warnings; + my ($root, $destination) = @ARGV; + chdir $root or die "cannot chdir to input root\n"; + my @paths; + find({ + no_chdir => 1, + wanted => sub { + my $path = $File::Find::name; + $path =~ s{^\./}{}; + $path = q{.} if $path eq q{}; + push @paths, $path; + } + }, q{.}); + open my $output, q{>:raw}, $destination or die "cannot create snapshot\n"; + for my $path (sort @paths) { + my @metadata = lstat $path; + die "cannot stat input entry\n" unless @metadata; + my %record = ( + path => $path, + device => $metadata[0], + inode => $metadata[1], + mode => sprintf(q{%04o}, $metadata[2] & 07777), + linkCount => $metadata[3], + uid => $metadata[4], + gid => $metadata[5], + mtime => $metadata[9], + ctime => $metadata[10], + ); + if (-l _) { + $record{type} = q{symlink}; + $record{target} = readlink $path; + } elsif (-d _) { + $record{type} = q{directory}; + } elsif (-f _) { + $record{type} = q{file}; + $record{size} = $metadata[7]; + open my $input, q{<:raw}, $path or die "cannot read input file\n"; + $record{sha256} = Digest::SHA->new(256)->addfile($input)->hexdigest; + close $input; + } else { + $record{type} = q{other}; + } + print {$output} JSON::PP->new->canonical->encode(\%record), "\n"; + } + close $output; + ' "$source_root" "$destination" +} + +print "正在建立只读前置快照;该阶段会完整读取输入文件但不会写入输入目录。" +snapshot_tree "$GPX_DIRECTORY" "$RUN_ROOT/gpx.before.jsonl" +snapshot_tree "$PHOTO_DIRECTORY" "$RUN_ROOT/photos.before.jsonl" + +REPORT="$RUN_ROOT/dry-run-report.json" +RUNTIME_LOG="$RUN_ROOT/runtime.stderr-and-time.log" +STDOUT_LOG="$RUN_ROOT/runtime.stdout.log" +SANDBOX_PROFILE="$RUN_ROOT/read-only-inputs.sb" +print -r -- '(version 1) +(allow default) +(deny file-write* (subpath (param "GPX_SOURCE"))) +(deny file-write* (subpath (param "PHOTO_SOURCE")))' >"$SANDBOX_PROFILE" +START_TIME=$EPOCHREALTIME +set +e +/usr/bin/time -l /usr/bin/sandbox-exec \ + -D "GPX_SOURCE=$GPX_DIRECTORY" \ + -D "PHOTO_SOURCE=$PHOTO_DIRECTORY" \ + -f "$SANDBOX_PROFILE" \ + "$CLI_PATH" dry-run \ + --gpx-directory "$GPX_DIRECTORY" \ + --photo-directory "$PHOTO_DIRECTORY" \ + --report "$REPORT" \ + --read-only-source-directories \ + >"$STDOUT_LOG" 2>"$RUNTIME_LOG" +CLI_STATUS=$? +set -e +ELAPSED_SECONDS=$(( EPOCHREALTIME - START_TIME )) + +print "正在建立后置快照并验证输入目录完全未变。" +snapshot_tree "$GPX_DIRECTORY" "$RUN_ROOT/gpx.after.jsonl" +snapshot_tree "$PHOTO_DIRECTORY" "$RUN_ROOT/photos.after.jsonl" + +INPUTS_UNCHANGED=1 +cmp -s "$RUN_ROOT/gpx.before.jsonl" "$RUN_ROOT/gpx.after.jsonl" || INPUTS_UNCHANGED=0 +cmp -s "$RUN_ROOT/photos.before.jsonl" "$RUN_ROOT/photos.after.jsonl" || INPUTS_UNCHANGED=0 +(( INPUTS_UNCHANGED == 1 )) || fail "dry-run改变了输入目录;证据保留在 $RUN_ROOT" +(( CLI_STATUS == 0 )) || fail "dry-run CLI退出码为 $CLI_STATUS;证据保留在 $RUN_ROOT" +[[ -s "$REPORT" ]] || fail "dry-run未生成报告;证据保留在 $RUN_ROOT" +validate_json_object "$REPORT" || fail "dry-run报告不是有效JSON;证据保留在 $RUN_ROOT" + +REPORT_MODE="$(/usr/bin/plutil -extract mode raw -o - "$REPORT" 2>/dev/null || true)" +REPORT_SCHEMA="$(/usr/bin/plutil -extract schemaVersion raw -o - "$REPORT" 2>/dev/null || true)" +[[ "$REPORT_MODE" == "dry-run" && "$REPORT_SCHEMA" == "1" ]] \ + || fail "dry-run报告缺少mode=dry-run或schemaVersion=1;证据保留在 $RUN_ROOT" + +MAX_RSS_BYTES="$(awk '/maximum resident set size/ { print $1; exit }' "$RUNTIME_LOG")" +printf 'PASS: 全量只读回归完成,输入目录SHA-256快照未变化。\n' +printf 'CLI wall time: %.3f 秒\n' "$ELAPSED_SECONDS" +if [[ -n "$MAX_RSS_BYTES" ]]; then + printf 'CLI maximum resident set size: %s bytes\n' "$MAX_RSS_BYTES" +fi +print "本地证据目录:$RUN_ROOT" diff --git a/Scripts/real-sample-smoke.sh b/Scripts/real-sample-smoke.sh index cea8068..aee46d2 100755 --- a/Scripts/real-sample-smoke.sh +++ b/Scripts/real-sample-smoke.sh @@ -2,32 +2,5 @@ set -euo pipefail PROJECT_ROOT="${0:A:h:h}" -export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer" -TEMP_ROOT="${TMPDIR:-/tmp}" -DERIVED_DATA="$(mktemp -d "${TEMP_ROOT%/}/RawGeoSync-Smoke.XXXXXX")" - -cleanup() { - case "$DERIVED_DATA" in - "${TEMP_ROOT%/}"/RawGeoSync-Smoke.*) - find "$DERIVED_DATA" -depth -delete 2>/dev/null || true - ;; - esac -} -trap cleanup EXIT INT TERM - -: "${RAWGEOSYNC_GPX_PATH:?请设置 RAWGEOSYNC_GPX_PATH}" -: "${RAWGEOSYNC_PHOTO_DIR:?请设置 RAWGEOSYNC_PHOTO_DIR}" - -xcodebuild \ - -project "$PROJECT_ROOT/RawGeoSync.xcodeproj" \ - -scheme RawGeoSyncSmoke \ - -configuration Debug \ - -destination 'platform=macOS,arch=arm64' \ - -derivedDataPath "$DERIVED_DATA" \ - CODE_SIGNING_ALLOWED=NO \ - build >/dev/null - -"$DERIVED_DATA/Build/Products/Debug/RawGeoSyncSmoke" \ - "$RAWGEOSYNC_GPX_PATH" \ - "$RAWGEOSYNC_PHOTO_DIR" \ - --expect-current-sample +print -u2 "提示:real-sample-smoke.sh 已由参数化的只读全量回归接口取代。" +exec "$PROJECT_ROOT/Scripts/real-data-regression.sh" "$@" diff --git a/Scripts/repository-policy-check.sh b/Scripts/repository-policy-check.sh new file mode 100755 index 0000000..1d575eb --- /dev/null +++ b/Scripts/repository-policy-check.sh @@ -0,0 +1,106 @@ +#!/bin/zsh +set -euo pipefail + +PROJECT_ROOT="${0:A:h:h}" +cd "$PROJECT_ROOT" + +fail() { + print -u2 "仓库策略检查失败:$1" + exit 1 +} + +for script in Scripts/*.sh; do + /bin/zsh -n "$script" || fail "$script 不是有效的zsh脚本" + [[ -x "$script" ]] || fail "$script 缺少可执行权限" +done + +SCAN_FILES=() +REPOSITORY_FILES=() +while IFS= read -r repository_file; do + REPOSITORY_FILES+=("$repository_file") + case "$repository_file" in + Vendor/*) ;; + *.md | *.sh | *.swift | *.yml | *.yaml | *.json | *.jsonl | *.log | *.txt | *.plist \ + | *.xml | *.pbxproj | *.xcscheme | *.xcconfig) + SCAN_FILES+=("$repository_file") + ;; + esac +done < <(git ls-files --cached --others --exclude-standard) + +ABSOLUTE_PATH_SCAN_FILES=() +for scan_file in "${SCAN_FILES[@]}"; do + [[ "$scan_file" == "Scripts/repository-policy-check.sh" ]] \ + || ABSOLUTE_PATH_SCAN_FILES+=("$scan_file") +done +LOCAL_HOME_PATTERN='/(Users|home|Volumes|private|var/folders)/|[A-Za-z]:\\Users\\|\\\\[A-Za-z0-9_.-]+\\[A-Za-z0-9$_.-]+' +ABSOLUTE_PATH_MATCHES="$( + grep -nEH "$LOCAL_HOME_PATTERN" -- "${ABSOLUTE_PATH_SCAN_FILES[@]}" || true +)" +[[ -z "$ABSOLUTE_PATH_MATCHES" ]] || { + print -u2 "$ABSOLUTE_PATH_MATCHES" + fail "跟踪文件包含本机绝对路径" +} + +SECRET_MATCHES="$( + grep -nEH '(BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY|AKIA[0-9A-Z]{16}|ghp_[0-9A-Za-z]{20,}|github_pat_[0-9A-Za-z_]{20,}|sk-[0-9A-Za-z_-]{20,}|xox[baprs]-[0-9A-Za-z-]{10,})' \ + -- "${SCAN_FILES[@]}" || true +)" +[[ -z "$SECRET_MATCHES" ]] || { + print -u2 "$SECRET_MATCHES" + fail "跟踪文件疑似包含私钥或访问令牌" +} + +SENSITIVE_TEXT_FILES=() +for scan_file in "${SCAN_FILES[@]}"; do + case "$scan_file" in + *.md | *.sh | *.yml | *.yaml | *.json | *.jsonl | *.log | *.txt | *.plist | *.xml \ + | *.pbxproj | *.xcscheme | *.xcconfig) + SENSITIVE_TEXT_FILES+=("$scan_file") + ;; + *.swift) + case "$scan_file" in + */Tests/*) ;; + *) SENSITIVE_TEXT_FILES+=("$scan_file") ;; + esac + ;; + esac +done +COORDINATE_MATCHES="$( + grep -nEH '[-+]?[0-9]{1,3}\.[0-9]{5,}[,[:space:]]+[-+]?[0-9]{1,3}\.[0-9]{5,}' \ + -- "${SENSITIVE_TEXT_FILES[@]}" || true +)" +[[ -z "$COORDINATE_MATCHES" ]] || { + print -u2 "$COORDINATE_MATCHES" + fail "文档或脚本疑似包含高精度坐标" +} + +FORBIDDEN_TRACKED_FILES=() +for repository_file in "${REPOSITORY_FILES[@]}"; do + lower_name="${repository_file:l}" + case "$lower_name" in + *.nef|*.nrw|*.arw|*.cr2|*.cr3|*.raf|*.orf|*.rw2|*.dng|*.xmp|*.jpg|*.jpeg|*.tif \ + |*.tiff|*.heic|*.png) + FORBIDDEN_TRACKED_FILES+=("$repository_file") + ;; + *.gpx) + case "$repository_file" in + */Tests/Fixtures/*.gpx | */Tests/Fixtures/**/*.gpx) ;; + *) FORBIDDEN_TRACKED_FILES+=("$repository_file") ;; + esac + ;; + */dry-run-report.json|*/capabilities.stderr.log|*/runtime.stderr-and-time.log \ + |*/runtime.stdout.log|*/gpx.before.jsonl|*/gpx.after.jsonl \ + |*/photos.before.jsonl|*/photos.after.jsonl) + FORBIDDEN_TRACKED_FILES+=("$repository_file") + ;; + esac +done +(( ${#FORBIDDEN_TRACKED_FILES[@]} == 0 )) || { + print -l -u2 -- "${FORBIDDEN_TRACKED_FILES[@]}" + fail "仓库跟踪了真实照片、XMP或非合成GPX" +} + +git diff --check +git diff --cached --check +git show --check --format= HEAD >/dev/null +print "仓库策略检查通过" diff --git a/Scripts/test.sh b/Scripts/test.sh index c84d39d..a2e3977 100755 --- a/Scripts/test.sh +++ b/Scripts/test.sh @@ -29,4 +29,4 @@ xcodebuild \ -destination 'platform=macOS,arch=arm64' \ -derivedDataPath "$DERIVED_DATA" \ CODE_SIGNING_ALLOWED=NO \ - build + test diff --git a/Tools/RawGeoSmoke/SmokeMain.swift b/Tools/RawGeoSmoke/SmokeMain.swift index f7b9abd..8d00841 100644 --- a/Tools/RawGeoSmoke/SmokeMain.swift +++ b/Tools/RawGeoSmoke/SmokeMain.swift @@ -4,132 +4,151 @@ import Foundation enum RawGeoSmokeMain { static func main() async { do { - let arguments = CommandLine.arguments - guard arguments.count == 3 || arguments.count == 4 else { + switch Array(CommandLine.arguments.dropFirst()) { + case ["capabilities", "--format", "json"]: + try printJSON(capabilities()) + case let arguments where arguments.first == "dry-run": + try await dryRun(arguments: Array(arguments.dropFirst())) + default: throw WorkflowFailure( message: - "用法:RawGeoSyncSmoke [--expect-current-sample|--apply-and-undo]" + "用法:RawGeoSyncSmoke capabilities --format json,或 dry-run --gpx-directory <目录> --photo-directory <目录> --report --read-only-source-directories" ) } - let configuration = SourceConfiguration( - trackURL: URL(fileURLWithPath: arguments[1]), - photoDirectoryURL: URL(fileURLWithPath: arguments[2]) - ) - let service = LiveGeoWorkflowService() - var finalSnapshot: AnalysisSnapshot? - for try await event in service.analysisEvents(for: configuration) { - if case .completed(let snapshot) = event { - finalSnapshot = snapshot - } - } - guard let snapshot = finalSnapshot else { - throw WorkflowFailure(message: "分析未返回结果") - } - let selectedReliable = snapshot.matches.count { - $0.confidence == .reliable && $0.isSelectedForWrite - } - let selectedReview = snapshot.matches.count { - $0.confidence == .review && $0.isSelectedForWrite - } - var output: [String: Any] = [ - "total": snapshot.matches.count, - "reliable": snapshot.reliableCount, - "review": snapshot.reviewCount, - "unmatched": snapshot.unmatchedCount, - "selectedReliable": selectedReliable, - "selectedReview": selectedReview, - "trackCoordinates": snapshot.trackCoordinates.count, - ] - if arguments.last == "--expect-current-sample" { - guard snapshot.matches.count == 52, - snapshot.reliableCount == 29, - snapshot.reviewCount == 23, - snapshot.unmatchedCount == 0, - selectedReliable == 29, - selectedReview == 0 - else { - throw WorkflowFailure(message: "真实样本黄金计数不符:\(output)") - } - } - if arguments.last == "--apply-and-undo" { - let preview = try await service.previewWrite( - matches: snapshot.matches, - configuration: configuration - ) - guard preview.createCount == selectedReliable, - preview.updateCount == 0, - preview.conflictCount == 0 - else { - throw WorkflowFailure(message: "首次写入预检不符:\(preview.message)") - } - - var appliedMatches: [PhotoMatch]? - var applicationReport: ApplicationReport? - for try await event in service.applyEvents( - matches: snapshot.matches, - configuration: configuration - ) { - if case .completed(let matches, let report) = event { - appliedMatches = matches - applicationReport = report - } - } - guard let appliedMatches, let applicationReport, - applicationReport.appliedCount == selectedReliable, - applicationReport.failedCount == 0 - else { - throw WorkflowFailure(message: "真实副本写入未完整成功") - } - - let xmpBeforeIdempotency = try xmpModificationDates(in: configuration.photoDirectoryURL!) - let secondPreview = try await service.previewWrite( - matches: appliedMatches, - configuration: configuration - ) - let xmpAfterIdempotency = try xmpModificationDates(in: configuration.photoDirectoryURL!) - guard secondPreview.alreadyAppliedCount == selectedReliable, - xmpBeforeIdempotency == xmpAfterIdempotency - else { - throw WorkflowFailure(message: "重复运行未保持语义幂等或修改了 XMP mtime") - } - - let undone = try await service.undo(report: applicationReport, matches: appliedMatches) - let remainingXMP = try xmpModificationDates(in: configuration.photoDirectoryURL!).count - guard remainingXMP == 0, - undone.count(where: { $0.verification == .undone }) == selectedReliable - else { - throw WorkflowFailure(message: "撤销后仍有 XMP 或撤销状态不完整") - } - output["applied"] = applicationReport.appliedCount - output["idempotent"] = true - output["undone"] = selectedReliable - } - let data = try JSONSerialization.data( - withJSONObject: output, - options: [.prettyPrinted, .sortedKeys] - ) - print(String(decoding: data, as: UTF8.self)) } catch { FileHandle.standardError.write( - Data("RawGeoSync smoke failed: \(error.localizedDescription)\n".utf8)) + Data("RawGeoSync smoke failed: \(error.localizedDescription)\n".utf8) + ) Foundation.exit(EXIT_FAILURE) } } - private static func xmpModificationDates(in directory: URL) throws -> [String: Date] { - let files = try FileManager.default.contentsOfDirectory( - at: directory, - includingPropertiesForKeys: [.contentModificationDateKey], - options: [.skipsHiddenFiles] + private static func capabilities() -> [String: Any] { + [ + "schemaVersion": 1, + "features": ["fullCorpusDryRun": true], + "guarantees": [ + "readOnlySourceDirectories": true, + "writeTargets": "proprietary-raw-xmp-sidecar-only", + ], + "matchingRuleVersion": "2.0", + ] + } + + private static func dryRun(arguments: [String]) async throws { + let options = try parseOptions(arguments) + guard options.readOnlySourceDirectories else { + throw WorkflowFailure(message: "dry-run 必须显式传入 --read-only-source-directories") + } + let gpxDirectory = try existingDirectory(options.gpxDirectory, label: "GPX") + let photoDirectory = try existingDirectory(options.photoDirectory, label: "照片") + guard let reportPath = options.report else { + throw WorkflowFailure(message: "缺少 --report") + } + let reportURL = URL(fileURLWithPath: reportPath).standardizedFileURL + guard !reportURL.path.hasPrefix(gpxDirectory.path + "/"), + !reportURL.path.hasPrefix(photoDirectory.path + "/") + else { + throw WorkflowFailure(message: "报告不能写入任一只读输入目录") + } + + let configuration = SourceConfiguration( + gpxDirectoryURL: gpxDirectory, + photoDirectoryURL: photoDirectory, + matchingStrategy: .coverage ) - return try Dictionary( - uniqueKeysWithValues: - files - .filter { $0.pathExtension.caseInsensitiveCompare("xmp") == .orderedSame } - .map { - let values = try $0.resourceValues(forKeys: [.contentModificationDateKey]) - return ($0.lastPathComponent, values.contentModificationDate ?? .distantPast) - } + let service = LiveGeoWorkflowService() + var snapshot: AnalysisSnapshot? + for try await event in service.analysisEvents(for: configuration) { + if case .completed(let completed) = event { snapshot = completed } + } + guard let snapshot else { throw WorkflowFailure(message: "分析未返回结果") } + + let methods = Dictionary(grouping: snapshot.matches, by: { $0.method.rawValue }) + .mapValues(\.count) + let granularities = Dictionary(grouping: snapshot.matches, by: { $0.granularity.rawValue }) + .mapValues(\.count) + let unmatchedReasons = Dictionary( + grouping: snapshot.matches.filter { $0.confidence == .unmatched }, + by: \PhotoMatch.evidenceSummary + ).mapValues(\.count) + let output: [String: Any] = [ + "schemaVersion": 1, + "mode": "dry-run", + "matchingRuleVersion": "2.0", + "strategy": "coverage", + "totalWritableTargets": snapshot.matches.count, + "reliable": snapshot.reliableCount, + "review": snapshot.reviewCount, + "coarse": snapshot.coarseCount, + "unmatched": snapshot.unmatchedCount, + "selectedForWrite": snapshot.matches.count(where: \.isSelectedForWrite), + "confirmationGroups": Set(snapshot.matches.compactMap(\.confirmationGroupID)).count, + "trackCoordinatesShown": snapshot.trackCoordinates.count, + "warningCount": snapshot.warnings.count, + "clockSuggestionCount": snapshot.clockSuggestions.count, + "methods": methods, + "granularities": granularities, + "unmatchedReasons": unmatchedReasons, + "sourceDirectoriesModified": false, + ] + let data = try JSONSerialization.data( + withJSONObject: output, options: [.prettyPrinted, .sortedKeys]) + try FileManager.default.createDirectory( + at: reportURL.deletingLastPathComponent(), + withIntermediateDirectories: true ) + try data.write(to: reportURL, options: .atomic) + try printJSON(output) + } + + private struct Options { + var gpxDirectory: String? + var photoDirectory: String? + var report: String? + var readOnlySourceDirectories = false + } + + private static func parseOptions(_ arguments: [String]) throws -> Options { + var options = Options() + var index = 0 + while index < arguments.count { + switch arguments[index] { + case "--gpx-directory": + guard index + 1 < arguments.count else { throw WorkflowFailure(message: "GPX 参数缺值") } + options.gpxDirectory = arguments[index + 1] + index += 2 + case "--photo-directory": + guard index + 1 < arguments.count else { throw WorkflowFailure(message: "照片参数缺值") } + options.photoDirectory = arguments[index + 1] + index += 2 + case "--report": + guard index + 1 < arguments.count else { throw WorkflowFailure(message: "报告参数缺值") } + options.report = arguments[index + 1] + index += 2 + case "--read-only-source-directories": + options.readOnlySourceDirectories = true + index += 1 + default: + throw WorkflowFailure(message: "未知参数:\(arguments[index])") + } + } + return options + } + + private static func existingDirectory(_ path: String?, label: String) throws -> URL { + guard let path else { throw WorkflowFailure(message: "缺少 \(label) 目录") } + let url = URL(fileURLWithPath: path).standardizedFileURL + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), + isDirectory.boolValue + else { throw WorkflowFailure(message: "\(label) 目录不存在") } + return url + } + + private static func printJSON(_ object: [String: Any]) throws { + let data = try JSONSerialization.data( + withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) + print(String(decoding: data, as: UTF8.self)) } } From c431e17c632b9627b1c432b3dd47251f5c866570 Mon Sep 17 00:00:00 2001 From: SSSimpleC <89213712+SSSimpleC@users.noreply.github.com> Date: Mon, 10 Aug 2026 02:29:01 +0800 Subject: [PATCH 2/5] ci: select Xcode 26.3 on GitHub runners --- .github/workflows/ci.yml | 4 ++-- Scripts/build-release.sh | 2 +- Scripts/build.sh | 2 +- Scripts/ci.sh | 2 +- Scripts/format-check.sh | 2 +- Scripts/test.sh | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f82a62..919fd55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,10 +18,10 @@ jobs: runs-on: macos-15 timeout-minutes: 45 env: - DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_26.3.app/Contents/Developer steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Show toolchain run: | diff --git a/Scripts/build-release.sh b/Scripts/build-release.sh index e446843..86ba065 100755 --- a/Scripts/build-release.sh +++ b/Scripts/build-release.sh @@ -2,7 +2,7 @@ set -euo pipefail PROJECT_ROOT="${0:A:h:h}" -export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer" +export DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}" TEMP_ROOT="${TMPDIR:-/tmp}" DERIVED_DATA="$(mktemp -d "${TEMP_ROOT%/}/RawGeoSync-Release.XXXXXX")" OUTPUT_ROOT="${RAWGEOSYNC_INSTALL_DIR:-${HOME}/Applications}" diff --git a/Scripts/build.sh b/Scripts/build.sh index 9531974..72de146 100755 --- a/Scripts/build.sh +++ b/Scripts/build.sh @@ -2,7 +2,7 @@ set -euo pipefail PROJECT_ROOT="${0:A:h:h}" -export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer" +export DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}" TEMP_ROOT="${TMPDIR:-/tmp}" DERIVED_DATA="$(mktemp -d "${TEMP_ROOT%/}/RawGeoSync-Debug.XXXXXX")" OUTPUT_ROOT="$PROJECT_ROOT/.local/Debug" diff --git a/Scripts/ci.sh b/Scripts/ci.sh index 543b0e3..9b3eb13 100755 --- a/Scripts/ci.sh +++ b/Scripts/ci.sh @@ -2,7 +2,7 @@ set -euo pipefail PROJECT_ROOT="${0:A:h:h}" -export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer" +export DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}" TEMP_ROOT="${TMPDIR:-/tmp}" DERIVED_DATA="$(mktemp -d "${TEMP_ROOT%/}/RawGeoSync-CI.XXXXXX")" diff --git a/Scripts/format-check.sh b/Scripts/format-check.sh index 5217492..8c89295 100755 --- a/Scripts/format-check.sh +++ b/Scripts/format-check.sh @@ -2,7 +2,7 @@ set -euo pipefail PROJECT_ROOT="${0:A:h:h}" -export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer" +export DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}" SWIFT_FILES=() while IFS= read -r file; do diff --git a/Scripts/test.sh b/Scripts/test.sh index a2e3977..d5b2d90 100755 --- a/Scripts/test.sh +++ b/Scripts/test.sh @@ -2,7 +2,7 @@ set -euo pipefail PROJECT_ROOT="${0:A:h:h}" -export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer" +export DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}" TEMP_ROOT="${TMPDIR:-/tmp}" DERIVED_DATA="$(mktemp -d "${TEMP_ROOT%/}/RawGeoSync-Tests.XXXXXX")" From 4459c3c740dd1e2d0111d326a2d587aeab5f029b Mon Sep 17 00:00:00 2001 From: SSSimpleC <89213712+SSSimpleC@users.noreply.github.com> Date: Mon, 10 Aug 2026 02:31:46 +0800 Subject: [PATCH 3/5] ci: expose quality gate stages --- Scripts/ci.sh | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/Scripts/ci.sh b/Scripts/ci.sh index 9b3eb13..d180cea 100755 --- a/Scripts/ci.sh +++ b/Scripts/ci.sh @@ -4,7 +4,16 @@ set -euo pipefail PROJECT_ROOT="${0:A:h:h}" export DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}" TEMP_ROOT="${TMPDIR:-/tmp}" -DERIVED_DATA="$(mktemp -d "${TEMP_ROOT%/}/RawGeoSync-CI.XXXXXX")" + +stage() { + print "\n==> $1" +} + +stage "准备CI临时目录" +DERIVED_DATA="$(mktemp -d "${TEMP_ROOT%/}/RawGeoSync-CI.XXXXXX")" || { + print -u2 "无法在 $TEMP_ROOT 创建CI临时目录" + exit 1 +} cleanup() { case "$DERIVED_DATA" in @@ -16,6 +25,7 @@ cleanup() { trap cleanup EXIT INT TERM XCODE_VERSION="$(xcodebuild -version | awk 'NR == 1 { print $2 }')" +print "使用Xcode $XCODE_VERSION($DEVELOPER_DIR)" awk -v actual="$XCODE_VERSION" -v minimum="26.3" ' BEGIN { split(actual, a, "."); @@ -29,20 +39,27 @@ awk -v actual="$XCODE_VERSION" -v minimum="26.3" ' exit 1 } +stage "检查仓库隐私与文件策略" "$PROJECT_ROOT/Scripts/repository-policy-check.sh" +stage "校验内置ExifTool" "$PROJECT_ROOT/Scripts/verify-vendor.sh" +stage "检查Swift格式" "$PROJECT_ROOT/Scripts/format-check.sh" +stage "运行Debug测试与应用构建" "$PROJECT_ROOT/Scripts/test.sh" +stage "运行RawGeoCore Release测试" swift test \ --configuration release \ --package-path "$PROJECT_ROOT/RawGeoCore" \ --scratch-path "$DERIVED_DATA/SwiftPM/RawGeoCore-Release" +stage "运行MetadataInfrastructure Release测试" swift test \ --configuration release \ --package-path "$PROJECT_ROOT/MetadataInfrastructure" \ --scratch-path "$DERIVED_DATA/SwiftPM/MetadataInfrastructure-Release" +stage "构建Release应用" xcodebuild \ -project "$PROJECT_ROOT/RawGeoSync.xcodeproj" \ -scheme RawGeoSync \ @@ -52,6 +69,7 @@ xcodebuild \ CODE_SIGNING_ALLOWED=NO \ build +stage "运行静态分析" xcodebuild \ -project "$PROJECT_ROOT/RawGeoSync.xcodeproj" \ -scheme RawGeoSync \ From d45d71133fd428b86740e98804755857277bb432 Mon Sep 17 00:00:00 2001 From: SSSimpleC <89213712+SSSimpleC@users.noreply.github.com> Date: Mon, 10 Aug 2026 02:32:57 +0800 Subject: [PATCH 4/5] ci: trace repository policy checks --- Scripts/repository-policy-check.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Scripts/repository-policy-check.sh b/Scripts/repository-policy-check.sh index 1d575eb..6d75cde 100755 --- a/Scripts/repository-policy-check.sh +++ b/Scripts/repository-policy-check.sh @@ -9,11 +9,13 @@ fail() { exit 1 } +print "检查脚本语法与权限" for script in Scripts/*.sh; do /bin/zsh -n "$script" || fail "$script 不是有效的zsh脚本" [[ -x "$script" ]] || fail "$script 缺少可执行权限" done +print "收集仓库文件清单" SCAN_FILES=() REPOSITORY_FILES=() while IFS= read -r repository_file; do @@ -27,6 +29,7 @@ while IFS= read -r repository_file; do esac done < <(git ls-files --cached --others --exclude-standard) +print "检查本机绝对路径" ABSOLUTE_PATH_SCAN_FILES=() for scan_file in "${SCAN_FILES[@]}"; do [[ "$scan_file" == "Scripts/repository-policy-check.sh" ]] \ @@ -41,6 +44,7 @@ ABSOLUTE_PATH_MATCHES="$( fail "跟踪文件包含本机绝对路径" } +print "检查密钥与访问令牌" SECRET_MATCHES="$( grep -nEH '(BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY|AKIA[0-9A-Z]{16}|ghp_[0-9A-Za-z]{20,}|github_pat_[0-9A-Za-z_]{20,}|sk-[0-9A-Za-z_-]{20,}|xox[baprs]-[0-9A-Za-z-]{10,})' \ -- "${SCAN_FILES[@]}" || true @@ -50,6 +54,7 @@ SECRET_MATCHES="$( fail "跟踪文件疑似包含私钥或访问令牌" } +print "检查高精度坐标" SENSITIVE_TEXT_FILES=() for scan_file in "${SCAN_FILES[@]}"; do case "$scan_file" in @@ -74,6 +79,7 @@ COORDINATE_MATCHES="$( fail "文档或脚本疑似包含高精度坐标" } +print "检查禁止纳入版本控制的媒体与回归产物" FORBIDDEN_TRACKED_FILES=() for repository_file in "${REPOSITORY_FILES[@]}"; do lower_name="${repository_file:l}" @@ -100,6 +106,7 @@ done fail "仓库跟踪了真实照片、XMP或非合成GPX" } +print "检查Git空白字符" git diff --check git diff --cached --check git show --check --format= HEAD >/dev/null From 486e8e54605feca9f34397c9640f0a82a8aa9eae Mon Sep 17 00:00:00 2001 From: SSSimpleC <89213712+SSSimpleC@users.noreply.github.com> Date: Mon, 10 Aug 2026 02:33:52 +0800 Subject: [PATCH 5/5] ci: fetch history for commit policy checks --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 919fd55..ed3f6e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + fetch-depth: 0 - name: Show toolchain run: |