From 9c864e0780361cd15b1343d5470c369df199232c Mon Sep 17 00:00:00 2001 From: Matt Gradwohl <3945134+mgradwohl@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:08:45 -0600 Subject: [PATCH] Save fixed movie calibration in Tracker projects --- README.md | 6 ++- docs/migration-plan.md | 18 ++++---- docs/native-format.md | 31 +++++++++---- docs/upstream-issues.md | 9 ++++ src/tracker_writer.cpp | 10 ++++- tests/corpus/README.md | 27 ++++++++++- tests/corpus/manifest.json | 3 ++ tests/corpus_probe.cpp | 26 +++++++++-- tests/paired_workspace_test.cpp | 52 ++++++++++++++++++++++ tests/test_corpus.py | 68 ++++++++++++++++++++++++++-- tests/tracker_writer_test.cpp | 79 ++++++++++++++++++++++++++++++++- tools/corpus.py | 69 +++++++++++++++++++++------- 12 files changed, 353 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index e253e0c..673ef3b 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,9 @@ audited constant-rate imports after timestamp indexing; its edited-file Tracker has passed for the pinned linked four-frame AVI and mixed-tab MOV archive cases. Later revisions require their own CI and review. Archived AVI output is refused because pinned Tracker cannot reopen it; the original case and compatibility restriction are tracked in [#140](https://github.com/mgradwohl/MoTrack/issues/140). -Movie timing/calibration/track-identity edits, broader movie containers, filters and broader settings remain unfinished. An unsupported export is refused; save a MoTrack copy to retain work. +Existing fixed movie calibration can also retain axes, distance-scale and length-label edits through +linked saves, MOV archives and paired saving. Its new actual-Tracker acceptance cases await current-revision CI. +Movie timing, varying calibration and track-identity edits, broader movie containers, filters and broader settings remain unfinished. An unsupported export is refused; save a MoTrack copy to retain work. Image experiments can retain Clip Settings changes to the starting frame, ending frame and frame interval in linked `.trk` files, existing image archives and portable image exports. All source images and marks remain available, including those outside the selected clip; image timing stays unchanged. @@ -93,7 +95,7 @@ history; exporting a copy does not replace ordinary Save or create a companion a Export runs in the background with a Cancel button. Missing media files are named in the error message, and failures or cancellation before replacement keep an existing archive intact. This command currently -supports audited local-image schemas and linked MOV mass/mark/clip-range edits described in the +supports audited local-image schemas and linked MOV mass/mark/clip-range and existing fixed-calibration edits described in the [format guide](docs/native-format.md#creating-a-portable-movie-archive). The entire movie is retained, including frames outside the selected clip. Other movie containers, arbitrary supporting documents and newly created standalone media projects remain unfinished. diff --git a/docs/migration-plan.md b/docs/migration-plan.md index 5b2f5f5..1a85f21 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -80,7 +80,7 @@ selected archive-member writer ([#121](https://github.com/mgradwohl/MoTrack/pull all-experiment archive acceptance ([#124](https://github.com/mgradwohl/MoTrack/pull/124)) and paired-save recovery adapter ([#125](https://github.com/mgradwohl/MoTrack/pull/125)) are also merged. -The save-workflow queue is merged through PR #139, including the test-group correction in #138. Each row passed all six required checks and code +The save-workflow queue is merged through PR #141, including the test-group correction in #138. Each row passed all six required checks and code review on the listed revision before merging; the links record the actual acceptance evidence. | Merged slice | Reviewed revision | Passing CI | @@ -95,9 +95,11 @@ review on the listed revision before merging; the links record the actual accept | [#138: bounded session/journal test groups](https://github.com/mgradwohl/MoTrack/pull/138) | 71b3494 | [42556009533](https://github.com/mgradwohl/MoTrack/actions/runs/42556009533) | | [#136: movie clip range/stride saving](https://github.com/mgradwohl/MoTrack/pull/136) | 4cf2b0f | [42561178958](https://github.com/mgradwohl/MoTrack/actions/runs/42561178958) | | [#139: existing MOV archive editing and AVI guard](https://github.com/mgradwohl/MoTrack/pull/139) | f166ba9 | [42568545597](https://github.com/mgradwohl/MoTrack/actions/runs/42568545597) | +| [#141: portable MOV packaging and desktop export](https://github.com/mgradwohl/MoTrack/pull/141) | d0e1a66 | [42583275559](https://github.com/mgradwohl/MoTrack/actions/runs/42583275559) | -The final row passes Windows, macOS, Linux, both contributor setup jobs and **95.82% combined C++ line -coverage (8887/9275)**. All four edited MOV archive modes also load, save and freshly reopen both experiments, +The final row passes Windows, macOS, Linux, both contributor setup jobs and **95.83% combined C++ line +coverage (8931/9320)**. All three portable MOV modes pass actual Tracker load/save/fresh reopen after +removal of the original external media. All four edited MOV archive modes also load, save and freshly reopen both experiments, check every media byte and reimport both natively. The four image-clip modes and both movie-clip modes pass MoTrack native reopen/standard export, actual Tracker load/save/fresh-process reopen and native reimport. The complete saved image-reference list, every image's bytes and the full movie hash are checked after Tracker resaves and reopens, including excluded frames. @@ -172,13 +174,13 @@ and platform evidence before it can be marked complete. | --- | --- | --- | --- | | R01 | Reproducible native builds, CI, review, numerical tests, at least 90% combined C++ line coverage | Implemented foundation; continue applying gates to every new component. The reference helper executes repeated loader completion callbacks once, fixing the empty-resave race in #105; bounded failure diagnostics remain and no retry or acceptance bypass is added. The #137 infrastructure correction partitions journal/session cases with complementary filters and unchanged 60-second limits; full case-inventory and CI validation remain required. | 0, every phase | | R02 | Open `.trk`/`.trz`; preserve all supported and unknown content, references, metadata, and archive tabs | Partial: supported point masses/vectors/calibration import; one archive tab selected. Native documents retain that tab's original XML, editable-track IDs and source-object associations for new imports. Version 5 embeds the exact original archive, including all tabs/media/unknown files. A partial read-only reference index maps original track names, panel/nested-view selections, table/plot owners (including dataset rows), ordered plot guests, mechanical-tool attachment slots/step targets center-of-mass/vector-sum members, model boosters and dynamic-system members to source nodes and known IDs; unknown subclass relationships remain explicit. Dataset rows have source-structure evidence only; media-backed application acceptance remains. Older native sources retain an explicit unknown-association state. | 1–3 | -| R03 | Save edited standard files plus optional `.motrack`; reopen in both applications without data loss | Partial: native source/archive preservation and audited standard writers are merged. Depending on the source schema, verified edits include mass/marks, independent vectors, clip ranges, media-free constant timing, fixed/partly fixed/varying calibration, unit labels, audited renames, independent track creation and supported removal/selection/plot/table cleanup. The [format guide](native-format.md) defines those per-schema limits. Linked images, selected image-archive members and indexed constant-rate linked movies have native reopen/relocation and actual Tracker acceptance. Per-output checkpoints, explicit recovery, desktop paired Save and portable image export are merged through #133. Image clip ranges/strides are merged in #134 with all four linked/archive/package application cases, including complete post-resave image inventory checks. Movie clip ranges/strides are merged in #136 with both application cases and complete post-resave movie hashes. Existing MOV-archive saving is merged in #139 with all four mixed-tab actual-Tracker modes passing; the original AVI extraction failure remains pinned and refused under #140. New portable MOV packaging has a native adapter and desktop export path, with three full/strided/single-frame application modes pending current-head CI and review. Untouched archive experiments are checked independently. Remaining work includes broader schemas and dependencies, dataset-owner removal, portable movie acceptance and broader containers, broader media timing and full export authoring. Known Tracker table-format losses (#64) and vector-sum losses (#63) retain strict guards and independent failing expectations. | 2–3, every feature | +| R03 | Save edited standard files plus optional `.motrack`; reopen in both applications without data loss | Partial: native source/archive preservation and audited standard writers are merged. Depending on the source schema, verified edits include mass/marks, independent vectors, clip ranges, media-free constant timing, fixed/partly fixed/varying calibration, unit labels, audited renames, independent track creation and supported removal/selection/plot/table cleanup. The [format guide](native-format.md) defines those per-schema limits. Linked images, selected image-archive members and indexed constant-rate linked movies have native reopen/relocation and actual Tracker acceptance. Per-output checkpoints, explicit recovery, desktop paired Save and portable image export are merged through #133. Image clip ranges/strides are merged in #134 with all four linked/archive/package application cases, including complete post-resave image inventory checks. Movie clip ranges/strides are merged in #136 with both application cases and complete post-resave movie hashes. Existing MOV-archive saving is merged in #139 with all four mixed-tab actual-Tracker modes passing; the original AVI extraction failure remains pinned and refused under #140. Portable MOV packaging and desktop export are merged in #141 with all three full/strided/single-frame actual-Tracker modes passing. Existing fixed movie calibration and length-label edits now have native/desktop regressions and four application modes awaiting current-revision CI/review. Untouched archive experiments are checked independently. Remaining work includes broader schemas and dependencies, dataset-owner removal, keyed movie calibration and broader containers, broader media timing and full export authoring. Known Tracker table-format losses (#64) and vector-sum losses (#63) retain strict guards and independent failing expectations. | 2–3, every feature | | R04 | Multiple experiment tabs, independent undo/dirty state, tab/project Save As, cross-tab import and reusable views | Partial foundation: document sessions own independent data/history/save checkpoints, and native storage preserves unopened archive tabs. The desktop still has one active experiment; multi-tab controllers/UI, cross-tab import and reusable views remain. | 2–4 | -| R05 | Movies, stills, image sequences and zipped sequences; accurate clip/time/frame controls and media properties | Partial: local movies/images, trim/stride, indexing, orientation, bounded caching and playback exist. Media-free duration/time-origin edits have history and export oracles. Four-image and four-frame constant-rate AVI fixtures independently check pixels, order, timestamps, clip time and calibrated marks. The movie baseline (#132) passes actual Tracker load/save/fresh reopen; existing mass/mark export (#133) also passes native reimport; movie edits require unchanged timing/calibration/identity and a matching completed native index. Image clip edits (#134) pass all four linked/archive/package cases while preserving source frame numbers, measurements and every image, including excluded frames. See the merged CI table above. Strided/single-frame movie range saving is merged in #136 with actual Tracker acceptance. Existing MOV-archive mass/mark/clip saving is merged in #139 with four passing mixed-tab application modes; archived AVI output is refused after the reproduced extraction failure (#140). New portable MOV packaging copies the complete movie and validates its staged index; native/desktop regressions pass, while actual-Tracker acceptance remains pending. Variable-rate timing, broader movie packaging, broader formats/properties/controls, numeric media timing edits and media export remain. The synthetic AVI does not reproduce or resolve the historical MOV/Open Recent report in #65. | 1, 3–4, 6 | +| R05 | Movies, stills, image sequences and zipped sequences; accurate clip/time/frame controls and media properties | Partial: local movies/images, trim/stride, indexing, orientation, bounded caching and playback exist. Media-free duration/time-origin edits have history and export oracles. Four-image and four-frame constant-rate AVI fixtures independently check pixels, order, timestamps, clip time and calibrated marks. The movie baseline (#132) passes actual Tracker load/save/fresh reopen; existing mass/mark export (#133) also passes native reimport; movie edits require unchanged numeric timing/identity and a matching completed native index; new fixed-calibration/length-label saving awaits its own application acceptance. Image clip edits (#134) pass all four linked/archive/package cases while preserving source frame numbers, measurements and every image, including excluded frames. See the merged CI table above. Strided/single-frame movie range saving is merged in #136 with actual Tracker acceptance. Existing MOV-archive mass/mark/clip saving is merged in #139 with four passing mixed-tab application modes; archived AVI output is refused after the reproduced extraction failure (#140). Portable MOV packaging is merged in #141 with staged-index validation and all three actual-Tracker modes passing. Fixed movie calibration saving has four new reference modes and native/desktop regressions, with current-revision application CI/review pending. Variable-rate timing, broader movie packaging, broader formats/properties/controls, numeric media timing edits and media export remain. The synthetic AVI does not reproduce or resolve the historical MOV/Open Recent report in #65. | 1, 3–4, 6 | | R06 | Complete point-mass editing and appearance: selection, dragging/nudging, numeric positions, multiselect, locks, labels, trails, paths, and vector overlays | Partial: manual marking/replacement, repeat, rename/delete, mass, and undo exist. Finish manipulation, appearance, path length, and selection behavior. | 4–5 | | R07 | Independent vectors, linked vectors, sums, their data, editing, and persistence | Partial: independent vectors import/display/edit/undo/native-save. Internal sample export, new-vector creation and unreferenced removal have independent empty/sparse/zero-vector reference cases; native selection-cleanup regressions cover empty and replacement vectors; creation retains the snapping/precision guards and requires an audited independent-vector/axes source. A Java-authored vector-sum case retains independent expected results but reproduces a Tracker 6.3.5 defect: members never resolve and resave loses their names. This is explicitly not accepted round-trip evidence; the internal writer rejects edited projects containing vector sums, including empty member tracks. Native read-only indexing now records original member links with explicit ambiguity/unclassified targets. Native links/sum evaluation, analysis and desktop standard saving remain. | 3–5 | | R08 | Center-of-mass tracks, constituents, dependent updates, and moving reference frames | Native evaluation/editing missing. A Java-authored center-of-mass reference checks named constituents, total mass and independent weighted positions; native member-link indexing and constituent-name export preserve original targets. The rename case requires unchanged 5 kg total mass and weighted positions through actual Tracker resave; native dependency evaluation, constituent editing and moving reference frames remain. | 2, 5 | -| R09 | Fixed or varying calibration, axes, calibration sticks/points, offset origins, attachments, coordinate locks, and L/M/T/angle units | Partial: imported calibration keys, basic distance calibration and axes exist. Internal fixed-calibration and length/mass/time-unit export have reference cases. A Java-authored varying-calibration case checks added/edited keys and world positions at boundaries. Tape-attachment reference and target-rename cases require normal Java target resolution, retained empty slots and calibrated lengths; native attachment evaluation, other edited dependencies and protractor/circle application cases remain. Six partly fixed combinations now have native export, history and independent reference cases with unchanged fixed flags. Finish fixed-state toggles/moving calibration, key deletion, varying vectors, tool tracks, units UI/defaults and reference frames. | 3–5 | +| R09 | Fixed or varying calibration, axes, calibration sticks/points, offset origins, attachments, coordinate locks, and L/M/T/angle units | Partial: imported calibration keys, basic distance calibration and axes exist. Internal fixed-calibration and length/mass/time-unit export have reference cases. A Java-authored varying-calibration case checks added/edited keys and world positions at boundaries. Tape-attachment reference and target-rename cases require normal Java target resolution, retained empty slots and calibrated lengths; native attachment evaluation, other edited dependencies and protractor/circle application cases remain. Six partly fixed media-free combinations now have native export, history and independent reference cases with unchanged fixed flags. Existing fixed movie calibration now has native save/reopen/history/paired-save regressions and four application modes pending current-revision acceptance; keyed movie edits remain refused. Finish fixed-state toggles/moving calibration, key deletion, varying vectors, tool tracks, units UI/defaults and reference frames. | 3–5 | | R10 | Persistent tape measures, protractors, circle fitting, attachments, and their changing measurements | Partial: temporary distance/angle tools only; these are not persisted Tracker track equivalents. Source attachment indexing covers tapes/protractors/circle fitters, with a three-tape Java reference case for full/partial attachment and changing lengths. Read-only tape target-renames have an application round-trip case; native persistent tools, tool geometry edits and other standard round trips remain. | 5 | | R11 | Full AutoTracker: masks, search areas, keyframes, target offsets, match scores, subpixel positions, evolution/tethering, look-ahead, line search, stop/retry/skip, and live/fast modes | Partial: fixed grayscale patch and bounded search on a point mass, background operation, uncertainty stop. Reference algorithm and interaction parity remain. | 5–7 | | R12 | Ordered, editable video filter stack with saved parameters and reproducible filtered frames | Missing: enhancement, temporal, noise-reduction, geometric, and lens-correction filters; see the filter inventory below. | 6 | @@ -335,7 +337,7 @@ Scoped image/archive/movie session loading now has the merged acceptance evidenc requirement coverage, runtime/dependency provenance and exact source mapping remain unfinished. The initial phase-3 writer/reference path covers mass values, marks, independent-vector samples, clip ranges/constant timing, existing fixed/partly fixed/fully varying calibration, length/mass/time-unit labels, audited names, new independent point masses/vectors, unreferenced removals, selection cleanup and custom plot/table owner removal, including native reopen and Java resave acceptance gates. Expand it -to dataset-owner deletion, broader dependencies, portable movie acceptance/broader containers and the remaining media timing/export +to dataset-owner deletion, broader dependencies, keyed movie calibration/broader containers and the remaining media timing/export schemas. Desktop paired Save is available for the audited schemas; broader standard Save remains unfinished. Phase 2 has an XML source envelope, persistent point-mass/vector IDs, original object associations, commands addressed by ID, native version migration and complete original archive preservation. `DocumentSession` now owns each document's data/history/native save state; @@ -424,7 +426,7 @@ archive, preserving other tabs and media without yet making those tabs editable. Version-1 native documents cannot recover previously discarded content. Audited media-free/local-image standard saves, existing image-archive updates, paired-save recovery and portable image packaging now have implementation and scoped actual-Tracker evidence in R03. The desktop exposes paired saving and portable -image export, and audited constant-rate linked-movie mass/mark edits pass actual Tracker acceptance. +image/MOV export, and audited constant-rate movie mass/mark/clip edits pass actual Tracker acceptance. Broader movie export, schemas and full writers remain unfinished. Treat full round-trip support as a compatibility milestone, not a completed capability. diff --git a/docs/native-format.md b/docs/native-format.md index 0b0a4ff..edfeabf 100644 --- a/docs/native-format.md +++ b/docs/native-format.md @@ -651,14 +651,27 @@ Windows/macOS and actual Tracker package acceptance, with 95.72% combined C++ li ### Linked constant-rate movie edits The document writer accepts the explicitly audited `XuggleVideo`/`VideoClipControl` schema only through -its movie adapter. Existing point-mass values and image-coordinate marks, clip start/step/count and the -linked media path may change. Original names, membership, calibration, unit labels, numeric clip timing -and other parsed fields must remain unchanged. Filters, vectors, other track types except axes, display-orientation +its movie adapter. Existing point-mass values and image-coordinate marks, clip start/step/count, existing +fixed calibration, length-unit labels and the linked media path may change. Original names, membership, +mass/time-unit labels, numeric clip timing and other parsed fields must remain unchanged. Filters, vectors, other track types except axes, display-orientation transforms remain refused. The selected-member archive writer uses the same audited movie adapter after verifying its immutable source, media members and completed native index. This opt-in schema does not enable movie renames or membership edits through the media-free writer. +Movie calibration edits require one original and edited frame-0 key with origin, angle and scale all +fixed. The shared coordinate writer validates finite/invertible values and image/world precision before +replacing the output. Creating/removing calibration, changing fixed flags, locked or unsafe transforms +and keyed/partly fixed movie calibration edits remain refused. Existing axes/distance commands retain +undo/redo and length labels together; native reopen, selected MOV archive saves, paired recovery and +portable MOV packaging use the same validated writer. + +Four new [movie-calibration corpus modes](../tests/corpus/README.md#fixed-movie-calibration-saving) +independently check a moved origin, 90-degree rotation, nonuniform scale, centimetre labels and all +calculated positions, plus complete movie bytes/times and the untouched image tab. Native export/oracle/ +reimport and desktop paired-save regressions pass locally. Actual Tracker load/save/fresh-reopen acceptance +for this new calibration scope still requires current-revision Linux CI and review. + Default point-mass number formats use the same movie-aware schema audit and remain in the exported XML after native reopen. Custom formats remain refused under the existing #64 fidelity guard. @@ -732,7 +745,7 @@ actual Tracker movie-range cases and **95.72% combined C++ line coverage (8874/9 An existing `.trz` containing an audited constant-rate MOV file can use the selected-member writer and paired desktop Save workflow. The original archive, other experiments, compressed media and opaque members retain their existing preservation contract. The selected XML may change only within the -movie adapter's mass/mark/clip-range limits. Native close/reopen and removal of the original input do +movie adapter's mass/mark/clip-range and existing fixed-calibration limits. Native close/reopen and removal of the original input do not break the owned archive or index. Both root and nested selected-member paths are covered. The desktop regression edits mass and clip settings, uses undo/redo and ordinary paired Save, removes @@ -783,7 +796,7 @@ project persistence does not link media decoders. The package contains `experime `media/movie.mov`; the complete source movie is copied byte for byte, including frames excluded by a trimmed or strided clip. No transcode, frame renumbering or timestamp change is performed. -The standalone movie writer validates mass/mark/clip-range edits and unchanged timing, calibration and +The standalone movie writer validates mass/mark/clip-range and existing fixed-calibration edits, with unchanged timing and track identity against the retained source envelope. Staging clears runtime media bindings only in its private snapshot, so generated members resolve inside the package. Before atomic replacement, the normal archive reader reopens the ZIP, resolves its media, and FFmpeg decodes/indexes the staged movie. @@ -803,9 +816,11 @@ subsequent embedded native saving and archive resave, stale timing, corrupt sour cancellation and shared save locks/recovery. Desktop cases exercise export after indexing, ordinary Save and undo/redo after export, cancellation, refusal of AVI, media removal/relocation, frame seeking and paired saving of the reopened package. Three independent corpus modes require actual Tracker acceptance: -`packaged-movie`, `packaged-movie-clip` and `packaged-movie-clip-single`. Their current-head Linux -application results, three-platform CI and review are still required; local/native checks alone are -not accepted interoperability evidence. Broader movie formats and full export authoring remain unfinished. +`packaged-movie`, `packaged-movie-clip` and `packaged-movie-clip-single`. All three passed actual Tracker +load/save/fresh reopen in [PR #141](https://github.com/mgradwohl/MoTrack/pull/141), reviewed at `d0e1a66`; +[CI 42583275559](https://github.com/mgradwohl/MoTrack/actions/runs/42583275559) passed all six checks with +**95.83% combined C++ line coverage (8931/9320)**. The new `packaged-movie-calibration` case still awaits +its own current-revision acceptance. Broader movie formats and full export authoring remain unfinished. Local Windows validation at `f18cecd` passes all ten CTest groups in **127.87 seconds** with **95.75% C++ line coverage (8897/9292)**, 90 Python corpus tests and all three native package diff --git a/docs/upstream-issues.md b/docs/upstream-issues.md index 9953f2d..3550b08 100644 --- a/docs/upstream-issues.md +++ b/docs/upstream-issues.md @@ -42,6 +42,15 @@ record the test evidence and update the plan. The broader post-port review in [MoTrack #39](https://github.com/mgradwohl/MoTrack/issues/39) remains separate from correctness and compatibility work that must happen during the port. +On **2026-11-09**, Tracker #12/#13 and their complete discussions were refreshed for the fixed-movie +calibration save slice. #12 remains a confirmed upstream two-click workflow; #13 remains open with the +historical second-endpoint report unreproduced by its maintainer. The native desktop regression now +places axes and both distance endpoints through the existing input controller, checks undo/redo, and +saves/reopens fixed calibration in both file formats. Four independent movie save/reference modes are +added with current-revision application CI still pending. This is scoped phase-3 persistence evidence; +[MoTrack #78](https://github.com/mgradwohl/MoTrack/issues/78) stays open for the full phase-5 tool, +selection/dragging, zoom/DPI and cancellation acceptance. The original reported bug is not claimed reproduced. + ## Defects discovered by the reference harness | Observation | Evidence and current limit | MoTrack tracking | diff --git a/src/tracker_writer.cpp b/src/tracker_writer.cpp index d11a19a..a7d965b 100644 --- a/src/tracker_writer.cpp +++ b/src/tracker_writer.cpp @@ -1020,8 +1020,16 @@ bool patchLinkedMoviePath(QDomDocument& xml, const LegacyProject& project, Legac require(project.clip.mediaReferences.size() == 1 && baseline.clip.mediaReferences.size() == 1 && project.vectors.empty() && std::ranges::all_of(project.unsupportedTrackTypes, [](const auto& type) { return type == "org.opensourcephysics.cabrillo.tracker.CoordAxes"; })); - // Movie edits retain original timing, geometry and identity. Clip ranges keep source frame numbers. + // Movie edits retain original timing and identity. Clip ranges keep source frame numbers. + // Existing fixed calibration uses the shared coordinate writer and its numerical checks below. auto unchanged = project; + if (project.calibration != baseline.calibration) + { + require(baseline.calibration && baseline.calibration->fixedOrigin && baseline.calibration->fixedAngle && + baseline.calibration->fixedScale); // Keyed movie calibration needs its own application reference. + unchanged.calibration = baseline.calibration; + } + unchanged.lengthUnit = baseline.lengthUnit; unchanged.clip.mediaReferences = baseline.clip.mediaReferences; unchanged.clip.firstFrame = baseline.clip.firstFrame; unchanged.clip.stepSize = baseline.clip.stepSize; diff --git a/tests/corpus/README.md b/tests/corpus/README.md index d39d60f..eeebe31 100644 --- a/tests/corpus/README.md +++ b/tests/corpus/README.md @@ -1216,5 +1216,28 @@ Tracker opens the actual `.trz`, saves through its normal application saver and process/private home. The normal persistent cache policy is required in both reports. Every source movie byte must remain available after saving and again after fresh reopen, including excluded frames; independent pixels, frame ordinals, timestamps, geometry, mass and untouched content remain checked. -Native reimport of Tracker's resave closes the loop. These new modes still require their current-head -Linux application run and three-platform CI/review; native or mocked oracle checks are not that evidence. +Native reimport of Tracker's resave closes the loop. All three modes passed actual Tracker acceptance in +[PR #141](https://github.com/mgradwohl/MoTrack/pull/141) at reviewed revision `d0e1a66`, with all six checks +and **95.83% combined coverage (8931/9320)** in [CI 42583275559](https://github.com/mgradwohl/MoTrack/actions/runs/42583275559). + +### Fixed movie calibration saving + +`linked-movie-calibration`, `archive-movie-calibration`, `paired-archive-movie-calibration` and +`packaged-movie-calibration` reuse the pinned AVI/MOV sources and unchanged asset hashes. The native +probe edits mass/mark 1, then uses the real axes and distance commands at original frames 1 and 2. +Undo twice restores the previous project; redo twice restores the edited project. The commands move +the fixed frame-0 origin to (10,18), rotate it to 90 degrees, change nonuniform scales to (4,8), and +set the length label to `cm`. Native save/reopen and original-source removal precede standard output; +the paired archive mode interrupts and recovers both outputs, and packaging removes external media. + +Independent expected coordinates use `X=(18-y)/8`, `Y=(10-x)/4`. Image marks (4,16), (8,12), (12,4) +must become (0.25,1.5), (0.75,0.5), (1.75,-0.5); probe point (13.5,17.25) must become +(0.09375,-0.875). XML comparison permits only the five coordinate values, length label and previously +specified mass/mark/path edits. Movie bytes, all frame timestamps, source-frame identities, axes display +settings and the second archive experiment remain independently checked. Negative tests corrupt origin, +rotation, scales, label or calculated positions, including the same corruption at every application stage. + +The normal Linux application runner requires Tracker load, save, fresh-process reopen and native +reimport for all four modes. Local Windows native export/oracle/reimport passes; current-revision Linux +acceptance and code review remain pending. This slice does not accept keyed/partly fixed movie +calibration, timing edits, filters or broader movie containers, and does not close MoTrack #78. diff --git a/tests/corpus/manifest.json b/tests/corpus/manifest.json index e13396f..2618c17 100644 --- a/tests/corpus/manifest.json +++ b/tests/corpus/manifest.json @@ -140,6 +140,7 @@ "xuggle-xuggler-server-all-5.7.0-20210422.000415-2.jar", "slf4j-api-1.7.30.jar" ], + "native_archive_movie_calibration_roundtrip": true, "native_archive_movie_roundtrip": true, "project_member": "experiment.trk", "session_archive": true, @@ -163,6 +164,7 @@ }, { "id": "linked-mov", + "native_movie_package_calibration_roundtrip": true, "native_movie_package_roundtrip": true, "tracker_version": "6.3.5", "file": "linked-movie.trk", @@ -1700,6 +1702,7 @@ }, { "id": "linked-movie", + "native_linked_movie_calibration_roundtrip": true, "native_linked_movie_roundtrip": true, "native_linked_movie_clip_roundtrip": true, "tracker_version": "6.3.5", diff --git a/tests/corpus_probe.cpp b/tests/corpus_probe.cpp index ab6d847..983c049 100644 --- a/tests/corpus_probe.cpp +++ b/tests/corpus_probe.cpp @@ -177,6 +177,18 @@ void saveAndRecoverPair(const QString& path, const motrack::WorkspaceDocument& d throw motrack::ProjectError("Recovered native companion changed the edited project or original source."); } +void editMovieCalibration(motrack::WorkspaceDocument& document, motrack::TrackEdits& edits) +{ + const auto before = document.project; + if (!edits.setCoordinateAxes(document.project, {.frame = 1, .originX = 10, .originY = 18, .positiveX = 10, .positiveY = 10}) || + !edits.calibrateDistance(document.project, {.frame = 2, .imageDeltaX = 8, .imageDeltaY = 0, .knownLength = 2, .unit = "cm"})) + throw motrack::ProjectError("Could not calibrate the movie reference."); + const auto edited = document.project; + if (!edits.undo(document.project) || !edits.undo(document.project) || document.project != before || !edits.redo(document.project) || + !edits.redo(document.project) || document.project != edited) + throw motrack::ProjectError("Movie calibration history did not preserve the experiment."); +} + } // namespace int main(int argc, char** argv) @@ -203,7 +215,8 @@ try if (args.size() == 4 && (args[1] == "--export-archive-images" || args[1] == "--export-paired-archive-images" || args[1] == "--export-multitab-archive-images" || args[1] == "--export-archive-image-clip" || args[1] == "--export-archive-movie" || args[1] == "--export-archive-movie-clip" || - args[1] == "--export-archive-movie-clip-single" || args[1] == "--export-paired-archive-movie")) + args[1] == "--export-archive-movie-clip-single" || args[1] == "--export-paired-archive-movie" || + args[1] == "--export-archive-movie-calibration" || args[1] == "--export-paired-archive-movie-calibration")) { const bool movie = args[1].contains("movie"); QTemporaryDir temporary; @@ -239,6 +252,8 @@ try !edits.markPointMass(document.project, 0, {.frame = 1, .x = 8.0, .y = 12.0}) || !edits.undo(document.project) || !edits.redo(document.project)) throw motrack::ProjectError("Could not edit archived media reference."); + if (args[1].endsWith("-movie-calibration")) + editMovieCalibration(document, edits); if (args[1] == "--export-archive-image-clip" || args[1] == "--export-archive-movie-clip" || args[1] == "--export-archive-movie-clip-single") { @@ -253,7 +268,7 @@ try if (!QFile::remove(source)) throw motrack::ProjectError("Could not remove original archive reference."); document = motrack::readWorkspaceDocument(native); - if (args[1] == "--export-paired-archive-images" || args[1] == "--export-paired-archive-movie") + if (args[1] == "--export-paired-archive-images" || args[1].startsWith("--export-paired-archive-movie")) saveAndRecoverPair(args[3], document, native); else motrack::writeTrackerArchiveDocument(args[3], document); @@ -261,7 +276,8 @@ try } if (args.size() == 4 && (args[1] == "--export-linked-movie" || args[1] == "--export-linked-movie-clip" || args[1] == "--export-linked-movie-clip-single" || args[1] == "--export-packaged-movie" || - args[1] == "--export-packaged-movie-clip" || args[1] == "--export-packaged-movie-clip-single")) + args[1] == "--export-packaged-movie-clip" || args[1] == "--export-packaged-movie-clip-single" || + args[1] == "--export-linked-movie-calibration" || args[1] == "--export-packaged-movie-calibration")) { const bool packaged = args[1].startsWith("--export-packaged-movie"); QTemporaryDir temporary; @@ -293,7 +309,9 @@ try !edits.markPointMass(document.project, 0, {.frame = 1, .x = 8.0, .y = 12.0}) || !edits.undo(document.project) || !edits.redo(document.project)) throw motrack::ProjectError("Could not edit linked-movie reference."); - if (args[1] != "--export-linked-movie" && args[1] != "--export-packaged-movie") + if (args[1].endsWith("-movie-calibration")) + editMovieCalibration(document, edits); + if (args[1].contains("-clip")) { const bool single = args[1].endsWith("-single"); if (!edits.setClipRange(document.project, single ? 2 : 1, single ? 2 : 3, single ? 1 : 2) || !edits.undo(document.project) || diff --git a/tests/paired_workspace_test.cpp b/tests/paired_workspace_test.cpp index cf2028b..777150e 100644 --- a/tests/paired_workspace_test.cpp +++ b/tests/paired_workspace_test.cpp @@ -3,6 +3,7 @@ #include "paired_save.hpp" #include "tracker_archive.hpp" #include "tracker_writer.hpp" +#include "video_canvas.hpp" #include "workspace_document.hpp" #include @@ -10,9 +11,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -109,6 +112,42 @@ TEST(PairedWorkspace, SavesIndexedMovieEditsAndNativeCompanionAfterSourceRemoval ASSERT_TRUE(plot && canvas && slider); ASSERT_TRUE(QTest::qWaitFor([plot] { return plot->accessibleName().endsWith("Time (s)"); })); ASSERT_TRUE(editMass(window, 7)); + auto* video = dynamic_cast(canvas); + auto* axes = window.findChild("setCoordinateAxes"); + auto* calibrate = window.findChild("calibrationStick"); + auto* undo = window.findChild("undoEdit"); + auto* redo = window.findChild("redoEdit"); + ASSERT_TRUE(video && axes && calibrate && undo && redo); + axes->trigger(); + ASSERT_TRUE(video->pick({10, 18}, false)); + ASSERT_TRUE(video->pick({10, 10}, false)); + calibrate->trigger(); + ASSERT_TRUE(video->pick({4, 12}, false)); + bool configured = false; + QTimer::singleShot(0, + &window, + [&configured] + { + if (auto* dialog = qobject_cast(QApplication::activeModalWidget())) + { + auto* distance = dialog->findChild("knownDistance"); + auto* unit = dialog->findChild("lengthUnit"); + if (distance && unit) + { + distance->setValue(2); + unit->setText("cm"); + configured = true; + } + dialog->accept(); + } + }); + ASSERT_TRUE(video->pick({12, 12}, false)); + ASSERT_TRUE(configured); + EXPECT_TRUE(plot->accessibleName().contains("(cm)")); + undo->trigger(); + EXPECT_TRUE(plot->accessibleName().contains("(m)")); + redo->trigger(); + EXPECT_TRUE(plot->accessibleName().contains("(cm)")); ASSERT_TRUE(QFile::remove(source)); ASSERT_TRUE(QDir().rmdir(inputDirectory)); ASSERT_TRUE(window.saveTrackerProject(target)); @@ -117,6 +156,17 @@ TEST(PairedWorkspace, SavesIndexedMovieEditsAndNativeCompanionAfterSourceRemoval EXPECT_TRUE(companion.movie && companion.alignMovieToClip); EXPECT_EQ(companion.movieFrameTimes, (std::vector{0.0, 0.125, 0.25, 0.375})); EXPECT_DOUBLE_EQ(companion.project.pointMasses.front().mass, 7); + EXPECT_EQ(companion.project.lengthUnit, "cm"); + if (!companion.project.calibration) + FAIL() << "Paired save lost the calibrated movie."; + ASSERT_EQ(companion.project.calibration->keyframes.size(), 1); + const auto& key = companion.project.calibration->keyframes.front(); + EXPECT_DOUBLE_EQ(key.originX, 10); + EXPECT_DOUBLE_EQ(key.originY, 18); + EXPECT_DOUBLE_EQ(key.angleDegrees, 90); + EXPECT_DOUBLE_EQ(key.scaleX, 4); + EXPECT_DOUBLE_EQ(key.scaleY, 8); + EXPECT_EQ(motrack::importTrackerDocument(target).project.calibration, companion.project.calibration); EXPECT_DOUBLE_EQ(motrack::importTrackerDocument(target).project.pointMasses.front().mass, 7); slider->setValue(2); ASSERT_TRUE(QTest::qWaitFor([canvas] { return canvas->accessibleName() == "Frame 2"; })); @@ -130,6 +180,8 @@ TEST(PairedWorkspace, SavesIndexedMovieEditsAndNativeCompanionAfterSourceRemoval ASSERT_TRUE(reopened.saveTrackerProject(target)); EXPECT_DOUBLE_EQ(motrack::readWorkspaceDocument(native).project.pointMasses.front().mass, 9); EXPECT_DOUBLE_EQ(motrack::importTrackerDocument(target).project.pointMasses.front().mass, 9); + EXPECT_EQ(motrack::importTrackerDocument(target).project.lengthUnit, "cm"); + EXPECT_EQ(motrack::importTrackerDocument(target).project.calibration, motrack::readWorkspaceDocument(native).project.calibration); } TEST(PairedWorkspace, SavesArchivedMovieClipsAndBothCopiesAfterOriginalRemoval) diff --git a/tests/test_corpus.py b/tests/test_corpus.py index dcc2e19..d511944 100644 --- a/tests/test_corpus.py +++ b/tests/test_corpus.py @@ -329,6 +329,52 @@ def test_movie_package_oracle_checks_complete_media_xml_and_clip_ranges(self): corpus.verify_media_package_export(original, exported, selected, case, clip_mode, movie=True) self.assertEqual(selected.read_text(encoding="utf-8"), xml) + def test_movie_calibration_xml_oracles_reject_unexpected_geometry_and_units(self): + for packaged in [False, True]: + case = next(c for c in corpus.load_manifest()["cases"] if c["id"] == ("linked-mov" if packaged else "linked-movie")) + original = corpus.local_fixture_path(case) + exported = self.root / ("calibrated.trz" if packaged else "calibrated.trk") + selected = self.root / "selected-calibrated.trk" + with corpus.minidom.parse(str(original)) as document: + corpus.patch_expected_linked_media(document, movie=True, packaged=packaged) + frame = next(node for node in document.getElementsByTagName("object") + if node.getAttribute("class") == "org.opensourcephysics.media.core.ImageCoordSystem$FrameData") + for name, value in [("xorigin", "10"), ("yorigin", "18"), ("angle", "90"), ("xscale", "4"), ("yscale", "8")]: + prop = next(node for node in frame.getElementsByTagName("property") if node.getAttribute("name") == name) + prop.firstChild.data = value + unit = next(node for node in document.documentElement.childNodes + if node.nodeType == node.ELEMENT_NODE and node.getAttribute("name") == "length_unit") + unit.firstChild.data = "cm" + xml = document.toxml() + payload = corpus.local_fixture_path(case["assets"][0]).read_bytes() + if not packaged: + media = self.root / "média & movie" + media.mkdir() + (media / "constant-rate.avi").write_bytes(payload) + for mutation in [None, "xorigin", "yorigin", "angle", "xscale", "yscale", "length_unit", "mass"]: + with self.subTest(packaged=packaged, mutation=mutation): + with corpus.minidom.parseString(xml) as document: + if mutation: + prop = next(node for node in document.getElementsByTagName("property") if node.getAttribute("name") == mutation) + prop.firstChild.data = "m" if mutation == "length_unit" else "999" + changed = document.toxml() + if packaged: + with zipfile.ZipFile(exported, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr("experiment.trk", changed) + archive.writestr("media/movie.mov", payload) + else: + exported.write_text(changed, encoding="utf-8") + def verify(): + if packaged: + corpus.verify_media_package_export(original, exported, selected, case, movie=True, calibrated=True) + else: + corpus.verify_linked_movie_export_xml(original, exported, case, calibrated=True) + if mutation: + with self.assertRaises(ValueError): + verify() + else: + verify() + def test_linked_movie_export_oracle_checks_all_xml_and_movie_bytes(self): case = next(c for c in corpus.load_manifest()["cases"] if c["id"] == "linked-movie") original = corpus.local_fixture_path(case) @@ -488,7 +534,8 @@ def test_movie_archive_modes_all_use_the_independent_multitab_checker(self): patch.object(corpus, "check_archive_tabs") as check: corpus.check_sessions(manifest, self.root, "java", "native-probe") self.assertEqual([call.args[-1] for call in check.call_args_list], - ["archive-movie", "archive-movie-clip", "archive-movie-clip-single", "paired-archive-movie"]) + ["archive-movie", "archive-movie-clip", "archive-movie-clip-single", "paired-archive-movie", + "archive-movie-calibration", "paired-archive-movie-calibration"]) self.assertTrue(all(call.args[0]["id"] == "archived-movie" for call in check.call_args_list)) def test_linked_image_export_oracle_checks_edited_geometry_all_xml_and_relocated_assets(self): @@ -669,13 +716,20 @@ def test_media_session_requires_independent_pixels_order_and_timing(self): with self.subTest(case=case_id): self.check_media_session_oracle(case_id) - def check_media_session_oracle(self, case_id, clip_mode=None): + def test_movie_calibration_session_requires_independent_geometry_and_units(self): + for case_id in ["linked-movie", "linked-mov", "archived-movie"]: + with self.subTest(case=case_id): + self.check_media_session_oracle(case_id, calibrated=True) + + def check_media_session_oracle(self, case_id, clip_mode=None, *, calibrated=False): manifest = corpus.load_manifest() case = next(c for c in manifest["cases"] if c["id"] == case_id) expected = json.loads(corpus.expected_path(case).read_text())["project"] if clip_mode is not None: expected, case = corpus.linked_media_clip_expected(case, clip_mode == "single") - keys = [[0, 1, 2]] if clip_mode is not None else None + if calibrated: + expected, case = corpus.linked_movie_calibration_expected(case) + keys = [[0, 1, 2]] if clip_mode is not None or calibrated else None mass = expected["point_masses"][0] before = {"tracks": [case["session_tracks"][0], { **case["session_tracks"][1], "mass": mass["mass"], "key_frames": [0, 1, 2] if keys else [], @@ -692,7 +746,7 @@ def check_media_session_oracle(self, case_id, clip_mode=None): corpus.validate_session(case, valid, manifest, expected, keys) for stages in [["after"], ["before", "after_save", "after"]]: for mutation in ["missing", "pixels", "order", "time", "dimensions", "count", "origin", "scale", "axes_grid", "engine", - "clip-range", "missing-mark", "display-time"]: + "clip-range", "missing-mark", "display-time", "angle", "world", "unit"]: invalid = copy.deepcopy(valid) for stage in stages: media = invalid[stage]["media"] @@ -712,6 +766,12 @@ def check_media_session_oracle(self, case_id, clip_mode=None): invalid[stage]["calibration"]["keys"][0]["origin"] = [0.0, 0.0] elif mutation == "scale": invalid[stage]["calibration"]["keys"][0]["scale"] = [1.0, 1.0] + elif mutation == "angle": + invalid[stage]["calibration"]["keys"][0]["angle"] += 5.0 + elif mutation == "world": + invalid[stage]["tracks"][1]["samples"][0]["world"][0] += 1.0 + elif mutation == "unit": + invalid[stage]["length_unit"] = "wrong" elif mutation == "axes_grid": invalid[stage]["axes"][0]["grid"]["visible"] = True elif mutation == "clip-range": diff --git a/tests/tracker_writer_test.cpp b/tests/tracker_writer_test.cpp index e5bb2e4..150dce7 100644 --- a/tests/tracker_writer_test.cpp +++ b/tests/tracker_writer_test.cpp @@ -21,8 +21,10 @@ #include #include #include +#include #include #include +#include namespace { @@ -126,6 +128,64 @@ TEST(TrackerWriter, SavesLinkedMovieEditsAfterNativeReopenSourceRemovalAndReloca EXPECT_NO_THROW(motrack::writeTrackerDocument(output, reopened, output)); } +TEST(TrackerWriter, SavesFixedMovieCalibrationAndUnitsThroughHistoryNativeReopenAndClipSaving) +{ + for (const bool mov : {false, true}) + { + SCOPED_TRACE(mov); + QTemporaryDir temporary; + const auto fixture = QString::fromUtf8(MOTRACK_FIXTURES) + "/../corpus/fixtures/" + (mov ? "linked-mov/" : "linked-movie/"); + const auto mediaName = mov ? QStringLiteral("constant-rate.mov") : QStringLiteral("constant-rate.avi"); + const auto input = temporary.filePath("input.trk"); + const auto movie = temporary.filePath(mediaName); + ASSERT_TRUE(QFile::copy(fixture + "linked-movie.trk", input)); + ASSERT_TRUE(QFile::copy(fixture + mediaName, movie)); + auto document = indexedMovieDocument(input); + const auto sourceBytes = retainedSource(document).xml; + motrack::TrackEdits edits; + ASSERT_TRUE(edits.setPointMassValue(document.project, 0, 5)); + ASSERT_TRUE(edits.markPointMass(document.project, 0, {.frame = 1, .x = 8, .y = 12})); + ASSERT_TRUE(edits.setClipRange(document.project, 1, 3, 2)); + const auto before = document.project; + ASSERT_TRUE( + edits.setCoordinateAxes(document.project, {.frame = 1, .originX = 10, .originY = 18, .positiveX = 10, .positiveY = 10})); + ASSERT_TRUE( + edits.calibrateDistance(document.project, {.frame = 2, .imageDeltaX = 8, .imageDeltaY = 0, .knownLength = 2, .unit = "cm"})); + auto expected = document.project; + ASSERT_TRUE(edits.undo(document.project)); + ASSERT_TRUE(edits.undo(document.project)); + EXPECT_EQ(document.project, before); + ASSERT_TRUE(edits.redo(document.project)); + ASSERT_TRUE(edits.redo(document.project)); + EXPECT_EQ(document.project, expected); + const auto native = temporary.filePath("saved.motrack"); + motrack::rebaseWorkspaceMedia(document, input, native); + motrack::writeWorkspaceDocument(native, document); + document = {}; + ASSERT_TRUE(QFile::remove(input)); + document = motrack::readWorkspaceDocument(native); + const auto target = temporary.filePath("saved.trk"); + ASSERT_NO_THROW(motrack::writeTrackerDocument(target, document, native)); + const auto reopened = motrack::importTrackerDocument(target).project; + expected.clip.sourceFrameCount.reset(); + EXPECT_EQ(reopened, expected); + EXPECT_EQ(retainedSource(document).xml, sourceBytes); + EXPECT_EQ(readBytes(movie), readBytes(fixture + mediaName)); + if (!reopened.calibration) + FAIL() << "Saved movie lost its calibration."; + const std::array, 3> world{{{0.25, 1.5}, {0.75, 0.5}, {1.75, -0.5}}}; + ASSERT_EQ(reopened.pointMasses.front().marks.size(), world.size()); + for (std::size_t index = 0; index < world.size(); ++index) + { + const auto position = motrack::worldPosition(*reopened.calibration, reopened.pointMasses.front().marks[index]); + if (!position) + FAIL() << "Saved movie has no world position."; + EXPECT_NEAR(position->x, world[index][0], 1e-12); + EXPECT_NEAR(position->y, world[index][1], 1e-12); + } + } +} + TEST(TrackerWriter, SavesStridedAndSingleFrameMovieClipsAfterNativeReopenWithoutDroppingMarks) { for (const bool single : {false, true}) @@ -255,7 +315,24 @@ TEST(TrackerWriter, RejectsUnsupportedMovieStateBeforeReplacingExistingOutput) [](auto& doc) { doc.project.pointMasses.front().name = "renamed"; }, [](auto& doc) { doc.project.pointMasses.front().marks.front().frame = 4; }, [](auto& doc) { doc.project.pointMasses.clear(); }, - [](auto& doc) { doc.project.lengthUnit = "cm"; }, + [](auto& doc) { doc.project.massUnit = "g"; }, + [](auto& doc) { doc.project.lengthUnit = "bad\nunit"; }, + [](auto& doc) { doc.project.calibration.reset(); }, + [](auto& doc) { doc.project.calibration = motrack::Calibration{}; }, + [](auto& doc) + { + auto xml = retainedSource(doc).xml; + const std::string token = R"(true)"; + const auto at = xml.find(token); + ASSERT_NE(at, std::string::npos); + xml.replace(at, token.size(), R"(false)"); + auto parsed = motrack::parseTrackerProject(xml); + doc.project = std::move(parsed.project); + doc.trackerSource = std::move(parsed.source); + doc.project.clip.sourceFrameCount = 4; + motrack::TrackEdits edits; + ASSERT_TRUE(edits.setCoordinateAxes(doc.project, {.frame = 1, .originX = 10, .originY = 18, .positiveX = 10, .positiveY = 10})); + }, [](auto& doc) { doc.project.clip.mediaReferences.front() = "missing.avi"; }, [](auto& doc) { diff --git a/tools/corpus.py b/tools/corpus.py index 6595926..ac506e1 100644 --- a/tools/corpus.py +++ b/tools/corpus.py @@ -490,9 +490,28 @@ def linked_media_clip_expected(case, single=False): return expected, case -def patch_expected_linked_media(document, relocate=True, packaged=False, movie=False, clip_mode=None): +def linked_movie_calibration_expected(case): + if case["id"] not in {"linked-movie", "linked-mov", "archived-movie"}: + raise ValueError("Movie calibration requires a pinned movie experiment") + expected = linked_media_roundtrip_expected(case) + expected["length_unit"] = "cm" + # Independent 90-degree inverse transform: X=(18-y)/8, Y=(10-x)/4. + for sample, world in zip(expected["point_masses"][0]["samples"], + [[0.25, 1.5], [0.75, 0.5], [1.75, -0.5]], strict=True): + sample["world"] = world + for sample in expected["coordinate_samples"]: + sample["world"] = [0.09375, -0.875] + case = copy.deepcopy(case) + case["session_calibration"] = {"fixed_origin": True, "fixed_angle": True, "fixed_scale": True, + "keys": [{"frame": 0, "origin": [10.0, 18.0], "angle": 90.0, "scale": [4.0, 8.0]}]} + return expected, case + + +def patch_expected_linked_media(document, relocate=True, packaged=False, movie=False, clip_mode=None, calibrated=False): if clip_mode not in {None, "stride", "single"}: raise ValueError("Unknown linked-media clip mode") + if calibrated and not movie: + raise ValueError("This calibration oracle requires movie media") def elements(node, tag): return [child for child in node.childNodes if child.nodeType == child.ELEMENT_NODE and child.tagName == tag] def prop(node, name): @@ -502,6 +521,13 @@ def scalar(node, value): node.removeChild(child) node.appendChild(document.createTextNode(value)) root = document.documentElement + if calibrated: + coords = elements(prop(root, "coords"), "object")[0] + frames = prop(coords, "framedata") + frame = elements(prop(frames, "[0]"), "object")[0] + for name, value in [("xorigin", "10"), ("yorigin", "18"), ("angle", "90"), ("xscale", "4"), ("yscale", "8")]: + scalar(prop(frame, name), value) + scalar(prop(root, "length_unit"), "cm") clip = elements(prop(root, "videoclip"), "object")[0] if clip_mode is not None: values = [("startframe", "2"), ("stepsize", "1"), ("stepcount", "1")] if clip_mode == "single" else [ @@ -658,15 +684,15 @@ def verify_saved_movie_asset(saved, asset, *, allow_archive=False): return {"reference": paths[0].text, "bytes": len(payload), "sha256": digest} -def verify_linked_movie_export_xml(original, exported, case, clip_mode=None): +def verify_linked_movie_export_xml(original, exported, case, clip_mode=None, *, calibrated=False): with minidom.parse(str(original)) as expected, minidom.parse(str(exported)) as actual: - patch_expected_linked_media(expected, movie=True, clip_mode=clip_mode) + patch_expected_linked_media(expected, movie=True, clip_mode=clip_mode, calibrated=calibrated) if canonical_tracker_xml(expected) != canonical_tracker_xml(actual): - raise ValueError("Linked-movie export changed content beyond the requested path, mass, mark and clip range") + raise ValueError("Linked-movie export changed content beyond the requested path, mass, mark, clip range and calibration") verify_file(exported.parent / "média & movie" / "constant-rate.avi", case["assets"][0]) -def verify_media_package_export(original, exported, selected_xml, case, clip_mode=None, *, movie=False): +def verify_media_package_export(original, exported, selected_xml, case, clip_mode=None, *, movie=False, calibrated=False): """Independent ZIP/XML/hash oracle for a new package; no external media paths may survive.""" if exported.stat().st_size > 1024 * 1024: raise ValueError("Media package reference exceeds the small-fixture limit") @@ -687,15 +713,15 @@ def verify_media_package_export(original, exported, selected_xml, case, clip_mod raise ValueError("Packaged XML exceeds fixture bounds") xml = package.read("experiment.trk") with minidom.parse(str(original)) as expected, minidom.parseString(xml) as actual: - patch_expected_linked_media(expected, packaged=True, clip_mode=clip_mode, movie=movie) + patch_expected_linked_media(expected, packaged=True, clip_mode=clip_mode, movie=movie, calibrated=calibrated) if canonical_tracker_xml(expected) != canonical_tracker_xml(actual): - raise ValueError("Media package changed XML beyond the requested paths, mass, mark and clip") + raise ValueError("Media package changed XML beyond the requested paths, mass, mark, clip and calibration") selected_xml.write_bytes(xml) except zipfile.BadZipFile as error: raise ValueError("Media package ZIP integrity check failed") from error -def verify_archive_media_export(original, exported, selected_xml, member_count=7, clip_mode=None, *, movie=False): +def verify_archive_media_export(original, exported, selected_xml, member_count=7, clip_mode=None, *, movie=False, calibrated=False): """Independent ZIP reader: only the selected XML and its encoding metadata may change.""" if max(original.stat().st_size, exported.stat().st_size) > 1024 * 1024: raise ValueError("Archive reference exceeds the small-fixture limit") @@ -716,9 +742,9 @@ def verify_archive_media_export(original, exported, selected_xml, member_count=7 if b.compress_type != zipfile.ZIP_STORED or b.flag_bits != a.flag_bits & 0x0800: raise ValueError("Unexpected edited-member encoding") with minidom.parseString(before.read(a)) as expected, minidom.parseString(after.read(b)) as actual: - patch_expected_linked_media(expected, relocate=False, movie=movie, clip_mode=clip_mode) + patch_expected_linked_media(expected, relocate=False, movie=movie, clip_mode=clip_mode, calibrated=calibrated) if canonical_tracker_xml(expected) != canonical_tracker_xml(actual): - raise ValueError("Archive export changed XML beyond the edited mass and mark") + raise ValueError("Archive export changed XML beyond the requested mass, mark, clip and calibration") selected_xml.write_bytes(after.read(b)) else: if before.read(a) != after.read(b): @@ -1896,13 +1922,16 @@ def check_archive_tabs(case, manifest, cache, java, classes, jar, output, displa if probe: exported = output / "motrack-export.trz" export_mode = mode if movie else "multitab-archive-images" + calibrated = movie and mode.endswith("-calibration") clip_mode = "single" if mode == "archive-movie-clip-single" else "stride" if mode == "archive-movie-clip" else None run([str(Path(probe).resolve()), "--export-" + export_mode, str(source), str(exported)]) verify_archive_media_export(source, exported, output / "exported-member.trk", member_count=9 if movie else 8, - clip_mode=clip_mode, movie=movie) + clip_mode=clip_mode, movie=movie, calibrated=calibrated) expected[0] = linked_media_roundtrip_expected(case) if clip_mode: expected[0], templates[0] = linked_media_clip_expected(templates[0], single=clip_mode == "single") + if calibrated: + expected[0], templates[0] = linked_movie_calibration_expected(templates[0]) source = exported else: # A fixture's neighboring movie must not stand in for extraction from the archive. @@ -1978,17 +2007,21 @@ def check_sessions(manifest, cache, java, probe=None): if sys.platform != "linux": raise ValueError("Session checks currently require Linux with Xvfb") modes = [("linked-movie", "native_linked_movie_roundtrip"), + ("linked-movie-calibration", "native_linked_movie_calibration_roundtrip"), ("linked-movie-clip", "native_linked_movie_clip_roundtrip"), ("linked-movie-clip-single", "native_linked_movie_clip_roundtrip"), ("archive-movie", "native_archive_movie_roundtrip"), ("archive-movie-clip", "native_archive_movie_roundtrip"), ("archive-movie-clip-single", "native_archive_movie_roundtrip"), ("paired-archive-movie", "native_archive_movie_roundtrip"), + ("archive-movie-calibration", "native_archive_movie_calibration_roundtrip"), + ("paired-archive-movie-calibration", "native_archive_movie_calibration_roundtrip"), ("archive-images", "native_archive_image_roundtrip"), ("linked-images", "native_linked_image_roundtrip"), ("linked-image-clip", "native_linked_image_clip_roundtrip"), ("linked-image-clip-single", "native_linked_image_clip_roundtrip"), ("archive-image-clip", "native_archive_image_clip_roundtrip"), ("packaged-image-clip", "native_linked_image_clip_roundtrip"), ("packaged-images", "native_image_package_roundtrip"), ("packaged-movie", "native_movie_package_roundtrip"), + ("packaged-movie-calibration", "native_movie_package_calibration_roundtrip"), ("packaged-movie-clip", "native_movie_package_roundtrip"), ("packaged-movie-clip-single", "native_movie_package_roundtrip"), ("paired-archive-images", "native_paired_archive_roundtrip"), ("paired-linked-images", "native_paired_linked_roundtrip"), @@ -2080,20 +2113,23 @@ def check_sessions(manifest, cache, java, probe=None): case = {**case, "session_tracks": [track for track in case["session_tracks"] if track["class"] == "org.opensourcephysics.cabrillo.tracker.CoordAxes"]} expected = None - if mode in {"packaged-images", "packaged-image-clip", "packaged-movie", "packaged-movie-clip", "packaged-movie-clip-single"}: + if mode in {"packaged-images", "packaged-image-clip", "packaged-movie", "packaged-movie-clip", "packaged-movie-clip-single", "packaged-movie-calibration"}: packaged_movie = mode.startswith("packaged-movie") clip_mode = ("single" if mode in {"packaged-image-clip", "packaged-movie-clip-single"} else "stride" if mode == "packaged-movie-clip" else None) exported = output / "motrack-export.trz" run([str(Path(probe).resolve()), "--export-" + mode, str(project), str(exported)]) selected = output / "exported-member.trk" - verify_media_package_export(project, exported, selected, case, clip_mode, movie=packaged_movie) + verify_media_package_export(project, exported, selected, case, clip_mode, movie=packaged_movie, + calibrated=mode == "packaged-movie-calibration") if (output / ("média & movie" if packaged_movie else "media é & frames")).exists(): raise ValueError("Media package probe retained original external media") project = selected expected = linked_media_roundtrip_expected(case) if clip_mode: expected, case = linked_media_clip_expected(case, single=clip_mode == "single") + if mode == "packaged-movie-calibration": + expected, case = linked_movie_calibration_expected(case) native_report = output / "native-export.json" run([str(Path(probe).resolve()), str(project), str(native_report)]) compare(expected, json.loads(native_report.read_text(encoding="utf-8")), manifest["tolerance"], case["id"] + ".export") @@ -2127,6 +2163,9 @@ def check_sessions(manifest, cache, java, probe=None): if mode == "linked-movie": verify_linked_movie_export_xml(project, exported, case) expected = linked_media_roundtrip_expected(case) + elif mode == "linked-movie-calibration": + verify_linked_movie_export_xml(project, exported, case, calibrated=True) + expected, case = linked_movie_calibration_expected(case) elif mode in {"linked-movie-clip", "linked-movie-clip-single"}: single = mode == "linked-movie-clip-single" verify_linked_movie_export_xml(project, exported, case, "single" if single else "stride") @@ -2243,7 +2282,7 @@ def check_sessions(manifest, cache, java, probe=None): keys = {"marks": [[0, 3], [7]], "empty-marks": [[], [0]], "first-mark": [[4], [0]]}.get(mode) if mode in {"linked-movie", "linked-movie-clip", "linked-movie-clip-single", "linked-images", "linked-image-clip", "linked-image-clip-single", "archive-images", "paired-linked-images", "paired-archive-images", "packaged-images", "archive-image-clip", "packaged-image-clip", - "packaged-movie", "packaged-movie-clip", "packaged-movie-clip-single"}: + "packaged-movie", "packaged-movie-clip", "packaged-movie-clip-single", "linked-movie-calibration", "packaged-movie-calibration"}: keys = [[0, 1, 2]] if mode in {"create-mass", "create-empty-mass"}: keys = [[0], [0], [] if mode == "create-empty-mass" else [1, 4, 8]] @@ -2283,7 +2322,7 @@ def check_sessions(manifest, cache, java, probe=None): else: standard = "TRZ" if mode in {"archive-images", "paired-archive-images", "packaged-images", "archive-image-clip", "packaged-image-clip", - "packaged-movie", "packaged-movie-clip", "packaged-movie-clip-single"} else "TRK" + "packaged-movie", "packaged-movie-clip", "packaged-movie-clip-single", "packaged-movie-calibration"} else "TRK" pair = "interrupted paired save/recovery; " if mode.startswith("paired-") else "" print(f"{case['id']}/{mode}: passed (MoTrack edit/native reopen/{standard} export; {pair}Tracker load/save/reopen; native reimport)") elif known_report is not None: