diff --git a/.github/workflows/deploy-dmg.yml b/.github/workflows/deploy-dmg.yml deleted file mode 100644 index 775ea84f..00000000 --- a/.github/workflows/deploy-dmg.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Deploy macOS .dmg -on: - workflow_call: - workflow_dispatch: -jobs: - build-macos-dmg: - runs-on: macos-latest - steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - name: Install py2app - run: pip install py2app - - name: Build .app bundle - run: | - cd macos - python setup.py py2app - - name: Create .dmg - run: | - hdiutil create -volname "PhotoMapAI" -srcfolder macos/dist/PhotoMapAI.app -ov -format UDZO PhotoMapAI.dmg - - name: Upload .dmg - uses: actions/upload-artifact@v4 - with: - name: PhotoMapAI.dmg - path: PhotoMapAI.dmg - \ No newline at end of file diff --git a/.github/workflows/deploy-pyinstaller.yml b/.github/workflows/deploy-pyinstaller.yml index 59683352..c46d638c 100644 --- a/.github/workflows/deploy-pyinstaller.yml +++ b/.github/workflows/deploy-pyinstaller.yml @@ -3,7 +3,6 @@ name: Deploy PyInstaller Executables on: workflow_call: workflow_dispatch: - pull_request: jobs: build: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 21988178..fadeacaa 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -50,12 +50,8 @@ jobs: needs: deploy-dockerhub uses: ./.github/workflows/deploy-pyinstaller.yml - deploy-make-dmg: - needs: deploy-pyinstaller - uses: ./.github/workflows/deploy-dmg.yml - upload-release: - needs: [deploy-pypi, deploy-dockerhub, deploy-pyinstaller, deploy-make-dmg] + needs: [deploy-pypi, deploy-dockerhub, deploy-pyinstaller] runs-on: ubuntu-latest steps: - name: Download all artifacts @@ -67,6 +63,6 @@ jobs: uses: softprops/action-gh-release@v2 with: files: | - artifacts/**/*.{zip,tar.gz,dmg} + artifacts/**/*.{zip,tar.gz,app} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/INSTALL/pyinstaller/make_pyinstaller_image.ps1 b/INSTALL/pyinstaller/make_pyinstaller_image.ps1 index 2259dbba..402b0f3f 100644 --- a/INSTALL/pyinstaller/make_pyinstaller_image.ps1 +++ b/INSTALL/pyinstaller/make_pyinstaller_image.ps1 @@ -93,6 +93,7 @@ pyinstaller ` --add-data "$env:USERPROFILE/.cache/clip${sep}clip_models" ` --add-data "photomap/frontend/static${sep}photomap/frontend/static" ` --add-data "photomap/frontend/templates${sep}photomap/frontend/templates" ` + --add-data "THIRD_PARTY_LICENSES.txt${sep}.THIRD_PARTY_LICENSES" ` --paths . ` $pyinstallerMode ` --name photomap ` diff --git a/INSTALL/pyinstaller/make_pyinstaller_image.sh b/INSTALL/pyinstaller/make_pyinstaller_image.sh index 7b14716e..8181496d 100755 --- a/INSTALL/pyinstaller/make_pyinstaller_image.sh +++ b/INSTALL/pyinstaller/make_pyinstaller_image.sh @@ -101,6 +101,7 @@ PYINSTALLER_ARGS=( --add-data "$HOME/.cache/clip:clip_models" --add-data "photomap/frontend/static:photomap/frontend/static" --add-data "photomap/frontend/templates:photomap/frontend/templates" + --add-data "THIRD_PARTY_LICENSES.txt:THIRD_PARTY_LICENSES" --paths . $PYINSTALLER_MODE --argv-emulation diff --git a/THIRD_PARTY_LICENSES.txt b/THIRD_PARTY_LICENSES.txt new file mode 100644 index 00000000..495168f5 --- /dev/null +++ b/THIRD_PARTY_LICENSES.txt @@ -0,0 +1,20 @@ +CLIP Model Weights +Copyright (c) 2021 OpenAI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/photomap/frontend/static/javascript/events.js b/photomap/frontend/static/javascript/events.js index f6d29e35..2e0602ed 100644 --- a/photomap/frontend/static/javascript/events.js +++ b/photomap/frontend/static/javascript/events.js @@ -488,7 +488,6 @@ export async function toggleGridSwiperView(gridView = null) { else state.gridViewActive = gridView; saveSettingsToLocalStorage(); - const swiperContainer = document.querySelector(".swiper"); const gridViewBtn = document.getElementById("gridViewBtn"); const gridViewIcon = gridViewBtn.querySelector("svg"); diff --git a/photomap/frontend/static/javascript/grid-view.js b/photomap/frontend/static/javascript/grid-view.js index c504e240..049c9252 100644 --- a/photomap/frontend/static/javascript/grid-view.js +++ b/photomap/frontend/static/javascript/grid-view.js @@ -21,6 +21,7 @@ let slidesPerBatch = 0; // Number of slides to load per batch let slideHeight = 140; // Default slide height (reduced from 200) let currentRows = 0; // Track current grid dimensions let currentColumns = 0; +let suppressSlideChange = false; let slideData = {}; // Store data for each slide const GRID_MAX_SCREENS = 6; // Keep up to this many screens in memory (tweakable) @@ -63,6 +64,7 @@ function calculateGridGeometry() { // Initialization code export async function initializeGridSwiper() { + console.log("Initializing grid swiper..."); gridInitialized = false; showSpinner(); eventRegistry.removeAll("grid"); // Clear previous event handlers @@ -120,11 +122,6 @@ export async function initializeGridSwiper() { const swiperContainer = document.querySelector(".swiper"); swiperContainer.classList.add("grid-mode"); - state.swiper.on("slideChange", () => { - // do nothing with this. - return; - }); - addGridEventListeners(); setupContinuousNavigation(); setupGridResizeHandler(); @@ -206,6 +203,7 @@ function addGridEventListeners() { // Load more when reaching the end state.swiper.on("slideNextTransitionStart", async () => { if (state.isTransitioning) return; // Don't load more during transitions + showSpinner(); state.isTransitioning = true; await waitForBatchLoadingToFinish(); state.isTransitioning = false; @@ -222,8 +220,17 @@ function addGridEventListeners() { const index = slideState.isSearchMode ? slideState.globalToSearch(lastSlideIndex) + 1 : lastSlideIndex + 1; - await loadBatch(index, true); // Append a batch at the end + await waitForBatchLoadingToFinish(); + setBatchLoading(true); + try { + await loadBatch(index, true); // Append a batch at the end + } catch (error) { + console.log(error); + } finally { + setBatchLoading(false); + } } + hideSpinner(); }); // Load more when reaching the start @@ -231,6 +238,7 @@ function addGridEventListeners() { if (state.isTransitioning) return; // Don't load more during transitions state.isTransitioning = true; await waitForBatchLoadingToFinish(); + setBatchLoading(true); state.isTransitioning = false; const firstSlide = parseInt( state.swiper.slides[0].dataset.globalIndex, @@ -242,12 +250,19 @@ function addGridEventListeners() { if (firstSlide > 0 && state.swiper.activeIndex === 0) { await loadBatch(index - 1, false); // Prepend a batch at the start } + setBatchLoading(false); + }); + + // transitionEnd event + state.swiper.on("transitionEnd", () => { + suppressSlideChange = false; }); // onChange event state.swiper.on("slideChange", async () => { - // If the currently highlighted slide s.seais not visible, move the highlight to the top-left slide - await state.swiper.update(); // Ensure Swiper state is current + if (suppressSlideChange) return; + + // If the currently highlighted slide is not visible, move the highlight to the top-left slide const currentSlide = slideState.getCurrentSlide(); const currentGlobal = currentSlide.globalIndex; const slideEl = document.querySelector( @@ -293,7 +308,7 @@ function addGridEventListeners() { window.handleGridSlideDblClick = async function (globalIndex) { // Prevent navigation if we're already transitioning if (state.isTransitioning) return; - + slideState.setCurrentIndex(globalIndex, false); updateCurrentSlideHighlight(globalIndex); @@ -323,15 +338,12 @@ function addDoubleTapHandler(slideEl, globalIndex) { // @param {number|null} targetIndex - Optional index to include in first screen. // If null, use current slide index. async function resetAllSlides() { - if (!gridInitialized) return; if (!state.swiper) return; showSpinner(); await new Promise(requestAnimationFrame); // display spinner - await waitForBatchLoadingToFinish(); - setBatchLoading(true); const targetIndex = slideState.getCurrentIndex(); @@ -346,19 +358,15 @@ async function resetAllSlides() { console.warn("removeAllSlides failed:", err); } try { - state.swiper.update(); // ensure internal state is correct + await waitForBatchLoadingToFinish(); + setBatchLoading(true); + await loadBatch(targetIndex, true); + await loadBatch(targetIndex + slidesPerBatch, true); // Load two batches to start in order to enable forward navigation + if (targetIndex > 0) { + await loadBatch(targetIndex, false); // Prepend a screen if not at start + } } catch (err) { - console.warn( - "swiper.update() failed:", - err, - "\ncontinuing anyway, slide length =", - state.swiper.slides.length - ); - } - await loadBatch(targetIndex, true); - await loadBatch(targetIndex + slidesPerBatch, true); // Load two batches to start in order to enable forward navigation - if (targetIndex > 0) { - await loadBatch(targetIndex, false); // Prepend a screen if not at start + console.log(err); } updateCurrentSlide(); setBatchLoading(false); @@ -370,8 +378,9 @@ async function resetAllSlides() { // The startIndex will be adjusted to be an even multiple of the screen size. // If startIndex is null, load the next batch after the last loaded slide. async function loadBatch(startIndex = null, append = true) { + + // Calculate the next batch to load if (startIndex === null) { - // Load after the last loaded slide if (!state.swiper.slides?.length) { startIndex = 0; } else { @@ -396,14 +405,21 @@ async function loadBatch(startIndex = null, append = true) { // --- NORMAL BATCH LOAD --- if (append) { for (let i = 0; i < slidesPerBatch; i++) { + if (i % 4 === 0) { + await new Promise(requestAnimationFrame); // yield to UI thread every 4 images + if (state.isTransitioning) { + throw new Error("Aborted loadBatch due to transition"); + } + } + // Calculate offset from current slide position const offset = startIndex + i; // Use slideState.resolveOffset to get the correct indices for this position const globalIndex = slideState.indexToGlobal(offset); + if (globalIndex === null) continue; // Out of bounds // In the event that the slide is already loaded, skip it. - // I'm not sure this logic is necessary if load tracking is done correctly. if (loadedImageIndices.has(globalIndex)) { continue; } @@ -421,10 +437,6 @@ async function loadBatch(startIndex = null, append = true) { console.error("Failed to load image:", error); break; } - if (i % 8 === 0) { - await new Promise(requestAnimationFrame); - if (state.isTransitioning) return; - } } if (slides.length > 0) state.swiper.appendSlide(slides); @@ -446,8 +458,14 @@ async function loadBatch(startIndex = null, append = true) { } else { // --- PREPEND LOGIC: Add a full screen's worth of slides before startIndex --- for (let i = 0; i < slidesPerBatch; i++) { + if (i % 4 === 0) { + await new Promise(requestAnimationFrame); // yield to UI thread every 4 images + if (state.isTransitioning) { + throw new Error("Aborted loadBatch due to transition"); + } + } + const globalIndex = slideState.indexToGlobal(startIndex - i - 1); // reverse order - // not sure this is wanted here if (loadedImageIndices.has(globalIndex)) continue; try { @@ -461,12 +479,10 @@ async function loadBatch(startIndex = null, append = true) { console.error("Failed to load image (prepend):", error); continue; } - if (i % 8 === 0) { - await new Promise(requestAnimationFrame); - if (state.isTransitioning) return; - } } if (slides.length > 0) { + suppressSlideChange = true; + state.swiper.prependSlide(slides); // After prepending slides, add double-tap handlers to all the new ones. @@ -478,7 +494,6 @@ async function loadBatch(startIndex = null, append = true) { } } state.swiper.slideTo(currentColumns, 0); // maintain current view - // enforce high water mark after prepending (trim the other side) enforceHighWaterMark(true); } } @@ -569,9 +584,6 @@ function enforceHighWaterMark(trimFromEnd = false) { delete slideData[g]; } - // Update Swiper internals - state.swiper.update(); - // Adjust active index once to avoid a jump: if (!trimFromEnd) { // We removed removeScreens full screens from the start. @@ -585,8 +597,6 @@ function enforceHighWaterMark(trimFromEnd = false) { const targetActive = Math.min(prevActive, maxActive); state.swiper.slideTo(targetActive, 0); } - - state.swiper.update(); } function setupContinuousNavigation() { @@ -703,10 +713,17 @@ function setupGridResizeHandler() { newGeometry.columns !== currentColumns || Math.abs(newGeometry.tileSize - slideHeight) > 10 ) { + // Current global index + const currentGlobalIndex = slideState.getCurrentSlide().globalIndex; // Reinitialize the grid completely await initializeGridSwiper(); - await loadBatch(); - await loadBatch(); // Load two batches to start + + // Do not allow concurrent execution! + await waitForBatchLoadingToFinish(); + setBatchLoading(true); + await loadBatch(currentGlobalIndex); + await loadBatch(currentGlobalIndex + slidesPerBatch); // Load two batches to start + setBatchLoading(false); } }, 300); // 300ms debounce delay } @@ -760,13 +777,15 @@ function makeSlideHTML(data, globalIndex) { data.searchIndex = slideState.globalToSearch(globalIndex); slideData[globalIndex] = data; // Cache the data + // replace image_url with thumbnail_url + const thumbnail_url = `thumbnails/${state.album}/${globalIndex}?size=${slideHeight}`; return `
- ${data.filename}
`; diff --git a/photomap/frontend/static/javascript/slide-state.js b/photomap/frontend/static/javascript/slide-state.js index d434363c..6fd6e700 100644 --- a/photomap/frontend/static/javascript/slide-state.js +++ b/photomap/frontend/static/javascript/slide-state.js @@ -261,7 +261,6 @@ class SlideStateManager { } seekToSlideIndex() { - console.log("Sending seekToSlideIndex event") const slideInfo = this.getCurrentSlide(); window.dispatchEvent( new CustomEvent("seekToSlideIndex", { diff --git a/photomap/frontend/static/javascript/swiper.js b/photomap/frontend/static/javascript/swiper.js index 5b645b1e..b5f0d39d 100644 --- a/photomap/frontend/static/javascript/swiper.js +++ b/photomap/frontend/static/javascript/swiper.js @@ -23,6 +23,8 @@ let isAppending = false; let isInternalSlideChange = false; // To prevent recursion in slideChange handler export async function initializeSingleSwiper() { + console.log("Initializing single swiper..."); + // The swiper shares part of the DOM with the grid view, // so we need to clean up any existing state. eventRegistry.removeAll("swiper"); // Clear previous event handlers @@ -422,12 +424,15 @@ export async function resetAllSlides() { await waitForBatchLoadingToFinish(); setBatchLoading(true); + console.log("Resetting all slides in swiper"); + const slideShowRunning = state.swiper?.autoplay?.running; pauseSlideshow(); state.swiper.removeAllSlides(); const { globalIndex, searchIndex } = slideState.getCurrentSlide(); + console.log("Current slide index:", globalIndex, searchIndex); // Prevent intermediate rendering while we add slides const swiperContainer = document.querySelector(".swiper"); diff --git a/photomap/frontend/static/javascript/umap.js b/photomap/frontend/static/javascript/umap.js index a355b8a5..3f054118 100644 --- a/photomap/frontend/static/javascript/umap.js +++ b/photomap/frontend/static/javascript/umap.js @@ -369,6 +369,12 @@ export async function fetchUmapData() { mapExists = true; } +// Add this after your Plotly event handlers in fetchUmapData() +const plotDiv = document.getElementById("umapPlot"); +plotDiv.addEventListener("mouseleave", () => { + removeUmapThumbnail(); +}); + // --- Dynamic Colorization --- export function colorizeUmap({ highlight = false, searchResults = [] } = {}) { if (!points.length) return;