diff --git a/CMakeLists.txt b/CMakeLists.txt index 3a519aa..deffd39 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -41,10 +41,10 @@ add_library(motrack_project STATIC src/document_session.cpp src/legacy_project.c target_include_directories(motrack_project PUBLIC src) target_link_libraries(motrack_project PUBLIC Qt6::Core PRIVATE Qt6::Xml motrack_archive) -# Image-package validation decodes staged media without adding image codecs to the project model. +# Package validation decodes staged images/movies without adding codecs to the project model. add_library(motrack_image_package STATIC src/tracker_image_package.cpp) target_include_directories(motrack_image_package PUBLIC src) -target_link_libraries(motrack_image_package PUBLIC motrack_project PRIVATE Qt6::Gui motrack_archive) +target_link_libraries(motrack_image_package PUBLIC motrack_project PRIVATE Qt6::Gui motrack_archive motrack_media) qt_add_executable(motrack src/main.cpp) add_library(motrack_workspace STATIC src/image_sequence.cpp src/main_window.cpp src/movie_worker.cpp src/position_plot.cpp @@ -148,7 +148,7 @@ if(BUILD_TESTING) FetchContent_MakeAvailable(googletest) add_executable(motrack_tests tests/paired_save_test.cpp tests/legacy_project_test.cpp tests/measurements_test.cpp tests/track_edits_test.cpp - tests/tracker_archive_test.cpp tests/tracker_archive_writer_test.cpp tests/tracker_image_package_test.cpp tests/tracker_references_test.cpp tests/tracker_writer_test.cpp) + tests/tracker_archive_test.cpp tests/tracker_archive_writer_test.cpp tests/tracker_image_package_test.cpp tests/tracker_movie_package_test.cpp tests/tracker_references_test.cpp tests/tracker_writer_test.cpp) target_link_libraries(motrack_tests PRIVATE motrack_project motrack_image_package GTest::gtest_main motrack_archive Qt6::Xml Qt6::Gui) target_compile_definitions(motrack_tests PRIVATE MOTRACK_FIXTURES="${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures") diff --git a/README.md b/README.md index 7030505..e253e0c 100644 --- a/README.md +++ b/README.md @@ -73,10 +73,10 @@ conflict. Recovery never marks newer edits saved without writing them to both fi This workflow currently covers audited media-free and local-image experiments, plus selected-member updates to existing image/MOV archives. The movie writer supports mass, mark and clip-range edits in audited constant-rate imports after timestamp indexing; its edited-file Tracker save/reopen acceptance -has passed for the pinned linked four-frame AVI case. Archive acceptance and later revisions require +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, movie packaging, filters and broader settings remain unfinished. An unsupported export is refused; save a MoTrack copy to retain work. +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. 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. @@ -84,17 +84,19 @@ Audited linked movies and archived MOV files also retain Clip Settings range and movie and all measurements stay available; source frame numbers and numeric timing are unchanged. The [format guide](docs/native-format.md) records the exact supported edits and compatibility evidence. -### Export a portable image experiment +### Export a portable experiment -For a supported imported image experiment, choose **File → Export portable image archive…** and save -as `.trz`. MoTrack copies the experiment and its linked images into one file that can be moved and -opened in Tracker. The current project keeps its original save destinations, unsaved edits and undo +For a supported imported image or indexed constant-rate MOV experiment, choose **File → Export portable archive…** +and save as `.trz`. MoTrack copies the experiment and its complete linked media without conversion into +one file that can be moved and opened in Tracker. The current project keeps its original save destinations, unsaved edits and undo history; exporting a copy does not replace ordinary Save or create a companion automatically. -Export runs in the background with a Cancel button. Missing images are named in the error message, +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 the audited local-image schemas described in the [format guide](docs/native-format.md#creating-a-portable-image-archive). -Movie packaging, arbitrary supporting documents and newly created standalone image projects remain unfinished. +supports audited local-image schemas and linked MOV mass/mark/clip-range 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. ### Analyze motion diff --git a/docs/migration-plan.md b/docs/migration-plan.md index 6a74fde..5b2f5f5 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 #136, with the test-group correction in #138. Each row passed all six required checks and code +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 review on the listed revision before merging; the links record the actual acceptance evidence. | Merged slice | Reviewed revision | Passing CI | @@ -94,9 +94,11 @@ review on the listed revision before merging; the links record the actual accept | [#134: image clip range/stride saving](https://github.com/mgradwohl/MoTrack/pull/134) | 898328a | [42549943932](https://github.com/mgradwohl/MoTrack/actions/runs/42549943932) | | [#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) | -The final row passes Windows, macOS, Linux, both contributor setup jobs and **95.72% combined C++ line -coverage (8874/9271)**. The four image-clip modes and both movie-clip modes pass MoTrack native reopen/standard export, +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, +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. These are scoped results, not full Tracker parity. Historical evidence below remains attributable to @@ -170,9 +172,9 @@ 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 has native/desktop regressions and four mixed-tab reference modes; an original AVI archive fails pinned Tracker loading (#140), remains explicitly excluded from accepted interchange, and is refused by the standard writer; current-head application acceptance, CI and review are pending. Untouched archive experiments are checked independently. Remaining work includes broader schemas and dependencies, dataset-owner removal, movie-archive acceptance and new movie packaging, 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. 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 | | 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 has local native/desktop evidence; archived AVI output is refused after a reproduced Tracker extraction failure (#140); four mixed-tab application modes remain to pass current-head CI. Variable-rate timing, movie-archive acceptance, new 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 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 | | 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 | @@ -333,7 +335,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, movie-archive acceptance/new packaging and the remaining media timing/export +to dataset-owner deletion, broader dependencies, portable movie acceptance/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; diff --git a/docs/native-format.md b/docs/native-format.md index 39ad1cd..0b0a4ff 100644 --- a/docs/native-format.md +++ b/docs/native-format.md @@ -619,15 +619,15 @@ existing destination bytes and source bindings. PNG/JPEG/BMP success cases retai The project-only import benchmark still links Qt Core/Xml without Qt Gui. Three existing LLVM function-profile mismatch warnings remain visible. Current-head CI and review remain required. -### Desktop portable-image export +### Desktop portable archive export -`File → Export portable image archive…` chooses a `.trz` destination and runs the adapter on the +`File → Export portable archive…` chooses a `.trz` destination and runs the adapter on the session's bounded save worker with an immutable document snapshot. The filename chooser is guarded against document replacement and window destruction. Progress and cancellation remain on the GUI thread; closing or destroying the window cannot leave a worker borrowing its widgets. Prepared project opens wait until export bookkeeping has finished, and overlapping save/export requests are rejected. -The action is available for imported linked-image documents. The writer remains the authoritative schema +The shared action is available for imported linked-image documents and indexed MOV documents (see the movie package contract below). The writer remains the authoritative schema check, so an enabled action does not promise that every imported setting or edit is exportable. Missing source diagnostics identify the resolved file. A successful export preserves the source paths, native and Tracker checkpoints, title, dirty state and undo/redo history. Edits made programmatically during export @@ -760,7 +760,7 @@ Tracker's extraction writes are also isolated from the committed fixtures. Corre passes all ten CTest groups in **123.28 seconds** with **95.74% line coverage (8857/9251)**, all 89 Python tests, all 30 native/headless Java import cases and all four native archive export/oracle/reimport modes. Formatting and static analysis of all five changed C++ sources pass; three existing LLVM profile-mismatch -warnings remain visible. Actual-Tracker and three-platform CI/review remain pending for this correction. +warnings remain visible. The following CI attempts supplied the application/three-platform evidence below. The next Linux attempt (`4efb381`, run 42567671197) loaded the MOV archive but exposed missing cache initialization in the bare-`TFrame` reference harness: with `ResourceLoader.ospCache` unset, extracted movies are marked for deletion on JVM exit. The harness now uses Tracker's public `setOSPCache` @@ -768,5 +768,50 @@ preference API under each subprocess's private home, records that policy and ver reports. A bounded headless resource experiment reproduced deletion without the setting and exact-byte retention with it; the ordinary AVI extraction omission remains. This sets up the standard cache policy; it does not copy media or change the application loader, saver, expected data or fresh-process gate. -New portable movie packaging, broader archived containers, filters, variable-rate timing and broader -movie schemas remain unfinished. +The existing MOV archive slice merged in [PR #139](https://github.com/mgradwohl/MoTrack/pull/139) at reviewed +head `f166ba9`, with all six checks passing in [run 42568545597](https://github.com/mgradwohl/MoTrack/actions/runs/42568545597) +and **95.82% combined C++ line coverage (8887/9275)**. The isolated baseline and all four edited modes passed +actual Tracker loading, saving and fresh-process reopening of both tabs, full media hashes and native reimport. +Broader archived containers, filters, variable-rate timing and broader movie schemas remain unfinished. + + +## Creating a portable movie archive + +`writeTrackerMoviePackage` creates a new `.trz` from an audited, fully indexed constant-rate linked MOV +experiment. It shares the bounded staging/ZIP adapter used for images, preserving the one-way boundary: +project persistence does not link media decoders. The package contains `experiment.trk` and +`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 +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. +Its complete frame count and relative timestamps must match the retained document index. Corrupt media, +changed timing, incomplete indexing, other containers (including AVI under #140), existing archives, +unsupported edits, file aliases and cancellation are refused without replacing an existing destination. +The existing 512 MiB total extracted-media and bounded archive/index/decoded-pixel limits apply. + +`writeTrackerMoviePackageCopy` uses the same destination lock and pending-recovery refusal as image +export. The desktop's **Export portable archive…** command selects the movie adapter for indexed linked +MOV imports, runs it on the bounded save worker, and retains the session's current title, save destinations, +checkpoints, dirty state and undo/redo history. The copied archive has no automatic native companion; +opening it enables the existing paired Save workflow, including a native companion when requested. + +Native tests cover source/native reopen and removal, relocation, complete movie bytes, clip edits, +subsequent embedded native saving and archive resave, stale timing, corrupt sources, unsupported schemas, +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. + +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 +export/oracle/reimport modes. All tracked C++ formatting and five changed-source static analyses pass. +The three existing LLVM function-profile mismatch warnings remain visible. + +The subsequent review correction sends the shared image/MOV destination-refusal message through Qt +translation and adds unfinished entries to all three catalogs. Its rebuilt package tests (12 cases), +affected-source formatting/static analysis and catalog checks pass; package behavior is unchanged. diff --git a/docs/upstream-issues.md b/docs/upstream-issues.md index 7320d93..9953f2d 100644 --- a/docs/upstream-issues.md +++ b/docs/upstream-issues.md @@ -48,7 +48,7 @@ compatibility work that must happen during the port. | --- | --- | --- | | Tracker 6.3.5 loses vector-sum members and computed samples on normal load/resave | [PR #61](https://github.com/mgradwohl/MoTrack/pull/61) reproduces the defect with pinned Java and independent expected sums. [PR #62](https://github.com/mgradwohl/MoTrack/pull/62) preserves source links and refuses affected edited standard exports. These are mitigation and diagnosis, not accepted dependent-vector round trips. | [MoTrack #63](https://github.com/mgradwohl/MoTrack/issues/63) | | Immediate resave of an unvisited custom table can lose columns | [PR #59](https://github.com/mgradwohl/MoTrack/pull/59) deliberately visits custom tables and restores selection before its reference resave. That accepted case does not prove unvisited-view fidelity. | [MoTrack #64](https://github.com/mgradwohl/MoTrack/issues/64) | -| Tracker 6.3.5 cannot extract the pinned AVI movie from an original archive | [PR #139 initial CI](https://github.com/mgradwohl/MoTrack/actions/runs/42565821290/job/128991986521) fails before native edits. Pinned bytecode omits AVI from extraction registration. The original case stays outside accepted application round trips; the proposed native writer restricts movie archives to audited MOV files, with an independent MOV reference required. Observed 2026-11-09, after the initial audit. | [MoTrack #140](https://github.com/mgradwohl/MoTrack/issues/140) | +| Tracker 6.3.5 cannot extract the pinned AVI movie from an original archive | [PR #139 initial CI](https://github.com/mgradwohl/MoTrack/actions/runs/42565821290/job/128991986521) fails before native edits. Pinned bytecode omits AVI from extraction registration. The original case stays outside accepted application round trips; the native writer merged in [PR #139](https://github.com/mgradwohl/MoTrack/pull/139) restricts movie archives to audited MOV files. Its independent MOV baseline and all four edited mixed-tab modes passed [CI](https://github.com/mgradwohl/MoTrack/actions/runs/42568545597); broader containers remain open. Observed 2026-11-09, after the initial audit. | [MoTrack #140](https://github.com/mgradwohl/MoTrack/issues/140) | No matching upstream filing was identified in the initial audit for its first two observations. Neither has been filed in Tracker by this work. The later AVI/archive issue search also found no matching filing; #140 records its source link and separate reproduction. Tracker #14 is a momentum-analysis request, not the vector-sum reload defect. diff --git a/src/main_window.cpp b/src/main_window.cpp index 405b541..a17e6a0 100644 --- a/src/main_window.cpp +++ b/src/main_window.cpp @@ -69,10 +69,18 @@ QString physicalSourcePath(const QString& path) const auto physical = info.canonicalFilePath(); return physical.isEmpty() ? info.absoluteFilePath() : physical; } -bool isLinkedImageDocument(const WorkspaceDocument& document) +bool isLinkedMediaDocument(const WorkspaceDocument& document) { - return document.trackerSource && !document.archivePayload && !document.archiveMember && document.archiveReference.empty() && - !document.movie && document.movieFrameTimes.empty() && !document.project.clip.mediaReferences.empty(); + if (!document.trackerSource || document.archivePayload || document.archiveMember || !document.archiveReference.empty() || + document.useDisplayOrientation || document.project.clip.mediaReferences.empty()) + return false; + if (!document.movie) + return document.movieFrameTimes.empty(); + return document.alignMovieToClip && !document.movieFrameTimes.empty() && + std::cmp_equal(document.project.clip.sourceFrameCount.value_or(0), document.movieFrameTimes.size()) && + document.project.clip.mediaReferences.size() == 1 && + QFileInfo(QString::fromStdString(document.project.clip.mediaReferences.front())).suffix().compare("mov", Qt::CaseInsensitive) == + 0; } // Dialogs may lose their parent inside the nested event loop. Qt owns these heap children; @@ -156,9 +164,9 @@ MainWindow::MainWindow() m_SaveTracker->setObjectName("saveTrackerProject"); m_SaveTracker->setToolTip(tr("Save a Tracker file and a MoTrack companion.")); m_SaveTracker->setEnabled(false); - m_ExportArchive = fileMenu->addAction(tr("Export portable image &archive…")); + m_ExportArchive = fileMenu->addAction(tr("Export portable &archive…")); m_ExportArchive->setObjectName("exportTrackerArchive"); - m_ExportArchive->setToolTip(tr("Copy this experiment and its images into one portable Tracker archive.")); + m_ExportArchive->setToolTip(tr("Copy this experiment and its media into one portable Tracker archive.")); m_ExportArchive->setEnabled(false); m_Export = new QAction(tr("&Export measurements…"), this); m_Export->setObjectName("exportMeasurements"); @@ -436,7 +444,7 @@ MainWindow::MainWindow() }); connect(m_SaveAs, &QAction::triggered, this, &MainWindow::chooseWorkspacePath); connect(m_SaveTracker, &QAction::triggered, this, &MainWindow::chooseTrackerPath); - connect(m_ExportArchive, &QAction::triggered, this, &MainWindow::chooseImageArchivePath); + connect(m_ExportArchive, &QAction::triggered, this, &MainWindow::chooseArchivePath); connect(m_Save, &QAction::triggered, this, &MainWindow::saveCurrentWorkspace); connect(m_Export, &QAction::triggered, @@ -964,6 +972,7 @@ void MainWindow::scheduleMovieIndex(const QString& path, bool standalone) if (steps == 0 || last < index.frameCount) clip.sourceFrameCount = std::max(clip.sourceFrameCount.value_or(0), index.frameCount); } + m_ExportArchive->setEnabled(isLinkedMediaDocument(m_Session.document)); refreshPlot(); return; } @@ -1036,7 +1045,7 @@ void MainWindow::activateProject(const QString& path) m_SaveAs->setEnabled(true); m_Save->setEnabled(true); m_SaveTracker->setEnabled(m_Session.document.trackerSource.has_value()); - m_ExportArchive->setEnabled(isLinkedImageDocument(m_Session.document)); + m_ExportArchive->setEnabled(isLinkedMediaDocument(m_Session.document)); refreshEditViews(0); syncClipControls(); setWindowTitle(tr("%1[*] — MoTrack").arg(QFileInfo(path).fileName())); @@ -2004,12 +2013,12 @@ bool MainWindow::saveTrackerProject(const QString& path, bool recoverPending) return true; } -bool MainWindow::chooseImageArchivePath() +bool MainWindow::chooseArchivePath() { if (m_Saving) return false; const auto checkpoint = m_Session.saveCheckpoint(); - const auto path = chooseSaveFile(this, tr("Export portable image archive"), {}, tr("Tracker archives (*.trz)"), "trz"); + const auto path = chooseSaveFile(this, tr("Export portable archive"), {}, tr("Tracker archives (*.trz)"), "trz"); if (!path || checkpoint.identity != m_Session.saveCheckpoint().identity) return false; return exportTrackerArchive(*path); @@ -2019,9 +2028,9 @@ bool MainWindow::exportTrackerArchive(const QString& path) { if (m_Saving) return false; - if (!isLinkedImageDocument(m_Session.document) || !path.endsWith(".trz", Qt::CaseInsensitive)) + if (!isLinkedMediaDocument(m_Session.document) || !path.endsWith(".trz", Qt::CaseInsensitive)) { - statusBar()->showMessage(tr("Choose a .trz filename for an imported image project.")); + statusBar()->showMessage(tr("Choose a .trz filename for an imported image or indexed MOV project.")); return false; } struct ExportResult @@ -2039,10 +2048,10 @@ bool MainWindow::exportTrackerArchive(const QString& path) m_Saving = true; auto* progress = new QDialog(this); progress->setObjectName("imageArchiveProgress"); - progress->setWindowTitle(tr("Exporting portable image archive")); + progress->setWindowTitle(tr("Exporting portable archive")); progress->setWindowModality(Qt::WindowModal); auto* layout = new QVBoxLayout(progress); - auto* label = new QLabel(tr("Copying the experiment and its images…"), progress); + auto* label = new QLabel(tr("Copying the experiment and its media…"), progress); auto* cancel = new QPushButton(tr("Cancel"), progress); cancel->setObjectName("cancelImageArchiveExport"); layout->addWidget(label); @@ -2064,7 +2073,10 @@ bool MainWindow::exportTrackerArchive(const QString& path) { try { - writeTrackerImagePackageCopy(path, document, source, cancelled.get()); + if (document.movie) + writeTrackerMoviePackageCopy(path, document, source, cancelled.get()); + else + writeTrackerImagePackageCopy(path, document, source, cancelled.get()); return ExportResult{.saved = true, .diagnostic = {}}; } catch (const SaveDestinationError& error) diff --git a/src/main_window.hpp b/src/main_window.hpp index 6d8d3e1..e336608 100644 --- a/src/main_window.hpp +++ b/src/main_window.hpp @@ -84,7 +84,7 @@ class MainWindow final : public QMainWindow void releaseArchive(); bool chooseWorkspacePath(); bool chooseTrackerPath(); - bool chooseImageArchivePath(); + bool chooseArchivePath(); bool saveTrackerWithRecoveryPrompt(const QString& path); void finishSaving(); bool saveCurrentWorkspace(); diff --git a/src/paired_save.hpp b/src/paired_save.hpp index 6254192..c23fed3 100644 --- a/src/paired_save.hpp +++ b/src/paired_save.hpp @@ -99,6 +99,13 @@ void writeTrackerImagePackageCopy(const QString& path, const QString& mediaProjectPath, const std::atomic_bool* cancelled = nullptr); +/// Export an audited linked MOV archive copy under the same destination lock and recovery rules. +/// Implemented by the media-package adapter; no companion or session save checkpoints are changed. +void writeTrackerMoviePackageCopy(const QString& path, + const WorkspaceDocument& document, + const QString& mediaProjectPath, + const std::atomic_bool* cancelled = nullptr); + /// Invoke a writer synchronously with its canonical physical destination while holding the sibling save lock. /// Refuses pending recovery records. The callback must finish its write before returning and must not /// re-enter this destination's save operations; it cannot retain the borrowed path or change session state. diff --git a/src/tracker_image_package.cpp b/src/tracker_image_package.cpp index 3762136..3d26258 100644 --- a/src/tracker_image_package.cpp +++ b/src/tracker_image_package.cpp @@ -2,6 +2,7 @@ #include "paired_save.hpp" #include "tracker_archive.hpp" #include "tracker_writer.hpp" +#include "video_reader.hpp" #include "workspace_document.hpp" #include @@ -31,7 +32,8 @@ constexpr qint64 MAX_IMAGE_PIXELS = qint64{32} * 1024 * 1024; void require(bool valid) { if (!valid) - throw ProjectError("Cannot package this linked-image experiment safely."); + throw ProjectError( + QCoreApplication::translate("TrackerImagePackage", "Cannot package this linked-media experiment safely.").toStdString()); } void requireReadable(bool valid, const QString& source) @@ -45,7 +47,7 @@ void requireReadable(bool valid, const QString& source) void checkCancelled(const std::atomic_bool* cancelled) { if (cancelled && *cancelled) - throw ProjectError("Tracker image packaging was canceled."); + throw ProjectError(QCoreApplication::translate("TrackerImagePackage", "Tracker media packaging was canceled.").toStdString()); } void validateImage(const QString& stagedPath, const QString& source, const std::atomic_bool* cancelled) @@ -143,27 +145,40 @@ void writeTrackerImagePackageCopy(const QString& path, const std::atomic_bool* cancelled) { if (!path.endsWith(".trz", Qt::CaseInsensitive) || QFileInfo(path).isSymbolicLink()) - throw ProjectError("Choose a regular Tracker archive filename."); + throw ProjectError(QCoreApplication::translate("TrackerImagePackage", "Choose a regular Tracker archive filename.").toStdString()); withSaveDestinationLock( path, cancelled, [&](const auto& physical) { writeTrackerImagePackage(physical, document, mediaProjectPath, cancelled); }); } -void writeTrackerImagePackage(const QString& path, - const WorkspaceDocument& document, - const QString& mediaProjectPath, - const std::atomic_bool* cancelled) +void writeTrackerMoviePackageCopy(const QString& path, + const WorkspaceDocument& document, + const QString& mediaProjectPath, + const std::atomic_bool* cancelled) +{ + if (!path.endsWith(".trz", Qt::CaseInsensitive) || QFileInfo(path).isSymbolicLink()) + throw ProjectError(QCoreApplication::translate("TrackerImagePackage", "Choose a regular Tracker archive filename.").toStdString()); + withSaveDestinationLock( + path, cancelled, [&](const auto& physical) { writeTrackerMoviePackage(physical, document, mediaProjectPath, cancelled); }); +} + +namespace +{ +void writeTrackerMediaPackage( + const QString& path, const WorkspaceDocument& document, const QString& mediaProjectPath, bool movie, const std::atomic_bool* cancelled) { checkCancelled(cancelled); require(path.endsWith(".trz", Qt::CaseInsensitive) && !QFileInfo(path).isSymbolicLink()); require(document.trackerSource && !document.archivePayload && !document.archiveMember && document.archiveReference.empty() && - !document.movie && document.movieFrameTimes.empty() && !document.useDisplayOrientation); + document.movie == movie && !document.useDisplayOrientation); + require(movie ? document.alignMovieToClip && !document.movieFrameTimes.empty() && document.project.clip.mediaReferences.size() == 1 + : document.movieFrameTimes.empty()); require(!document.project.clip.mediaReferences.empty() && document.project.clip.mediaReferences.size() <= MAX_IMAGES && QFileInfo(mediaProjectPath).isAbsolute()); const auto directory = localProjectDirectory(path); require(!directory.isEmpty()); const auto destination = QDir(directory).filePath(QFileInfo(path).fileName()); QTemporaryDir temporary; - require(temporary.isValid() && QDir(temporary.path()).mkdir("images")); + require(temporary.isValid() && QDir(temporary.path()).mkdir(movie ? "media" : "images")); auto snapshot = document; QStringList members; qint64 remaining = MAX_EXTRACTED_BYTES; @@ -180,7 +195,10 @@ void writeTrackerImagePackage(const QString& path, require((character >= QLatin1Char('a') && character <= QLatin1Char('z')) || (character >= QLatin1Char('A') && character <= QLatin1Char('Z')) || (character >= QLatin1Char('0') && character <= QLatin1Char('9'))); - const auto member = QStringLiteral("images/frame-%1.%2").arg(members.size(), 6, 10, QLatin1Char('0')).arg(suffix); + // Only MOV has actual Tracker archive extraction acceptance; never disguise other containers. + require(!movie || suffix.compare("mov", Qt::CaseInsensitive) == 0); + const auto member = movie ? QStringLiteral("media/movie.mov") + : QStringLiteral("images/frame-%1.%2").arg(members.size(), 6, 10, QLatin1Char('0')).arg(suffix); QFile image(temporary.filePath(member)); require(image.open(QIODevice::WriteOnly | QIODevice::NewOnly)); remaining -= copyFile(source, image, remaining, cancelled); @@ -216,10 +234,28 @@ void writeTrackerImagePackage(const QString& path, auto reopened = importTrackerDocument(verified.projectPath(projectMember)); verified.resolveMedia(reopened.project, projectMember); require(reopened.project.clip.mediaReferences == snapshot.project.clip.mediaReferences); - for (std::size_t index = 0; index < snapshot.project.clip.mediaReferences.size(); ++index) - validateImage(QDir(verified.root()).filePath(QString::fromStdString(snapshot.project.clip.mediaReferences[index])), - workspaceMediaPath(document, mediaProjectPath, document.project.clip.mediaReferences[index]), - cancelled); + if (movie) + { + // Decode/index the immutable staged copy, including frames excluded from the clip. + // A stale document index or truncated/replaced source must not produce a portable archive. + const std::atomic_bool running{false}; + const auto index = + buildMovieIndex(QDir(verified.root()).filePath(members.back()), VideoOrientation::Encoded, cancelled ? *cancelled : running); + checkCancelled(cancelled); + requireReadable(index.frameCount > 0, + workspaceMediaPath(document, mediaProjectPath, document.project.clip.mediaReferences.front())); + if (document.project.clip.sourceFrameCount != index.frameCount || index.frameTimes != document.movieFrameTimes) + throw ProjectError( + QCoreApplication::translate("TrackerImagePackage", "The movie's frame count or timing no longer matches this document.") + .toStdString()); + } + else + { + for (std::size_t index = 0; index < snapshot.project.clip.mediaReferences.size(); ++index) + validateImage(QDir(verified.root()).filePath(QString::fromStdString(snapshot.project.clip.mediaReferences[index])), + workspaceMediaPath(document, mediaProjectPath, document.project.clip.mediaReferences[index]), + cancelled); + } checkCancelled(cancelled); require(!QFileInfo(destination).isSymbolicLink()); QSaveFile finalOutput(destination); @@ -228,4 +264,21 @@ void writeTrackerImagePackage(const QString& path, checkCancelled(cancelled); require(finalOutput.commit()); } +} // namespace + +void writeTrackerImagePackage(const QString& path, + const WorkspaceDocument& document, + const QString& mediaProjectPath, + const std::atomic_bool* cancelled) +{ + writeTrackerMediaPackage(path, document, mediaProjectPath, false, cancelled); +} + +void writeTrackerMoviePackage(const QString& path, + const WorkspaceDocument& document, + const QString& mediaProjectPath, + const std::atomic_bool* cancelled) +{ + writeTrackerMediaPackage(path, document, mediaProjectPath, true, cancelled); +} } // namespace motrack diff --git a/src/tracker_writer.hpp b/src/tracker_writer.hpp index d9f8052..5378e25 100644 --- a/src/tracker_writer.hpp +++ b/src/tracker_writer.hpp @@ -38,4 +38,14 @@ void writeTrackerImagePackage(const QString& path, const QString& mediaProjectPath, const std::atomic_bool* cancelled = nullptr); +/// Create a portable .trz from an audited, fully indexed constant-rate linked MOV document. +/// Copies the complete movie without transcoding; retains the standalone movie writer's edit limits. +/// Re-indexes staged media before replacement. Existing archives, other containers and unsupported edits are refused. +/// The shared media adapter owns decoding; the project model has no decoder dependency. +/// Document/source bindings and save checkpoints stay unchanged; cancellation preserves the destination. +void writeTrackerMoviePackage(const QString& path, + const WorkspaceDocument& document, + const QString& mediaProjectPath, + const std::atomic_bool* cancelled = nullptr); + } // namespace motrack diff --git a/tests/corpus/README.md b/tests/corpus/README.md index a7a2856..d39d60f 100644 --- a/tests/corpus/README.md +++ b/tests/corpus/README.md @@ -1197,6 +1197,24 @@ compressed record, payload and metadata remains unchanged. Independent numerical apply to both loaded tabs, including the untouched 3.5 kg image experiment. After Tracker saves and freshly reopens, the complete saved movie and every image reference must retain the pinned byte count and SHA-256, including excluded frames. Bounded local archive references are allowed; missing/duplicate -members, changed bytes, path escapes and remote URLs fail. Current-head actual Tracker acceptance, -three-platform CI and review remain required. New movie packaging, broader archive containers, variable -timing, filters, broader schemas and the historical MOV/Open Recent report remain separate work. +members, changed bytes, path escapes and remote URLs fail. PR #139 at `f166ba9` passed all six checks in +[run 42568545597](https://github.com/mgradwohl/MoTrack/actions/runs/42568545597), including the isolated baseline +and all four edited all-tab modes, with 95.82% combined C++ line coverage. Broader archive containers, +variable timing, filters, broader schemas and the historical MOV/Open Recent report remain separate work. + + +### New portable MOV package acceptance + +The independently authored `linked-mov` fixture also drives `packaged-movie`, `packaged-movie-clip` +and `packaged-movie-clip-single`. The native probe edits mass and mark 1, uses undo/redo, optionally +selects original frames 1/3 or frame 2, saves/reopens a native copy, and removes the original project. +It packages the complete MOV and then removes the external movie directory before returning. + +The independent Python ZIP/XML/hash oracle requires exactly `experiment.trk` and `media/movie.mov`, +Java-compatible deflated entries, unchanged complete movie bytes and only the requested XML edits. +Tracker opens the actual `.trz`, saves through its normal application saver and reopens in a separate +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. diff --git a/tests/corpus/manifest.json b/tests/corpus/manifest.json index 43812d4..e13396f 100644 --- a/tests/corpus/manifest.json +++ b/tests/corpus/manifest.json @@ -163,6 +163,7 @@ }, { "id": "linked-mov", + "native_movie_package_roundtrip": true, "tracker_version": "6.3.5", "file": "linked-movie.trk", "local_file": "fixtures/linked-mov/linked-movie.trk", diff --git a/tests/corpus_probe.cpp b/tests/corpus_probe.cpp index eceaed0..ab6d847 100644 --- a/tests/corpus_probe.cpp +++ b/tests/corpus_probe.cpp @@ -259,9 +259,11 @@ try motrack::writeTrackerArchiveDocument(args[3], document); return 0; } - if (args.size() == 4 && - (args[1] == "--export-linked-movie" || args[1] == "--export-linked-movie-clip" || args[1] == "--export-linked-movie-clip-single")) + 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")) { + const bool packaged = args[1].startsWith("--export-packaged-movie"); QTemporaryDir temporary; const auto inputDirectory = temporary.filePath("source"); const auto source = inputDirectory + "/input.trk"; @@ -269,12 +271,13 @@ try const QDir originalDirectory(QFileInfo(args[2]).absolutePath()); const QDir destinationDirectory(QFileInfo(args[3]).absolutePath()); const auto mediaDirectory = destinationDirectory.absoluteFilePath(QString::fromUtf8("média & movie")); - const auto movie = mediaDirectory + "/constant-rate.avi"; + const auto name = packaged ? QStringLiteral("constant-rate.mov") : QStringLiteral("constant-rate.avi"); + const auto movie = mediaDirectory + '/' + name; if (!temporary.isValid() || !QDir().mkdir(inputDirectory) || !QDir().mkdir(mediaDirectory) || !QFile::copy(args[2], source) || - !QFile::copy(originalDirectory.filePath("constant-rate.avi"), movie)) + !QFile::copy(originalDirectory.filePath(name), movie)) throw motrack::ProjectError("Could not prepare linked-movie round trip."); auto document = motrack::importTrackerDocument(source); - if (document.project.clip.mediaReferences != std::vector{"constant-rate.avi"}) + if (document.project.clip.mediaReferences != std::vector{name.toStdString()}) throw motrack::ProjectError("Expected the pinned movie reference."); const std::atomic_bool cancelled{false}; const auto index = motrack::buildMovieIndex(movie, motrack::VideoOrientation::Encoded, cancelled); @@ -290,9 +293,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") + if (args[1] != "--export-linked-movie" && args[1] != "--export-packaged-movie") { - const bool single = args[1] == "--export-linked-movie-clip-single"; + 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) || !edits.redo(document.project)) throw motrack::ProjectError("Could not edit the movie clip range."); @@ -303,7 +306,14 @@ try if (!QFile::remove(source) || !QDir().rmdir(inputDirectory)) throw motrack::ProjectError("Could not remove the original movie project directory."); document = motrack::readWorkspaceDocument(native); - motrack::writeTrackerDocument(args[3], document, native); + if (packaged) + { + motrack::writeTrackerMoviePackage(args[3], document, native); + if (!QFile::remove(movie) || !QDir().rmdir(mediaDirectory)) + throw motrack::ProjectError("Could not remove original packaged movie."); + } + else + motrack::writeTrackerDocument(args[3], document, native); return 0; } if (args.size() == 4 && (args[1] == "--export-linked-images" || args[1] == "--export-paired-linked-images" || diff --git a/tests/image_package_workspace_test.cpp b/tests/image_package_workspace_test.cpp index fe81344..ef78301 100644 --- a/tests/image_package_workspace_test.cpp +++ b/tests/image_package_workspace_test.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -82,6 +83,112 @@ bool editMass(motrack::MainWindow& window, double value) return accepted; } +TEST(ImagePackageWorkspace, ExportsIndexedMovWithoutChangingSaveStateAndReopensWithoutOriginalMedia) +{ + QTemporaryDir temporary; + const auto movieRoot = QString::fromUtf8(MOTRACK_FIXTURES) + "/../corpus/fixtures/linked-mov/"; + const auto source = temporary.filePath("input.trk"); + const auto movie = temporary.filePath("constant-rate.mov"); + const auto native = temporary.filePath("saved.motrack"); + const auto target = temporary.filePath(QString::fromUtf8("portable é & movie.trz")); + ASSERT_TRUE(QFile::copy(movieRoot + "linked-movie.trk", source)); + ASSERT_TRUE(QFile::copy(movieRoot + "constant-rate.mov", movie)); + { + motrack::MainWindow window; + ASSERT_TRUE(window.loadProject(source)); + auto* action = window.findChild("exportTrackerArchive"); + ASSERT_NE(action, nullptr); + ASSERT_TRUE(QTest::qWaitFor([action] { return action->isEnabled(); })); + ASSERT_TRUE(window.saveWorkspace(native)); + const auto saved = bytes(native); + const auto title = window.windowTitle(); + ASSERT_TRUE(QFile::remove(source)); + ASSERT_TRUE(editMass(window, 5)); + ASSERT_TRUE(window.exportTrackerArchive(target)); + EXPECT_TRUE(window.isWindowModified()); + EXPECT_EQ(window.windowTitle(), title); + EXPECT_EQ(bytes(native), saved); + ASSERT_NE(window.findChild("undoEdit"), nullptr); + ASSERT_NE(window.findChild("redoEdit"), nullptr); + ASSERT_NE(window.findChild("saveProject"), nullptr); + window.findChild("undoEdit")->trigger(); + window.findChild("saveProject")->trigger(); + EXPECT_FALSE(window.isWindowModified()); + EXPECT_DOUBLE_EQ(motrack::readWorkspaceDocument(native).project.pointMasses.at(0).mass, 2); + window.findChild("redoEdit")->trigger(); + ASSERT_TRUE(window.exportTrackerArchive(target)); + EXPECT_TRUE(window.isWindowModified()); + } + ASSERT_TRUE(QFile::remove(movie)); + ASSERT_TRUE(QFile::remove(native)); + ASSERT_TRUE(QDir(temporary.path()).mkdir("moved")); + const auto moved = temporary.filePath("moved/portable.trz"); + ASSERT_TRUE(QFile::rename(target, moved)); + const auto archive = motrack::TrackerArchive::open(moved); + EXPECT_EQ(bytes(QDir(archive.root()).filePath("media/movie.mov")), bytes(movieRoot + "constant-rate.mov")); + EXPECT_DOUBLE_EQ(motrack::importTrackerDocument(archive.projectPath("experiment.trk")).project.pointMasses.at(0).mass, 5); + motrack::MainWindow reopened; + ASSERT_TRUE(reopened.loadProject(moved)); + auto* canvas = reopened.findChild("videoCanvas"); + auto* slider = reopened.findChild("stepSlider"); + auto* action = reopened.findChild("exportTrackerArchive"); + ASSERT_TRUE(canvas && slider && action); + EXPECT_FALSE(action->isEnabled()); // Existing archives use paired Save, not new-package export. + slider->setValue(3); + ASSERT_TRUE(QTest::qWaitFor([canvas] { return canvas->accessibleName() == "Frame 3"; })); + ASSERT_TRUE(editMass(reopened, 7)); + ASSERT_TRUE(reopened.saveTrackerProject(moved)); + EXPECT_DOUBLE_EQ(motrack::readWorkspaceDocument(temporary.filePath("moved/portable.motrack")).project.pointMasses.at(0).mass, 7); +} + +TEST(ImagePackageWorkspace, MovieExportCancellationAndUnsupportedContainersPreserveTheDestination) +{ + QTemporaryDir temporary; + const auto movieRoot = QString::fromUtf8(MOTRACK_FIXTURES) + "/../corpus/fixtures/"; + const auto target = temporary.filePath("portable.trz"); + put(target, "existing archive"); + motrack::MainWindow window; + ASSERT_TRUE(window.loadProject(movieRoot + "linked-movie/linked-movie.trk")); + auto* action = window.findChild("exportTrackerArchive"); + auto* plot = window.findChild("positionPlot"); + ASSERT_TRUE(action && plot); + ASSERT_TRUE(QTest::qWaitFor([plot] { return plot->accessibleName().endsWith("Time (s)"); })); + EXPECT_FALSE(action->isEnabled()); + EXPECT_FALSE(window.exportTrackerArchive(target)); + ASSERT_TRUE(window.loadProject(movieRoot + "linked-mov/linked-movie.trk")); + ASSERT_TRUE(QTest::qWaitFor([action] { return action->isEnabled(); })); + ASSERT_TRUE(editMass(window, 5)); + auto* pool = window.findChild("workspaceSavePool"); + ASSERT_NE(pool, nullptr); + QSemaphore entered; + QSemaphore release; + pool->start( + [&] + { + entered.release(); + static_cast(release.tryAcquire(1, 5000)); + }); + ASSERT_TRUE(entered.tryAcquire(1, 5000)); + bool canceled = false; + QTimer::singleShot(0, + &window, + [&] + { + if (auto* cancel = window.findChild("cancelImageArchiveExport")) + { + cancel->click(); + canceled = true; + } + release.release(); + }); + EXPECT_FALSE(window.exportTrackerArchive(target)); + EXPECT_TRUE(canceled); + EXPECT_EQ(bytes(target), "existing archive"); + EXPECT_TRUE(window.isWindowModified()); + EXPECT_FALSE(QFileInfo::exists(temporary.filePath("portable.motrack"))); + ASSERT_TRUE(pool->waitForDone(5000)); +} + TEST(ImagePackageWorkspace, PreservesPendingPairedArchiveAndCompanionUntilExplicitRecovery) { for (const auto& stem : {QString("saved"), QString(240, 'a'), QString::fromUtf8("é").repeated(120)}) diff --git a/tests/test_corpus.py b/tests/test_corpus.py index e104a6c..dcc2e19 100644 --- a/tests/test_corpus.py +++ b/tests/test_corpus.py @@ -275,12 +275,59 @@ def build(mutation=None): package.writestr("unexpected.txt", "unexpected") build() - corpus.verify_image_package_export(original, exported, selected, case) + corpus.verify_media_package_export(original, exported, selected, case) self.assertEqual(selected.read_text(encoding="utf-8"), xml) for mutation in ["xml", "path", "pixels", "missing", "extra", "stored"]: build(mutation) with self.subTest(mutation=mutation), self.assertRaises(ValueError): - corpus.verify_image_package_export(original, exported, selected, case) + corpus.verify_media_package_export(original, exported, selected, case) + + def test_movie_package_oracle_checks_complete_media_xml_and_clip_ranges(self): + case = next(c for c in corpus.load_manifest()["cases"] if c["id"] == "linked-mov") + self.assertTrue(case["native_movie_package_roundtrip"]) + original = corpus.local_fixture_path(case) + exported = self.root / "packaged-movie.trz" + selected = self.root / "selected-movie.trk" + payload = corpus.local_fixture_path(case["assets"][0]).read_bytes() + for clip_mode in [None, "stride", "single"]: + with corpus.minidom.parse(str(original)) as document: + corpus.patch_expected_linked_media(document, movie=True, relocate=False, clip_mode=clip_mode) + xml = document.toxml().replace("constant-rate.mov", "media/movie.mov") + for mutation in [None, "xml", "path", "movie", "missing", "extra", "stored", "crc-xml", "crc-movie", "not-zip"]: + with self.subTest(clip_mode=clip_mode, mutation=mutation): + with zipfile.ZipFile(exported, "w", compression=(zipfile.ZIP_STORED if mutation == "stored" + else zipfile.ZIP_DEFLATED)) as package: + data = xml + if mutation == "xml": + data = data.replace('name="xorigin" type="double">2.0', 'name="xorigin" type="double">3.0') + elif mutation == "path": + data = data.replace("media/movie.mov", "../external.mov") + package.writestr("experiment.trk", data) + if mutation != "missing": + package.writestr("media/movie.mov", payload[:-1] + bytes([payload[-1] ^ 1]) if mutation == "movie" else payload) + if mutation == "extra": + package.writestr("unexpected.txt", "unexpected") + if mutation in {"crc-xml", "crc-movie"}: + damaged = bytearray(exported.read_bytes()) + with zipfile.ZipFile(exported) as package: + central = package.start_dir + if mutation == "crc-movie": + first = package.infolist()[0] + central += 46 + len(first.filename.encode("utf-8")) + len(first.extra) + len(first.comment) + self.assertEqual(damaged[central:central + 4], b"PK\x01\x02") + damaged[central + 16] ^= 1 # Central-directory CRC, leaving decoded bytes unchanged. + exported.write_bytes(damaged) + elif mutation == "not-zip": + exported.write_bytes(b"not a ZIP archive") + if mutation: + previous = selected.read_bytes() + message = "ZIP integrity" if mutation in {"crc-xml", "crc-movie", "not-zip"} else ".*" + with self.assertRaisesRegex(ValueError, message): + corpus.verify_media_package_export(original, exported, selected, case, clip_mode, movie=True) + self.assertEqual(selected.read_bytes(), previous) + else: + corpus.verify_media_package_export(original, exported, selected, case, clip_mode, movie=True) + self.assertEqual(selected.read_text(encoding="utf-8"), xml) 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") diff --git a/tests/tracker_movie_package_test.cpp b/tests/tracker_movie_package_test.cpp new file mode 100644 index 0000000..f4244e5 --- /dev/null +++ b/tests/tracker_movie_package_test.cpp @@ -0,0 +1,235 @@ +#include "paired_save.hpp" +#include "track_edits.hpp" +#include "tracker_archive.hpp" +#include "tracker_writer.hpp" +#include "video_reader.hpp" +#include "workspace_document.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace +{ +QString fixture(const QString& name = "linked-movie.trk") +{ + return QString::fromUtf8(MOTRACK_FIXTURES) + "/../corpus/fixtures/linked-mov/" + name; +} + +QByteArray bytes(const QString& path) +{ + QFile input(path); + if (!input.open(QIODevice::ReadOnly)) + throw motrack::ProjectError("Could not read movie package fixture."); + return input.readAll(); +} + +void put(const QString& path, const QByteArray& value) +{ + QFile output(path); + if (!output.open(QIODevice::WriteOnly) || output.write(value) != value.size()) + throw motrack::ProjectError("Could not write movie package fixture."); +} + +motrack::WorkspaceDocument indexedDocument(const QString& path = fixture()) +{ + auto document = motrack::importTrackerDocument(path); + const std::atomic_bool cancelled{false}; + const auto index = + motrack::buildMovieIndex(QFileInfo(path).dir().filePath("constant-rate.mov"), motrack::VideoOrientation::Encoded, cancelled); + document.movie = true; + document.alignMovieToClip = true; + document.movieFrameTimes = index.frameTimes; + document.project.clip.sourceFrameCount = index.frameCount; + return document; +} + +TEST(TrackerMoviePackage, RetainsEditedClipAndCompleteMovieAfterNativeReopenAndSourceRemoval) +{ + QTemporaryDir temporary; + ASSERT_TRUE(temporary.isValid()); + const auto source = temporary.filePath("input.trk"); + const auto movie = temporary.filePath("constant-rate.mov"); + ASSERT_TRUE(QFile::copy(fixture(), source)); + ASSERT_TRUE(QFile::copy(fixture("constant-rate.mov"), movie)); + auto document = indexedDocument(source); + ASSERT_EQ(document.movieFrameTimes, (std::vector{0, 0.125, 0.25, 0.375})); + 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)); + ASSERT_TRUE(edits.undo(document.project)); + ASSERT_TRUE(edits.redo(document.project)); + const auto native = temporary.filePath("saved.motrack"); + motrack::writeWorkspaceDocument(native, document); + document = {}; + ASSERT_TRUE(QFile::remove(source)); + document = motrack::readWorkspaceDocument(native); + motrack::retainWorkspaceMediaLocations(document, native); + const auto before = document.project; + const auto bindings = document.mediaLocations; + if (!document.trackerSource) + FAIL() << "Native reopen lost its source."; + const auto originalXml = document.trackerSource->xml; + const auto target = temporary.filePath(QString::fromUtf8("portable é & movie.trz")); + motrack::writeTrackerMoviePackageCopy(target, document, native); + EXPECT_EQ(document.project, before); + EXPECT_EQ(document.mediaLocations, bindings); + if (!document.trackerSource) + FAIL() << "Packaging removed the native source."; + EXPECT_EQ(document.trackerSource->xml, originalXml); + ASSERT_TRUE(QFile::remove(native)); + ASSERT_TRUE(QFile::remove(movie)); + ASSERT_TRUE(QDir(temporary.path()).mkdir("relocated")); + const auto relocated = temporary.filePath("relocated/experiment.trz"); + ASSERT_TRUE(QFile::rename(target, relocated)); + auto archive = std::make_shared(motrack::TrackerArchive::open(relocated)); + ASSERT_EQ(archive->projects(), QStringList{"experiment.trk"}); + auto reopened = motrack::importTrackerDocument(archive->projectPath("experiment.trk")); + archive->resolveMedia(reopened.project, "experiment.trk"); + auto expected = before; + expected.clip.sourceFrameCount.reset(); + expected.clip.mediaReferences = {"media/movie.mov"}; + EXPECT_EQ(reopened.project, expected); + const auto packagedMovie = QDir(archive->root()).filePath("media/movie.mov"); + EXPECT_EQ(bytes(packagedMovie), bytes(fixture("constant-rate.mov"))); + const std::atomic_bool cancelled{false}; + const auto index = motrack::buildMovieIndex(packagedMovie, motrack::VideoOrientation::Encoded, cancelled); + ASSERT_EQ(index.frameCount, 4); + EXPECT_EQ(index.frameTimes, document.movieFrameTimes); + reopened.movie = true; + reopened.alignMovieToClip = true; + reopened.movieFrameTimes = index.frameTimes; + reopened.project.clip.sourceFrameCount = index.frameCount; + reopened.archivePayload = archive; + reopened.archiveMember = "experiment.trk"; + const auto portableNative = temporary.filePath("portable.motrack"); + motrack::writeWorkspaceDocument(portableNative, reopened); + reopened = {}; + archive.reset(); + ASSERT_TRUE(QFile::remove(relocated)); + reopened = motrack::readWorkspaceDocument(portableNative); + ASSERT_TRUE(edits.setPointMassValue(reopened.project, 0, 7)); + const auto saved = temporary.filePath("resaved.trz"); + motrack::writeTrackerArchiveDocument(saved, reopened); + const auto again = motrack::TrackerArchive::open(saved); + EXPECT_DOUBLE_EQ(motrack::importTrackerDocument(again.projectPath("experiment.trk")).project.pointMasses.at(0).mass, 7); + EXPECT_EQ(bytes(QDir(again.root()).filePath("media/movie.mov")), bytes(fixture("constant-rate.mov"))); +} + +TEST(TrackerMoviePackage, RefusesUnsupportedOrUnindexedInputsWithoutReplacingDestination) +{ + for (int kind = 0; kind < 12; ++kind) + { + SCOPED_TRACE(kind); + QTemporaryDir temporary; + auto document = indexedDocument(); + std::atomic_bool cancelled{kind == 0}; + switch (kind) + { + case 1: + document.trackerSource.reset(); + break; + case 2: + document.movie = false; + break; + case 3: + document.movieFrameTimes.clear(); + break; + case 4: + document.alignMovieToClip = false; + break; + case 5: + document.useDisplayOrientation = true; + break; + case 6: + document.archiveMember = "experiment.trk"; + break; + case 7: + document.project.clip.mediaReferences = {"missing.mov"}; + break; + case 8: + document.project.clip.mediaReferences = {fixture("../linked-movie/constant-rate.avi").toStdString()}; + break; + case 9: + document.project.clip.frameDurationMs = 250; + break; + case 10: + document.project.pointMasses.front().name = "unsupported rename"; + break; + default: + break; + } + const auto target = temporary.filePath(kind == 11 ? "wrong.trk" : "saved.trz"); + put(target, "existing destination"); + EXPECT_THROW(motrack::writeTrackerMoviePackage(target, document, fixture(), &cancelled), motrack::ProjectError); + EXPECT_EQ(bytes(target), "existing destination"); + } +} + +TEST(TrackerMoviePackage, RejectsCorruptAndChangedTimingInTheStagedMovie) +{ + for (const bool changedTiming : {false, true}) + { + SCOPED_TRACE(changedTiming); + QTemporaryDir temporary; + auto document = indexedDocument(); + const auto source = temporary.filePath(QString::fromUtf8("changed é.mov")); + auto payload = bytes(fixture("constant-rate.mov")); + if (changedTiming) + { + const auto atom = payload.indexOf("stts"); + ASSERT_GT(atom, 0); + payload.replace(atom + 16, 4, QByteArray::fromHex("000000fa")); // 250 ms instead of 125 ms. + } + else + payload = "not a movie"; + put(source, payload); + if (changedTiming) + { + const std::atomic_bool cancelled{false}; + const auto index = motrack::buildMovieIndex(source, motrack::VideoOrientation::Encoded, cancelled); + ASSERT_EQ(index.frameCount, 4); + ASSERT_EQ(index.frameTimes, (std::vector{0, 0.25, 0.5, 0.75})); + } + document.mediaLocations.insert("constant-rate.mov", source); + const auto target = temporary.filePath("existing.trz"); + put(target, "original archive"); + try + { + motrack::writeTrackerMoviePackage(target, document, fixture()); + FAIL() << "Changed media must not replace the destination."; + } + catch (const motrack::ProjectError& error) + { + EXPECT_NE(std::string{error.what()}.find(changedTiming ? "frame count or timing" : "Could not read"), std::string::npos); + } + EXPECT_EQ(bytes(target), "original archive"); + } +} + +TEST(TrackerMoviePackage, CopyHonorsTheSharedSaveLockAndPendingRecovery) +{ + QTemporaryDir temporary; + auto document = indexedDocument(); + const auto target = temporary.filePath("saved.trz"); + put(target, "original archive"); + QLockFile lock(temporary.filePath(".saved.motrack-save.lock")); + lock.setStaleLockTime(0); + ASSERT_TRUE(lock.tryLock(0)); + EXPECT_THROW(motrack::writeTrackerMoviePackageCopy(target, document, fixture()), motrack::SaveDestinationError); + EXPECT_EQ(bytes(target), "original archive"); + lock.unlock(); + put(temporary.filePath(".saved.motrack-save.json"), "pending recovery"); + EXPECT_THROW(motrack::writeTrackerMoviePackageCopy(target, document, fixture()), motrack::SaveDestinationError); + EXPECT_EQ(bytes(target), "original archive"); +} +} // namespace diff --git a/tools/corpus.py b/tools/corpus.py index a7eb2f9..6595926 100644 --- a/tools/corpus.py +++ b/tools/corpus.py @@ -510,7 +510,9 @@ def scalar(node, value): scalar(prop(clip, name), value) video = elements(prop(clip, "video"), "object")[0] if movie: - if relocate: + if packaged: + scalar(prop(video, "path"), "media/movie.mov") + elif relocate: scalar(prop(video, "path"), "média & movie/constant-rate.avi") elif relocate or packaged: paths = prop(video, "paths") @@ -664,25 +666,33 @@ def verify_linked_movie_export_xml(original, exported, case, clip_mode=None): verify_file(exported.parent / "média & movie" / "constant-rate.avi", case["assets"][0]) -def verify_image_package_export(original, exported, selected_xml, case, clip_mode=None): - """Independent ZIP/XML/hash oracle for a new package; no external image paths may survive.""" +def verify_media_package_export(original, exported, selected_xml, case, clip_mode=None, *, movie=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("Image package reference exceeds the small-fixture limit") - images = [f"images/frame-{index:06d}.png" for index in range(4)] - with zipfile.ZipFile(exported) as package: - if (package.namelist() != ["experiment.trk", *images] or package.comment or package.testzip() is not None - or any(entry.compress_type != zipfile.ZIP_DEFLATED or entry.extract_version > 20 for entry in package.infolist())): - raise ValueError("Unexpected image package inventory or encoding") - for member, asset in zip(images, case["assets"], strict=True): - payload = package.read(member) - if len(payload) != asset["bytes"] or hashlib.sha256(payload).hexdigest() != asset["sha256"]: - raise ValueError("Packaged image bytes changed") - 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) - if canonical_tracker_xml(expected) != canonical_tracker_xml(actual): - raise ValueError("Image package changed XML beyond the requested paths, mass and mark") - selected_xml.write_bytes(xml) + raise ValueError("Media package reference exceeds the small-fixture limit") + media = ["media/movie.mov"] if movie else [f"images/frame-{index:06d}.png" for index in range(4)] + try: + with zipfile.ZipFile(exported) as package: + if (package.namelist() != ["experiment.trk", *media] or package.comment + or any(entry.compress_type != zipfile.ZIP_DEFLATED or entry.extract_version > 20 for entry in package.infolist())): + raise ValueError("Unexpected media package inventory or encoding") + # Reading every bounded member checks its CRC without an unbounded testzip() pass. + for member, asset in zip(media, case["assets"], strict=True): + if package.getinfo(member).file_size != asset["bytes"]: + raise ValueError("Packaged media size changed") + payload = package.read(member) + if len(payload) != asset["bytes"] or hashlib.sha256(payload).hexdigest() != asset["sha256"]: + raise ValueError("Packaged media bytes changed") + if package.getinfo("experiment.trk").file_size > MAX_XML_BYTES: + 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) + if canonical_tracker_xml(expected) != canonical_tracker_xml(actual): + raise ValueError("Media package changed XML beyond the requested paths, mass, mark and clip") + 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): @@ -1978,6 +1988,9 @@ def check_sessions(manifest, cache, java, probe=None): ("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-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"), ("mass", "native_mass_roundtrip"), ("marks", "native_mark_roundtrip"), ("empty-marks", "native_mark_roundtrip"), ("first-mark", "native_mark_roundtrip"), @@ -2067,17 +2080,20 @@ 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"}: + if mode in {"packaged-images", "packaged-image-clip", "packaged-movie", "packaged-movie-clip", "packaged-movie-clip-single"}: + 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_image_package_export(project, exported, selected, case, "single" if mode == "packaged-image-clip" else None) - if (output / "media é & frames").exists(): - raise ValueError("Image package probe retained original external media") + verify_media_package_export(project, exported, selected, case, clip_mode, movie=packaged_movie) + 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 mode == "packaged-image-clip": - expected, case = linked_media_clip_expected(case, single=True) + if clip_mode: + expected, case = linked_media_clip_expected(case, single=clip_mode == "single") 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") @@ -2203,7 +2219,8 @@ def check_sessions(manifest, cache, java, probe=None): stages[stage] = json.loads((output / ("session-" + stage + ".json")).read_text(encoding="utf-8")) if (stages[stage]["reference_version"] != manifest["reference"]["version"] or stages[stage]["verification_level"] != "tracker_session_" + stage - or stages[stage].get("view_preparation") != "expand_visit_custom_tables_restore_selection"): + or stages[stage].get("view_preparation") != "expand_visit_custom_tables_restore_selection" + or (mode.startswith("packaged-movie") and stages[stage].get("cache_policy") != "private_persistent_osp_cache")): raise ValueError("Unexpected Tracker session stage") callbacks = stages[stage].get("loader_callbacks") if type(callbacks) is not int or callbacks < 1: @@ -2215,15 +2232,18 @@ def check_sessions(manifest, cache, java, probe=None): assets = next(item["assets"] for item in manifest["cases"] if item["id"] == "linked-images") image_inventories[stage] = verify_saved_image_inventory(output / "tracker-resaved.trk", assets) elif case["id"] in {"linked-movie", "linked-mov"}: - movie_inventories[stage] = verify_saved_movie_asset(output / "tracker-resaved.trk", case["assets"][0]) + movie_inventories[stage] = verify_saved_movie_asset(output / "tracker-resaved.trk", case["assets"][0], + allow_archive=mode.startswith("packaged-movie")) report = {"reference_version": stages["save"]["reference_version"], "verification_level": "tracker_session_resave", "view_preparation": stages["save"]["view_preparation"], "loader_callbacks": {stage: stages[stage]["loader_callbacks"] for stage in stages}, + "cache_policy": {stage: stages[stage].get("cache_policy") for stage in stages}, "before": stages["save"]["loaded"], "after_save": stages["save"]["after_save"], "after": stages["reopen"]["loaded"], "saved_image_inventories": image_inventories, "saved_movie_assets": movie_inventories} 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"}: + "paired-linked-images", "paired-archive-images", "packaged-images", "archive-image-clip", "packaged-image-clip", + "packaged-movie", "packaged-movie-clip", "packaged-movie-clip-single"}: keys = [[0, 1, 2]] if mode in {"create-mass", "create-empty-mass"}: keys = [[0], [0], [] if mode == "create-empty-mass" else [1, 4, 8]] @@ -2262,7 +2282,8 @@ def check_sessions(manifest, cache, java, probe=None): print(f"{case['id']}/{mode}: passed (MoTrack rejected unsafe export; actual Tracker snapping confirmed; resave/reimport)") else: standard = "TRZ" if mode in {"archive-images", "paired-archive-images", "packaged-images", - "archive-image-clip", "packaged-image-clip"} else "TRK" + "archive-image-clip", "packaged-image-clip", + "packaged-movie", "packaged-movie-clip", "packaged-movie-clip-single"} 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: diff --git a/translations/motrack_de.ts b/translations/motrack_de.ts index 9e287d3..8315b23 100644 --- a/translations/motrack_de.ts +++ b/translations/motrack_de.ts @@ -3,12 +3,12 @@ MainWindow - Export portable image &archive… - Copy this experiment and its images into one portable Tracker archive. - Export portable image archive - Choose a .trz filename for an imported image project. - Exporting portable image archive - Copying the experiment and its images… + Export portable &archive… + Copy this experiment and its media into one portable Tracker archive. + Export portable archive + Choose a .trz filename for an imported image or indexed MOV project. + Exporting portable archive + Copying the experiment and its media… Canceling export… Export canceled. Existing files were kept. Could not export the Tracker archive: %1 @@ -198,6 +198,10 @@ TrackerImagePackage + The movie's frame count or timing no longer matches this document. + Choose a regular Tracker archive filename. + Cannot package this linked-media experiment safely. + Tracker media packaging was canceled. Could not read a file required for the archive: %1 diff --git a/translations/motrack_es.ts b/translations/motrack_es.ts index e20785d..120c606 100644 --- a/translations/motrack_es.ts +++ b/translations/motrack_es.ts @@ -3,12 +3,12 @@ MainWindow - Export portable image &archive… - Copy this experiment and its images into one portable Tracker archive. - Export portable image archive - Choose a .trz filename for an imported image project. - Exporting portable image archive - Copying the experiment and its images… + Export portable &archive… + Copy this experiment and its media into one portable Tracker archive. + Export portable archive + Choose a .trz filename for an imported image or indexed MOV project. + Exporting portable archive + Copying the experiment and its media… Canceling export… Export canceled. Existing files were kept. Could not export the Tracker archive: %1 @@ -198,6 +198,10 @@ TrackerImagePackage + The movie's frame count or timing no longer matches this document. + Choose a regular Tracker archive filename. + Cannot package this linked-media experiment safely. + Tracker media packaging was canceled. Could not read a file required for the archive: %1 diff --git a/translations/motrack_fr.ts b/translations/motrack_fr.ts index 928422c..13c0c52 100644 --- a/translations/motrack_fr.ts +++ b/translations/motrack_fr.ts @@ -3,12 +3,12 @@ MainWindow - Export portable image &archive… - Copy this experiment and its images into one portable Tracker archive. - Export portable image archive - Choose a .trz filename for an imported image project. - Exporting portable image archive - Copying the experiment and its images… + Export portable &archive… + Copy this experiment and its media into one portable Tracker archive. + Export portable archive + Choose a .trz filename for an imported image or indexed MOV project. + Exporting portable archive + Copying the experiment and its media… Canceling export… Export canceled. Existing files were kept. Could not export the Tracker archive: %1 @@ -198,6 +198,10 @@ TrackerImagePackage + The movie's frame count or timing no longer matches this document. + Choose a regular Tracker archive filename. + Cannot package this linked-media experiment safely. + Tracker media packaging was canceled. Could not read a file required for the archive: %1