├── .changes ├── header.tpl.md ├── unreleased │ ├── .gitkeep │ ├── Added-20250527-231337.yaml │ ├── Fixed-20250320-004431.yaml │ ├── Fixed-20250320-004457.yaml │ ├── Fixed-20250320-004524.yaml │ └── Fixed-20250526-202420.yaml ├── v0.17.1.md └── v0.18.0.md ├── .changie.yaml ├── .dockerignore ├── .env.development ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── release.yml ├── .gitignore ├── .goreleaser.yml ├── .vscode └── launch.json ├── CHANGELOG.md ├── Dockerfile ├── Dockerfile-goreleaser ├── Dockerfile-multiarch ├── LICENSE ├── README.md ├── Taskfile.yml ├── api.yaml ├── cmd ├── sha256 │ └── main.go └── zip │ └── main.go ├── config.go ├── db ├── migrations-thumbs │ ├── 000001_init.down.sql │ └── 000001_init.up.sql └── migrations │ ├── 000001_create_infos_table.down.sql │ ├── 000001_create_infos_table.up.sql │ ├── 000002_add_indexed_at.down.sql │ ├── 000002_add_indexed_at.up.sql │ ├── 000003_remove_indexed_at.down.sql │ ├── 000003_remove_indexed_at.up.sql │ ├── 000004_indexes.down.sql │ ├── 000004_indexes.up.sql │ ├── 000005_dirs.down.sql │ ├── 000005_dirs.up.sql │ ├── 000006_add_orientation.down.sql │ ├── 000006_add_orientation.up.sql │ ├── 000007_reformat_time_to_iso8601.down.sql │ ├── 000007_reformat_time_to_iso8601.up.sql │ ├── 000008_drop_redundant_index.down.sql │ ├── 000008_drop_redundant_index.up.sql │ ├── 000009_prefix_and_unix.down.sql │ ├── 000009_prefix_and_unix.up.sql │ ├── 000010_explicit_rowid.down.sql │ ├── 000010_explicit_rowid.up.sql │ ├── 000011_clip_emb.down.sql │ ├── 000011_clip_emb.up.sql │ ├── 000012_tags.down.sql │ ├── 000012_tags.up.sql │ ├── 000013_add_gps_coords.down.sql │ ├── 000013_add_gps_coords.up.sql │ ├── 000014_tag_dates.down.sql │ ├── 000014_tag_dates.up.sql │ ├── 000015_tag_versions.down.sql │ └── 000015_tag_versions.up.sql ├── defaults.yaml ├── docker-compose.yaml ├── docker ├── grafana │ ├── Dockerfile │ ├── dashboards │ │ └── photofield.json │ └── provisioning │ │ ├── dashboards │ │ └── default.yaml │ │ └── datasources │ │ └── default.yaml └── prometheus │ ├── Dockerfile │ └── prometheus.yml ├── docs ├── .vitepress │ ├── .gitignore │ ├── config.mts │ └── theme │ │ ├── custom.css │ │ └── index.js ├── assets │ ├── album.jpg │ ├── app-bar.png │ ├── background.jpeg │ ├── context-menu.jpeg │ ├── context-menu.png │ ├── flex.png │ ├── highlights.png │ ├── layouts.png │ ├── logo-wide.jpg │ ├── logo-zoom.gif │ ├── map.jpg │ ├── progressive-load.gif │ ├── seamless-zoom.gif │ ├── semantic-search.jpg │ ├── timeline.jpg │ └── wall.jpg ├── components │ └── Background.vue ├── configuration.md ├── contributing.md ├── credits.md ├── dependencies.md ├── development.md ├── features │ ├── geolocation.md │ ├── layouts.md │ ├── search.md │ └── tags.md ├── index.md ├── license.md ├── maintenance.md ├── package-lock.json ├── package.json ├── performance.md ├── public │ ├── assets │ │ └── features │ │ │ ├── cat-eyes.jpg │ │ │ ├── downloads.png │ │ │ ├── file-scroll.gif │ │ │ ├── gource.jpeg │ │ │ ├── llama.gif │ │ │ ├── map.jpg │ │ │ ├── media-system.png │ │ │ ├── progressive-load.gif │ │ │ ├── read-only.png │ │ │ ├── seamless-zoom.gif │ │ │ ├── slovenia.jpg │ │ │ └── tags.jpg │ ├── favicon-32x32.png │ └── favicon.ico ├── quick-start.md ├── usage.md └── user-interface.md ├── e2e ├── .gitignore ├── configs │ ├── holiday.yaml │ └── three-collections.yaml ├── package-lock.json ├── package.json ├── playwright.config.ts ├── src │ ├── fixtures.ts │ ├── steps.ts │ └── teardown.ts └── tests │ ├── cleanup.feature │ ├── connection-error.feature │ ├── first-run.feature │ └── rescan.feature ├── embed-docs-stub.go ├── embed-docs.go ├── embed-geo-stub.go ├── embed-geo.go ├── embed-ui-stub.go ├── embed-ui.go ├── fonts └── Roboto │ ├── LICENSE.txt │ ├── Roboto-Black.ttf │ ├── Roboto-BlackItalic.ttf │ ├── Roboto-Bold.ttf │ ├── Roboto-BoldItalic.ttf │ ├── Roboto-Italic.ttf │ ├── Roboto-Light.ttf │ ├── Roboto-LightItalic.ttf │ ├── Roboto-Medium.ttf │ ├── Roboto-MediumItalic.ttf │ ├── Roboto-Regular.ttf │ ├── Roboto-Thin.ttf │ └── Roboto-ThinItalic.ttf ├── go.mod ├── go.sum ├── internal ├── clip │ ├── ai.go │ └── clip.go ├── codec │ ├── image.go │ └── libjpeg.go ├── collection │ └── collection.go ├── fs │ ├── rewrite │ │ └── fs.go │ ├── watch.go │ └── watch_test.go ├── geo │ ├── cache.go │ └── geo.go ├── image │ ├── cache.go │ ├── color.go │ ├── database.go │ ├── decoder.go │ ├── exiftool-mostlygeek.go │ ├── goexif-rwcarlsen.go │ ├── indexContents.go │ ├── indexFiles.go │ ├── indexMetadata.go │ ├── info.go │ ├── range.go │ ├── search.go │ ├── source.go │ ├── sourceConfig.go │ └── sourceInfo.go ├── layout │ ├── album.go │ ├── common.go │ ├── dag │ │ └── dag.go │ ├── flex.go │ ├── highlights.go │ ├── map.go │ ├── search.go │ ├── square.go │ ├── strip.go │ ├── timeline.go │ └── wall.go ├── metrics │ ├── metrics.go │ └── timing.go ├── openapi │ └── api.gen.go ├── queue │ └── queue.go ├── render │ ├── bitmap.go │ ├── photo.go │ ├── rect.go │ ├── scene.go │ ├── solid.go │ ├── sprite.go │ └── text.go └── scene │ └── sceneSource.go ├── io ├── bench │ └── bench.go ├── cached │ └── cached.go ├── configured │ └── configured.go ├── djpeg │ └── djpeg.go ├── exiftool │ └── exiftool.go ├── ffmpeg │ └── ffmpeg.go ├── filtered │ └── filtered.go ├── goexif │ └── goexif.go ├── goimage │ └── goimage.go ├── io.go ├── mutex │ └── mutex.go ├── ristretto │ └── ristretto.go ├── sqlite │ ├── sqlite.go │ └── sqlite_test.go └── thumb │ └── thumb.go ├── io_test.go ├── justfile ├── main.go ├── rangetree ├── rangetree.go └── rangetree_test.go ├── reload_test.go ├── search ├── query.go └── query_test.go ├── tag ├── config.go ├── exif.go ├── selection.go └── tag.go ├── tools ├── jupyter │ ├── data │ │ └── browserperf.ipynb │ └── docker-compose.yaml ├── profile-allocs.ps1 ├── profile-block.ps1 ├── profile-cpu.ps1 ├── profile-goroutine.ps1 ├── profile-heap.ps1 ├── profile-mutex.ps1 └── profile-trace.ps1 ├── ui ├── .env.development ├── .gitignore ├── index.html ├── jsconfig.json ├── package-lock.json ├── package.json ├── public │ ├── about.txt │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── manifest.webmanifest │ └── maskable-512x512.png ├── src │ ├── App.vue │ ├── Root.vue │ ├── api.js │ ├── components │ │ ├── CenterMessage.vue │ │ ├── CollectionDebug.vue │ │ ├── CollectionLink.vue │ │ ├── CollectionPanel.vue │ │ ├── CollectionSettings.vue │ │ ├── CollectionView.vue │ │ ├── ColorModeSwitch.vue │ │ ├── Controls.vue │ │ ├── DateStrip.vue │ │ ├── DetailItem.vue │ │ ├── DisplaySettings.vue │ │ ├── Downloads.vue │ │ ├── ErrorBar.vue │ │ ├── ExpandButton.vue │ │ ├── Home.vue │ │ ├── Map.vue │ │ ├── MapViewer.vue │ │ ├── Overlays.vue │ │ ├── PageTitle.vue │ │ ├── PhotoDetails.vue │ │ ├── PhotoSkeleton.vue │ │ ├── PixelCount.vue │ │ ├── RectDebug.vue │ │ ├── RegionMenu.vue │ │ ├── ResponseLoader.vue │ │ ├── ResponseRetryButton.vue │ │ ├── ScrollViewer.vue │ │ ├── Scrollbar.vue │ │ ├── SearchInput.vue │ │ ├── Spinner.vue │ │ ├── TagEditor.vue │ │ ├── Tags.vue │ │ ├── TaskList.vue │ │ ├── TileViewer.vue │ │ ├── ToolbarTasks.vue │ │ ├── VideoPlayer.vue │ │ └── openlayers │ │ │ ├── CrossDragPan.js │ │ │ └── geoview.js │ ├── index.css │ ├── main.js │ ├── router │ │ └── index.js │ ├── simulation.js │ ├── use.js │ └── utils.js └── vite.config.js └── vetur.config.js /.changes/header.tpl.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), 6 | and is generated by [Changie](https://github.com/miniscruff/changie). -------------------------------------------------------------------------------- /.changes/unreleased/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/.changes/unreleased/.gitkeep -------------------------------------------------------------------------------- /.changes/unreleased/Added-20250527-231337.yaml: -------------------------------------------------------------------------------- 1 | kind: Added 2 | body: Make use of djpeg / libjpeg-turbo if installed to make image loading faster in the absence of better thumbnails. libjpeg-turbo can partially decode smaller resolutions of JPEGs, which can be many times faster than loading the full resolution and then resizing. 3 | time: 2025-05-27T23:13:37.777811134+02:00 4 | -------------------------------------------------------------------------------- /.changes/unreleased/Fixed-20250320-004431.yaml: -------------------------------------------------------------------------------- 1 | kind: Fixed 2 | body: Fast scrolling leading to cut off rendering at the edge 3 | time: 2025-03-20T00:44:31.646248+01:00 4 | -------------------------------------------------------------------------------- /.changes/unreleased/Fixed-20250320-004457.yaml: -------------------------------------------------------------------------------- 1 | kind: Fixed 2 | body: Clicks/taps unintentionally zooming into photos 3 | time: 2025-03-20T00:44:57.5708667+01:00 4 | -------------------------------------------------------------------------------- /.changes/unreleased/Fixed-20250320-004524.yaml: -------------------------------------------------------------------------------- 1 | kind: Fixed 2 | body: Zooming being sometimes too fast 3 | time: 2025-03-20T00:45:24.9550322+01:00 4 | -------------------------------------------------------------------------------- /.changes/unreleased/Fixed-20250526-202420.yaml: -------------------------------------------------------------------------------- 1 | kind: Fixed 2 | body: Fixed the top right progress spinner being invisible in the light theme 3 | time: 2025-05-26T20:24:20.118010338+02:00 4 | -------------------------------------------------------------------------------- /.changes/v0.18.0.md: -------------------------------------------------------------------------------- 1 | ## [v0.18.0] - 2025-03-02 - Wider platform support and improved build process 2 | 3 | There is no new functionality in this release, but the build and release process 4 | has been refactored to use Taskfile and changie -- a nice learning experience. 5 | 6 | The entire changelog has been extracted to CHANGELOG.md, which is now the 7 | canonical source of release notes. 8 | 9 | Releases are now built for many more platforms than before, let me know 10 | if any of them come in handy or have issues :) 11 | 12 | ### Added 13 | * Refactored the build and release process to use Taskfile and changie 14 | 15 | [v0.18.0]: https://github.com/SmilyOrg/photofield/compare/v0.17.1...v0.18.0 16 | 17 | -------------------------------------------------------------------------------- /.changie.yaml: -------------------------------------------------------------------------------- 1 | changesDir: .changes 2 | unreleasedDir: unreleased 3 | headerPath: header.tpl.md 4 | changelogPath: CHANGELOG.md 5 | versionExt: md 6 | kindFormat: '### {{.Kind}}' 7 | changeFormat: '* {{.Body}}' 8 | versionFormat: '## [{{.Version}}] - {{.Time.Format "2006-01-02"}}' 9 | footerFormat: | 10 | 11 | [{{ .Version }}]: https://github.com/SmilyOrg/photofield/compare/{{ .PreviousVersion }}...{{ .Version }} 12 | kinds: 13 | - label: Breaking Changes 14 | auto: minor # at version zero, this is still minor 15 | - label: Added 16 | auto: minor 17 | - label: Removed 18 | auto: minor # at version zero, this is still minor 19 | - label: Deprecated 20 | auto: minor 21 | - label: Fixed 22 | auto: patch 23 | - label: Security 24 | auto: patch 25 | newlines: 26 | afterChangelogHeader: 1 27 | beforeChangelogVersion: 3 28 | endOfVersion: 1 29 | beforeKind: 1 30 | envPrefix: CHANGIE_ 31 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .vs 3 | .vscode 4 | .history 5 | .docker-image-id 6 | photos/ 7 | profiles/ 8 | data/ 9 | main 10 | photofield 11 | photofield.exe 12 | photofield.exe~ 13 | *.cache.db 14 | out.png 15 | tmp 16 | ui/dist/ 17 | ui/node_modules/ 18 | dist/ 19 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | PHOTOFIELD_DATA_DIR=./data 2 | PHOTOFIELD_API_PREFIX=/ 3 | PHOTOFIELD_CORS_ALLOWED_ORIGINS=http://localhost:3000 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .history 2 | photos/ 3 | profiles/ 4 | data/ 5 | main 6 | photofield 7 | photofield.exe 8 | photofield.exe~ 9 | *.cache.db 10 | *.cache.db-shm 11 | *.cache.db-wal 12 | out.png 13 | tmp 14 | .ipynb_checkpoints 15 | tools/jupyter/data 16 | .env 17 | dist/ 18 | node_modules/ 19 | /blob-report/ 20 | .task/ 21 | .docker-image-id 22 | .task-release-changelog.md 23 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # photofield release configuration 2 | # 3 | # Make sure to check the documentation at https://goreleaser.com 4 | before: 5 | hooks: 6 | - go mod tidy 7 | - go generate -x 8 | - sh -c "cd docs && npm install && npm run docs:build && cd ../ui && npm install && npm run build" 9 | builds: 10 | - env: 11 | - CGO_ENABLED=0 12 | goos: 13 | - linux 14 | - windows 15 | - darwin 16 | ignore: 17 | # Unsupported by modernc sqlite currently 18 | - goos: windows 19 | goarch: "386" 20 | tags: 21 | - embedui 22 | - embeddocs 23 | - embedgeo 24 | dockers: 25 | - dockerfile: Dockerfile-goreleaser 26 | use: buildx 27 | image_templates: 28 | - "ghcr.io/smilyorg/photofield:{{ .Tag }}-amd64" 29 | build_flag_templates: 30 | - "--pull" 31 | - "--platform=linux/amd64" 32 | 33 | - dockerfile: Dockerfile-goreleaser 34 | use: buildx 35 | image_templates: 36 | - "ghcr.io/smilyorg/photofield:{{ .Tag }}-arm64" 37 | build_flag_templates: 38 | - "--pull" 39 | - "--platform=linux/arm64" 40 | goarch: arm64 41 | 42 | docker_manifests: 43 | - name_template: "ghcr.io/smilyorg/photofield:{{ .Tag }}" 44 | image_templates: 45 | - "ghcr.io/smilyorg/photofield:{{ .Tag }}-amd64" 46 | - "ghcr.io/smilyorg/photofield:{{ .Tag }}-arm64" 47 | 48 | - name_template: "ghcr.io/smilyorg/photofield:v{{ .Major }}" 49 | image_templates: 50 | - "ghcr.io/smilyorg/photofield:{{ .Tag }}-amd64" 51 | - "ghcr.io/smilyorg/photofield:{{ .Tag }}-arm64" 52 | 53 | - name_template: "ghcr.io/smilyorg/photofield:v{{ .Major }}.{{ .Minor }}" 54 | image_templates: 55 | - "ghcr.io/smilyorg/photofield:{{ .Tag }}-amd64" 56 | - "ghcr.io/smilyorg/photofield:{{ .Tag }}-arm64" 57 | 58 | - name_template: "ghcr.io/smilyorg/photofield:latest" 59 | image_templates: 60 | - "ghcr.io/smilyorg/photofield:{{ .Tag }}-amd64" 61 | - "ghcr.io/smilyorg/photofield:{{ .Tag }}-arm64" 62 | 63 | archives: 64 | - format: zip 65 | name_template: >- 66 | {{ .ProjectName }}_ 67 | {{ .Version }}_ 68 | {{- title .Os }}_ 69 | {{- if eq .Arch "amd64" }}x86_64 70 | {{- else if eq .Arch "386" }}i386 71 | {{- else }}{{ .Arch }}{{ end }} 72 | 73 | checksum: 74 | name_template: 'checksums.txt' 75 | snapshot: 76 | name_template: "{{ incpatch .Version }}-next" 77 | changelog: 78 | sort: asc 79 | filters: 80 | exclude: 81 | - '^docs:' 82 | - '^test:' 83 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "${workspaceFolder}", 13 | }, 14 | { 15 | "name": "Debug w/ UI", 16 | "type": "go", 17 | "request": "launch", 18 | "mode": "auto", 19 | "program": "${workspaceFolder}", 20 | "buildFlags": "-tags embedui" 21 | }, 22 | { 23 | "name": "Debug bddgen", 24 | "request": "launch", 25 | "program": "${workspaceFolder}/e2e/node_modules/playwright-bdd/dist/cli/index.js", 26 | "autoAttachChildProcesses": true, 27 | "skipFiles": [ 28 | "/**" 29 | ], 30 | "type": "node" 31 | }, 32 | ] 33 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ### 2 | # Client 3 | ### 4 | FROM node:18-alpine as node-builder 5 | WORKDIR /ui 6 | 7 | # install deps 8 | COPY ui/package-lock.json ui/package.json ./ 9 | RUN npm install 10 | 11 | # build 12 | COPY ui . 13 | RUN npm run build 14 | 15 | 16 | 17 | ### 18 | # Server 19 | ### 20 | FROM golang:1-alpine AS go-builder 21 | # RUN apk add --no-cache gcc libffi-dev musl-dev libjpeg-turbo-dev 22 | 23 | WORKDIR /go/src/app 24 | 25 | # get deps 26 | COPY go.mod go.sum ./ 27 | RUN go mod download 28 | 29 | # build 30 | COPY *.go ./ 31 | COPY defaults.yaml ./ 32 | COPY internal ./internal 33 | COPY io ./io 34 | COPY db ./db 35 | COPY fonts ./fonts 36 | COPY data/geo ./data/geo 37 | # RUN go install -tags libjpeg . 38 | COPY --from=node-builder /ui/dist/ ./ui/dist 39 | RUN go install -tags embedui,embedgeo . 40 | 41 | 42 | 43 | ### 44 | # Runtime 45 | ### 46 | FROM alpine:latest 47 | # RUN apk add --no-cache exiftool>12.06-r0 libjpeg-turbo 48 | RUN apk add --no-cache exiftool ffmpeg 49 | 50 | COPY --from=go-builder /go/bin/ /app 51 | 52 | WORKDIR /app 53 | RUN mkdir ./data && touch ./data/configuration.yaml 54 | 55 | EXPOSE 8080 56 | ENV PHOTOFIELD_DATA_DIR=./data 57 | CMD ["./photofield"] 58 | -------------------------------------------------------------------------------- /Dockerfile-goreleaser: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | RUN apk add --no-cache exiftool ffmpeg 3 | 4 | ARG binary=photofield 5 | 6 | WORKDIR /app 7 | COPY $binary ./photofield 8 | 9 | RUN mkdir ./data && touch ./data/configuration.yaml 10 | 11 | EXPOSE 8080 12 | ENV PHOTOFIELD_DATA_DIR=./data 13 | ENTRYPOINT ["./photofield"] 14 | -------------------------------------------------------------------------------- /Dockerfile-multiarch: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM alpine:latest 2 | RUN apk add --no-cache exiftool ffmpeg libjpeg-turbo-utils 3 | 4 | ARG TARGETOS 5 | ARG TARGETARCH 6 | ARG TARGETPLATFORM 7 | ARG BUILDPLATFORM 8 | ARG VERSION 9 | 10 | WORKDIR /app 11 | 12 | COPY photofield_${VERSION}_${TARGETOS}_${TARGETARCH} ./photofield 13 | 14 | RUN mkdir ./data && touch ./data/configuration.yaml 15 | 16 | EXPOSE 8080 17 | ENV PHOTOFIELD_DATA_DIR=./data 18 | ENTRYPOINT ["./photofield"] 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2024 Miha Lunar 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cmd/sha256/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/sha256" 5 | "fmt" 6 | "io" 7 | "log" 8 | "os" 9 | ) 10 | 11 | func main() { 12 | // Check if arguments are provided 13 | if len(os.Args) < 2 { 14 | log.Fatalf("Usage: %s ...", os.Args[0]) 15 | } 16 | 17 | for _, filePath := range os.Args[1:] { 18 | // Check if file exists and is not a dir 19 | fileInfo, err := os.Stat(filePath) 20 | if err != nil { 21 | log.Printf("Failed to stat %s: %v", filePath, err) 22 | continue 23 | } 24 | if fileInfo.IsDir() { 25 | continue 26 | } 27 | 28 | // Compute SHA-256 checksum 29 | checksum, err := computeSHA256(filePath) 30 | if err != nil { 31 | log.Printf("Failed to compute checksum for %s: %v", filePath, err) 32 | continue 33 | } 34 | 35 | // Print filename and checksum to stdout 36 | fmt.Printf("%x %s\n", checksum, filePath) 37 | } 38 | } 39 | 40 | func computeSHA256(filePath string) ([]byte, error) { 41 | file, err := os.Open(filePath) 42 | if err != nil { 43 | return nil, err 44 | } 45 | defer file.Close() 46 | 47 | hash := sha256.New() 48 | if _, err := io.Copy(hash, file); err != nil { 49 | return nil, err 50 | } 51 | 52 | return hash.Sum(nil), nil 53 | } 54 | -------------------------------------------------------------------------------- /cmd/zip/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "archive/zip" 5 | "fmt" 6 | "io" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | ) 11 | 12 | func main() { 13 | if len(os.Args) < 3 { 14 | fmt.Println("Usage: zip ...") 15 | return 16 | } 17 | 18 | output := os.Args[1] 19 | files := os.Args[2:] 20 | 21 | zipFile, err := os.Create(output) 22 | if err != nil { 23 | log.Fatalf("Failed to create zip file: %v\n", err) 24 | } 25 | defer zipFile.Close() 26 | 27 | zipWriter := zip.NewWriter(zipFile) 28 | defer zipWriter.Close() 29 | 30 | for _, file := range files { 31 | if err := addFileToZip(zipWriter, file); err != nil { 32 | log.Fatalf("Failed to add file %s to zip: %v\n", file, err) 33 | } 34 | } 35 | } 36 | 37 | func addFileToZip(zipWriter *zip.Writer, filename string) error { 38 | file, err := os.Open(filename) 39 | if err != nil { 40 | return err 41 | } 42 | defer file.Close() 43 | 44 | info, err := file.Stat() 45 | if err != nil { 46 | return err 47 | } 48 | 49 | header, err := zip.FileInfoHeader(info) 50 | if err != nil { 51 | return err 52 | } 53 | header.Name = filepath.Base(filename) 54 | header.Method = zip.Deflate 55 | 56 | writer, err := zipWriter.CreateHeader(header) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | _, err = io.Copy(writer, file) 62 | return err 63 | } 64 | -------------------------------------------------------------------------------- /db/migrations-thumbs/000001_init.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE thumb256; 2 | -------------------------------------------------------------------------------- /db/migrations-thumbs/000001_init.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE thumb256 ( 2 | id INTEGER PRIMARY KEY, 3 | created_at_unix INTEGER, 4 | data BLOB 5 | ); 6 | -------------------------------------------------------------------------------- /db/migrations/000001_create_infos_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE infos; 2 | -------------------------------------------------------------------------------- /db/migrations/000001_create_infos_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "infos" ( 2 | "path" text, 3 | "width" integer, 4 | "height" integer, 5 | "datetime" datetime, 6 | "color" integer, 7 | PRIMARY KEY ("path") 8 | ); 9 | -------------------------------------------------------------------------------- /db/migrations/000002_add_indexed_at.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE infos RENAME COLUMN "created_at" TO "datetime"; 2 | ALTER TABLE infos DROP COLUMN indexed_at; 3 | ALTER TABLE infos DROP COLUMN active; 4 | -------------------------------------------------------------------------------- /db/migrations/000002_add_indexed_at.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE infos RENAME COLUMN "datetime" TO "created_at"; 2 | ALTER TABLE infos ADD COLUMN indexed_at DATETIME; 3 | ALTER TABLE infos ADD COLUMN active BOOLEAN DEFAULT 1; 4 | -------------------------------------------------------------------------------- /db/migrations/000003_remove_indexed_at.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE infos ADD COLUMN indexed_at DATETIME; 2 | ALTER TABLE infos ADD COLUMN active BOOLEAN DEFAULT 1; 3 | -------------------------------------------------------------------------------- /db/migrations/000003_remove_indexed_at.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE infos DROP COLUMN indexed_at; 2 | ALTER TABLE infos DROP COLUMN active; 3 | -------------------------------------------------------------------------------- /db/migrations/000004_indexes.down.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX list_paths_idx; 2 | DROP INDEX sorted_path_idx; 3 | -------------------------------------------------------------------------------- /db/migrations/000004_indexes.up.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX list_paths_idx 2 | ON infos ( 3 | created_at COLLATE NOCASE, 4 | path COLLATE NOCASE, 5 | width, 6 | height, 7 | color 8 | ); 9 | 10 | CREATE INDEX sorted_path_idx ON infos (path COLLATE NOCASE); 11 | -------------------------------------------------------------------------------- /db/migrations/000005_dirs.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE dirs; 2 | -------------------------------------------------------------------------------- /db/migrations/000005_dirs.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "dirs" ( 2 | "path" text, 3 | "indexed_at" datetime, 4 | PRIMARY KEY ("path") 5 | ); 6 | -------------------------------------------------------------------------------- /db/migrations/000006_add_orientation.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE infos DROP COLUMN orientation; 2 | 3 | DROP INDEX list_paths_idx; 4 | 5 | CREATE INDEX list_paths_idx 6 | ON infos ( 7 | created_at COLLATE NOCASE, 8 | path COLLATE NOCASE, 9 | width, 10 | height, 11 | color 12 | ); 13 | 14 | -------------------------------------------------------------------------------- /db/migrations/000006_add_orientation.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE infos ADD COLUMN orientation INTEGER; 2 | 3 | DROP INDEX list_paths_idx; 4 | 5 | CREATE INDEX list_paths_idx 6 | ON infos ( 7 | created_at COLLATE NOCASE, 8 | path COLLATE NOCASE, 9 | width, 10 | height, 11 | orientation, 12 | color 13 | ); 14 | 15 | -------------------------------------------------------------------------------- /db/migrations/000007_reformat_time_to_iso8601.down.sql: -------------------------------------------------------------------------------- 1 | UPDATE infos 2 | SET created_at = fixed.noniso 3 | FROM ( 4 | SELECT 5 | *, 6 | substr(created_at, 0, split_pos + 1) || 7 | substr(created_at, split_pos + 1, 2) || 8 | substr(created_at, split_pos + 4, 2) as noniso 9 | FROM ( 10 | SELECT *, instr(substr(created_at, 12), " ") + 12 as split_pos 11 | FROM infos 12 | ) 13 | ) AS fixed 14 | WHERE infos.path = fixed.path; 15 | 16 | UPDATE dirs 17 | SET indexed_at = fixed.noniso 18 | FROM ( 19 | SELECT 20 | *, 21 | substr(indexed_at, 0, split_pos + 1) || 22 | substr(indexed_at, split_pos + 1, 2) || 23 | substr(indexed_at, split_pos + 4, 2) as noniso 24 | FROM ( 25 | SELECT *, instr(substr(indexed_at, 12), " ") + 12 as split_pos 26 | FROM dirs 27 | ) 28 | ) AS fixed 29 | WHERE dirs.path = fixed.path; 30 | -------------------------------------------------------------------------------- /db/migrations/000007_reformat_time_to_iso8601.up.sql: -------------------------------------------------------------------------------- 1 | UPDATE infos 2 | SET created_at = fixed.iso8601 3 | FROM ( 4 | SELECT 5 | *, 6 | substr(created_at, 0, split_pos + 1) || 7 | substr(created_at, split_pos + 1, 2) || 8 | ':' || 9 | substr(created_at, split_pos + 3, 2) as iso8601 10 | FROM ( 11 | SELECT *, instr(substr(created_at, 12), " ") + 12 as split_pos 12 | FROM infos 13 | ) 14 | ) AS fixed 15 | WHERE infos.path = fixed.path; 16 | 17 | UPDATE dirs 18 | SET indexed_at = fixed.iso8601 19 | FROM ( 20 | SELECT 21 | *, 22 | substr(indexed_at, 0, split_pos + 1) || 23 | substr(indexed_at, split_pos + 1, 2) || 24 | ':' || 25 | substr(indexed_at, split_pos + 3, 2) as iso8601 26 | FROM ( 27 | SELECT *, instr(substr(indexed_at, 12), " ") + 12 as split_pos 28 | FROM dirs 29 | ) 30 | ) AS fixed 31 | WHERE dirs.path = fixed.path; 32 | -------------------------------------------------------------------------------- /db/migrations/000008_drop_redundant_index.down.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX sorted_path_idx ON infos (path COLLATE NOCASE); 2 | -------------------------------------------------------------------------------- /db/migrations/000008_drop_redundant_index.up.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX sorted_path_idx; 2 | -------------------------------------------------------------------------------- /db/migrations/000009_prefix_and_unix.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE infos RENAME TO prefixed_infos; 2 | 3 | CREATE TABLE "infos" ( 4 | "path" text, 5 | "width" integer, 6 | "height" integer, 7 | "created_at" datetime, 8 | "color" integer, orientation INTEGER, 9 | PRIMARY KEY ("path") 10 | ); 11 | 12 | CREATE INDEX list_paths_idx 13 | ON infos ( 14 | created_at COLLATE NOCASE, 15 | path COLLATE NOCASE, 16 | width, 17 | height, 18 | orientation, 19 | color 20 | ); 21 | 22 | INSERT INTO infos 23 | SELECT 24 | str || filename as path, 25 | width, 26 | height, 27 | created_at, 28 | color, 29 | orientation 30 | FROM prefixed_infos 31 | JOIN prefix ON prefix.id == prefixed_infos.path_prefix_id; 32 | 33 | DROP TABLE prefix; 34 | DROP TABLE prefixed_infos; 35 | -------------------------------------------------------------------------------- /db/migrations/000009_prefix_and_unix.up.sql: -------------------------------------------------------------------------------- 1 | -- create prefix table 2 | CREATE TABLE prefix ( 3 | id integer primary key, 4 | str text unique 5 | ); 6 | 7 | -- fill prefix table 8 | INSERT INTO prefix(str) 9 | SELECT 10 | rtrim(path, replace(replace(path, '/', ''), '\', '')) as str 11 | FROM infos 12 | WHERE true 13 | GROUP BY str; 14 | 15 | -- recreate table 16 | ALTER TABLE infos RENAME TO old_infos; 17 | CREATE TABLE infos ( 18 | path_prefix_id INTEGER REFERENCES prefix(id), 19 | filename TEXT, 20 | width INTEGER, 21 | height INTEGER, 22 | created_at_unix INTEGER, 23 | created_at_tz_offset INTEGER, 24 | color INTEGER, 25 | orientation INTEGER, 26 | created_at TEXT GENERATED ALWAYS AS ( 27 | datetime( 28 | created_at_unix + 29 | created_at_tz_offset*60, 30 | "unixepoch" 31 | ) || " " || 32 | -- timezone offset 33 | printf("%s%02d:%02d", 34 | (CASE WHEN created_at_tz_offset < 0 THEN "-" ELSE "+" END), 35 | abs(created_at_tz_offset)/60, 36 | abs(created_at_tz_offset) % 60 37 | ) 38 | ) VIRTUAL, 39 | CONSTRAINT infos_pk PRIMARY KEY ("path_prefix_id", "filename") 40 | ); 41 | 42 | -- reinsert with prefix columns 43 | INSERT INTO infos 44 | SELECT 45 | prefix.id as path_prefix_id, 46 | replace(path, rtrim(path, replace(replace(path, '/', ''), '\', '')), '') as filename, 47 | width, 48 | height, 49 | strftime("%s", created_at) as created_at_unix, 50 | (CASE WHEN substr(replace(created_at, rtrim(created_at, replace(created_at, " ", "")), ""), 1, 1) == "+" THEN 1 ELSE -1 END) * -- offset sign 51 | ( 52 | CAST(substr(replace(created_at, rtrim(created_at, replace(created_at, " ", "")), ""), 2, 2) as INTEGER) * 60 + -- hour offset in minutes 53 | CAST(substr(replace(created_at, rtrim(created_at, replace(created_at, " ", "")), ""), 5, 2) as INTEGER) -- minute offset 54 | ) as created_at_tz_offset, 55 | color, 56 | orientation 57 | FROM old_infos 58 | JOIN prefix ON prefix.str == rtrim(path, replace(replace(path, '/', ''), '\', '')); 59 | 60 | DROP TABLE old_infos; 61 | 62 | CREATE INDEX list_idx 63 | ON infos ( 64 | path_prefix_id, 65 | created_at_unix ASC, 66 | created_at_tz_offset, 67 | width, 68 | height, 69 | orientation, 70 | color 71 | ); 72 | 73 | -------------------------------------------------------------------------------- /db/migrations/000010_explicit_rowid.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE infos RENAME TO old_infos; 2 | 3 | CREATE TABLE infos ( 4 | path_prefix_id INTEGER REFERENCES prefix(id), 5 | filename TEXT, 6 | width INTEGER, 7 | height INTEGER, 8 | created_at_unix INTEGER, 9 | created_at_tz_offset INTEGER, 10 | color INTEGER, 11 | orientation INTEGER, 12 | created_at TEXT GENERATED ALWAYS AS ( 13 | datetime( 14 | created_at_unix + 15 | created_at_tz_offset*60, 16 | "unixepoch" 17 | ) || " " || 18 | -- timezone offset 19 | printf("%s%02d:%02d", 20 | (CASE WHEN created_at_tz_offset < 0 THEN "-" ELSE "+" END), 21 | abs(created_at_tz_offset)/60, 22 | abs(created_at_tz_offset) % 60 23 | ) 24 | ) VIRTUAL, 25 | CONSTRAINT infos_pk UNIQUE ("path_prefix_id", "filename") 26 | ); 27 | 28 | INSERT INTO infos( 29 | path_prefix_id, 30 | filename, 31 | width, 32 | height, 33 | created_at_unix, 34 | created_at_tz_offset, 35 | color, 36 | orientation 37 | ) 38 | SELECT 39 | path_prefix_id, 40 | filename, 41 | width, 42 | height, 43 | created_at_unix, 44 | created_at_tz_offset, 45 | color, 46 | orientation 47 | FROM old_infos; 48 | 49 | DROP TABLE old_infos; 50 | 51 | CREATE INDEX list_idx 52 | ON infos ( 53 | path_prefix_id, 54 | created_at_unix ASC, 55 | created_at_tz_offset, 56 | width, 57 | height, 58 | orientation, 59 | color 60 | ); 61 | -------------------------------------------------------------------------------- /db/migrations/000010_explicit_rowid.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE infos RENAME TO old_infos; 2 | CREATE TABLE infos ( 3 | id INTEGER PRIMARY KEY, 4 | path_prefix_id INTEGER REFERENCES prefix(id), 5 | filename TEXT, 6 | width INTEGER, 7 | height INTEGER, 8 | created_at_unix INTEGER, 9 | created_at_tz_offset INTEGER, 10 | color INTEGER, 11 | orientation INTEGER, 12 | created_at TEXT GENERATED ALWAYS AS ( 13 | datetime( 14 | created_at_unix + 15 | created_at_tz_offset*60, 16 | "unixepoch" 17 | ) || " " || 18 | -- timezone offset 19 | printf("%s%02d:%02d", 20 | (CASE WHEN created_at_tz_offset < 0 THEN "-" ELSE "+" END), 21 | abs(created_at_tz_offset)/60, 22 | abs(created_at_tz_offset) % 60 23 | ) 24 | ) VIRTUAL, 25 | CONSTRAINT infos_pk UNIQUE ("path_prefix_id", "filename") 26 | ); 27 | INSERT INTO infos( 28 | id, 29 | path_prefix_id, 30 | filename, 31 | width, 32 | height, 33 | created_at_unix, 34 | created_at_tz_offset, 35 | color, 36 | orientation 37 | ) 38 | SELECT 39 | rowid, 40 | path_prefix_id, 41 | filename, 42 | width, 43 | height, 44 | created_at_unix, 45 | created_at_tz_offset, 46 | color, 47 | orientation 48 | FROM old_infos; 49 | DROP TABLE old_infos; 50 | CREATE INDEX list_idx 51 | ON infos ( 52 | path_prefix_id, 53 | created_at_unix ASC, 54 | created_at_tz_offset, 55 | width, 56 | height, 57 | orientation, 58 | color 59 | ); 60 | -------------------------------------------------------------------------------- /db/migrations/000011_clip_emb.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE clip_emb; 2 | -------------------------------------------------------------------------------- /db/migrations/000011_clip_emb.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE clip_emb ( 2 | file_id INTEGER UNIQUE REFERENCES infos(id), 3 | inv_norm INTEGER NOT NULL, 4 | embedding BLOB NOT NULL 5 | ); -------------------------------------------------------------------------------- /db/migrations/000012_tags.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE tag; 2 | DROP TABLE infos_tag; -------------------------------------------------------------------------------- /db/migrations/000012_tags.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE tag ( 2 | id INTEGER PRIMARY KEY, 3 | revision INTEGER NOT NULL, 4 | name TEXT UNIQUE 5 | ); 6 | 7 | CREATE TABLE infos_tag ( 8 | tag_id INTEGER REFERENCES tag(id) NOT NULL, 9 | file_id INTEGER REFERENCES infos(id) NOT NULL, 10 | len INTEGER NOT NULL, 11 | CONSTRAINT infos_tag_pk UNIQUE (tag_id, file_id, len) 12 | ); -------------------------------------------------------------------------------- /db/migrations/000013_add_gps_coords.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE infos DROP COLUMN "longitude" text; 2 | ALTER TABLE infos DROP COLUMN "latitude" text; -------------------------------------------------------------------------------- /db/migrations/000013_add_gps_coords.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE infos ADD COLUMN "latitude" REAL; 2 | ALTER TABLE infos ADD COLUMN "longitude" REAL; -------------------------------------------------------------------------------- /db/migrations/000014_tag_dates.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE tag DROP COLUMN updated_at_ms; 2 | ALTER TABLE tag ADD COLUMN revision INTEGER; -------------------------------------------------------------------------------- /db/migrations/000014_tag_dates.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE tag DROP COLUMN revision; 2 | ALTER TABLE tag ADD updated_at_ms INTEGER; -------------------------------------------------------------------------------- /db/migrations/000015_tag_versions.down.sql: -------------------------------------------------------------------------------- 1 | PRAGMA foreign_keys=OFF; 2 | 3 | -- Create a new table "new_tag" with the original format of table tag 4 | CREATE TABLE new_tag ( 5 | id INTEGER PRIMARY KEY, 6 | name TEXT UNIQUE, 7 | updated_at_ms INTEGER 8 | ); 9 | 10 | -- Transfer content from tag into new_tag 11 | INSERT INTO new_tag SELECT * FROM tag; 12 | 13 | -- Drop the new table tag 14 | DROP TABLE tag; 15 | 16 | -- Change the name of new_tag to tag 17 | ALTER TABLE new_tag RENAME TO tag; 18 | 19 | -- Enable foreign key constraints 20 | PRAGMA foreign_key_check; 21 | 22 | -- Reenable foreign key constraints 23 | PRAGMA foreign_keys=ON; -------------------------------------------------------------------------------- /db/migrations/000015_tag_versions.up.sql: -------------------------------------------------------------------------------- 1 | PRAGMA foreign_keys=OFF; 2 | 3 | -- Create a new table "new_tag" without the unique constraint 4 | CREATE TABLE new_tag ( 5 | id INTEGER PRIMARY KEY, 6 | name TEXT NOT NULL, 7 | updated_at_ms INTEGER, 8 | active BOOLEAN NOT NULL DEFAULT 1 9 | ); 10 | 11 | -- Transfer content from tag into new_tag 12 | INSERT INTO new_tag SELECT id, name, updated_at_ms, 1 FROM tag; 13 | 14 | -- Drop the old table tag 15 | DROP TABLE tag; 16 | 17 | -- Change the name of new_tag to tag 18 | ALTER TABLE new_tag RENAME TO tag; 19 | 20 | PRAGMA foreign_key_check; 21 | 22 | PRAGMA foreign_keys=ON; -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | 4 | photofield: 5 | build: ./ 6 | image: photofield 7 | ports: 8 | - 8080:8080 9 | labels: 10 | - "traefik.http.services.photofield.loadbalancer.server.port=8080" 11 | volumes: 12 | - ./data/configuration.docker.yaml:/app/data/configuration.yaml:ro 13 | - ./photos:/photos:ro 14 | restart: "no" 15 | 16 | prometheus: 17 | build: ./docker/prometheus/ 18 | ports: 19 | - 9090:9090 20 | volumes: 21 | - ./docker/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml 22 | 23 | pyroscope: 24 | image: "pyroscope/pyroscope:latest" 25 | ports: 26 | - "4040:4040" 27 | command: 28 | - "server" 29 | 30 | grafana: 31 | build: ./docker/grafana/ 32 | environment: 33 | - GF_DASHBOARDS_MIN_REFRESH_INTERVAL=1s 34 | - GF_PATHS_PROVISIONING=/provisioning 35 | ports: 36 | - 9091:3000 37 | volumes: 38 | - ./docker/grafana/provisioning:/provisioning 39 | - ./docker/grafana/dashboards:/var/lib/grafana/dashboards 40 | - ./data/grafana:/var/lib/grafana 41 | 42 | # Local Docker registry for "task docker:multiarch:push:local" 43 | registry: 44 | image: registry:2 45 | ports: 46 | - 5000:5000 47 | volumes: 48 | - ./dist/registry:/var/lib/registry 49 | -------------------------------------------------------------------------------- /docker/grafana/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM grafana/grafana:9.4.7 2 | COPY provisioning /provisioning 3 | COPY dashboards /var/lib/grafana/dashboards 4 | -------------------------------------------------------------------------------- /docker/grafana/provisioning/dashboards/default.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | providers: 4 | - name: Default 5 | type: file 6 | options: 7 | path: /var/lib/grafana/dashboards 8 | foldersFromFilesStructure: true 9 | -------------------------------------------------------------------------------- /docker/grafana/provisioning/datasources/default.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | datasources: 3 | - orgId: 1 4 | version: 5 5 | name: Prometheus 6 | type: prometheus 7 | access: proxy 8 | url: http://prometheus:9090 9 | isDefault: true 10 | jsonData: 11 | httpMethod: POST 12 | queryTimeout: "" 13 | timeInterval: 1s 14 | -------------------------------------------------------------------------------- /docker/prometheus/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM prom/prometheus:v2.43.0 2 | COPY prometheus.yml /etc/prometheus/ 3 | -------------------------------------------------------------------------------- /docker/prometheus/prometheus.yml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 1s 3 | evaluation_interval: 1s 4 | 5 | scrape_configs: 6 | - job_name: 'photofield' 7 | static_configs: 8 | - targets: ['host.docker.internal:8080'] 9 | -------------------------------------------------------------------------------- /docs/.vitepress/.gitignore: -------------------------------------------------------------------------------- 1 | cache -------------------------------------------------------------------------------- /docs/.vitepress/config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress' 2 | 3 | // https://vitepress.dev/reference/site-config 4 | export default defineConfig({ 5 | title: "Photofield", 6 | description: "Self-Hosted Personal Photo Gallery", 7 | ignoreDeadLinks: [ 8 | /^https?:\/\/localhost/, 9 | ], 10 | base: '/docs/', 11 | cleanUrls: true, 12 | themeConfig: { 13 | // https://vitepress.dev/reference/default-theme-config 14 | 15 | logo: "/favicon-32x32.png", 16 | 17 | search: { 18 | provider: 'local', 19 | }, 20 | 21 | editLink: { 22 | pattern: 'https://github.com/smilyorg/photofield/edit/main/docs/:path' 23 | }, 24 | 25 | nav: [ 26 | { text: 'Home', link: '/' }, 27 | { text: 'Quick Start', link: '/quick-start' }, 28 | ], 29 | 30 | sidebar: [ 31 | { 32 | text: 'Install', 33 | items: [ 34 | { text: 'Quick Start', link: '/quick-start' }, 35 | { text: 'Dependencies', link: '/dependencies' }, 36 | ] 37 | }, 38 | { 39 | text: 'Features', 40 | link: '/features', 41 | items: [ 42 | { text: 'Layouts', link: '/features/layouts' }, 43 | { text: 'Search', link: '/features/search' }, 44 | { text: 'Tags', link: '/features/tags' }, 45 | { text: 'Reverse Geolocation', link: '/features/geolocation' }, 46 | ] 47 | }, 48 | { 49 | text: 'Usage', 50 | link: '/usage', 51 | items: [ 52 | { text: 'User Interface', link: '/user-interface' }, 53 | { text: 'Configuration', link: '/configuration' }, 54 | { text: 'Maintenance', link: '/maintenance' }, 55 | { text: 'Performance', link: '/performance' }, 56 | ] 57 | }, 58 | { 59 | text: 'Contributing', 60 | link: '/contributing', 61 | items: [ 62 | { text: 'Development', link: '/development' }, 63 | ] 64 | }, 65 | { 66 | text: 'About', 67 | items: [ 68 | { text: 'License', link: '/license' }, 69 | { text: 'Credits', link: '/credits' }, 70 | ] 71 | } 72 | ], 73 | 74 | socialLinks: [ 75 | { icon: 'github', link: 'https://github.com/SmilyOrg/photofield' } 76 | ] 77 | 78 | }, 79 | }) 80 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/custom.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --vp-c-brand-1: #dd8888; 3 | --vp-c-brand-2: #dd8888; 4 | --vp-c-brand-3: #be6868; 5 | --vp-code-color: #c46363; 6 | --vp-code-bg: hsl(0, 0%, 97%); 7 | --vp-custom-block-tip-code-bg: hsl(237, 100%, 99%); 8 | } 9 | 10 | :root.dark { 11 | --vp-code-color: var(--vp-c-brand-1); 12 | --vp-code-bg: var(--vp-c-default-soft); 13 | --vp-custom-block-tip-code-bg: var(--vp-custom-block-tip-code-bg); 14 | } 15 | 16 | .VPFeature article > img { 17 | margin-left: -24px; 18 | margin-top: -24px; 19 | margin-bottom: 24px; 20 | width: calc(100% + 48px); 21 | max-width: calc(100% + 48px); 22 | border-top-left-radius: 12px; 23 | border-top-right-radius: 12px; 24 | } 25 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.js: -------------------------------------------------------------------------------- 1 | // .vitepress/theme/index.js 2 | import DefaultTheme from 'vitepress/theme' 3 | import './custom.css' 4 | 5 | export default DefaultTheme -------------------------------------------------------------------------------- /docs/assets/album.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/assets/album.jpg -------------------------------------------------------------------------------- /docs/assets/app-bar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/assets/app-bar.png -------------------------------------------------------------------------------- /docs/assets/background.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/assets/background.jpeg -------------------------------------------------------------------------------- /docs/assets/context-menu.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/assets/context-menu.jpeg -------------------------------------------------------------------------------- /docs/assets/context-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/assets/context-menu.png -------------------------------------------------------------------------------- /docs/assets/flex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/assets/flex.png -------------------------------------------------------------------------------- /docs/assets/highlights.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/assets/highlights.png -------------------------------------------------------------------------------- /docs/assets/layouts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/assets/layouts.png -------------------------------------------------------------------------------- /docs/assets/logo-wide.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/assets/logo-wide.jpg -------------------------------------------------------------------------------- /docs/assets/logo-zoom.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/assets/logo-zoom.gif -------------------------------------------------------------------------------- /docs/assets/map.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/assets/map.jpg -------------------------------------------------------------------------------- /docs/assets/progressive-load.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/assets/progressive-load.gif -------------------------------------------------------------------------------- /docs/assets/seamless-zoom.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/assets/seamless-zoom.gif -------------------------------------------------------------------------------- /docs/assets/semantic-search.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/assets/semantic-search.jpg -------------------------------------------------------------------------------- /docs/assets/timeline.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/assets/timeline.jpg -------------------------------------------------------------------------------- /docs/assets/wall.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/assets/wall.jpg -------------------------------------------------------------------------------- /docs/components/Background.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 19 | 20 | 79 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | You can configure the app via `configuration.yaml`. 4 | 5 | The location of the file depends on your installation method, see 6 | [Quick Start](/quick-start). 7 | 8 | ## Minimal Example 9 | 10 | The following is a minimal `configuration.yaml` example, see [Defaults](#defaults) for all options. 11 | 12 | ::: code-group 13 | ```yaml [configuration.yaml] 14 | collections: 15 | # Normal Album-type collection 16 | - name: Vacation Photos 17 | dirs: 18 | - /photo/vacation-photos 19 | 20 | # Timeline collection (similar to Google Photos) 21 | - name: My Timeline 22 | layout: timeline 23 | dirs: 24 | - /photo/myphotos 25 | - /exampleuser 26 | 27 | # Create collections from sub-directories based on their name 28 | - expand_subdirs: true 29 | expand_sort: desc 30 | dirs: 31 | - /photo 32 | ``` 33 | 34 | ::: 35 | 36 | ## Defaults 37 | 38 | <<< @/../defaults.yaml 39 | 40 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | The source code is available at [github.com/SmilyOrg/photofield](https://github.com/SmilyOrg/photofield). 4 | 5 | Pull requests are welcome. For major changes, please [open a discussion] first to 6 | discuss what you would like to change. 7 | 8 | See [Development](/development) for details on how to set up your development environment. 9 | 10 | [open a discussion]: https://github.com/SmilyOrg/photofield/discussions -------------------------------------------------------------------------------- /docs/credits.md: -------------------------------------------------------------------------------- 1 | # Credits 2 | 3 | ## Built With 4 | 5 | * [Go] - API and server-side tile rendering 6 | * [Canvas (tdewolff)](https://github.com/tdewolff/canvas) - vector rendering in 7 | Go 8 | * [SQLite 3 (zombiezen)](https://github.com/zombiezen/go-sqlite) - 9 | fast single-file database 10 | * [Vue 3] - frontend framework 11 | * [BalmUI] - Material UI components 12 | * [OpenLayers] - in-browser tiled image rendering 13 | * [geoBoundaries](https://www.geoboundaries.org/) for geographic boundary data used for reverse geolocation 14 | * + more Go libraries 15 | * + more npm libraries 16 | 17 | ## Special Thanks 18 | 19 | * [Open Images Dataset](https://opensource.google/projects/open-images-dataset) for the sample image dataset 20 | * [OpenSeadragon] tiled image rendering library used previously 21 | * [sams96/rgeo](https://github.com/sams96/rgeo) for previous reverse geolocation implementation and inspiration 22 | * [Best-README-Template](https://github.com/othneildrew/Best-README-Template) and [readme.so](https://readme.so/) for readme ideas 23 | 24 | ## Third Party Libraries 25 | 26 | Thanks to all the authors of the third party libraries used in this project. 27 | 28 | ### API 29 | <<< @/../go.mod 30 | 31 | ### UI 32 | 33 | <<< @/../ui/package.json 34 | 35 | 36 | [open an issue]: https://github.com/SmilyOrg/photofield/issues 37 | [Getting Started]: #getting-started 38 | [`defaults.yaml`]: defaults.yaml 39 | 40 | [open-images-dataset]: https://opensource.google/projects/open-images-dataset 41 | 42 | [Scoop]: https://scoop.sh/ 43 | [just]: https://github.com/casey/just 44 | [watchexec]: https://github.com/watchexec/watchexec 45 | 46 | [Go]: https://golang.org/ 47 | [GoReleaser]: https://github.com/goreleaser/goreleaser 48 | [godirwalk]: https://github.com/karrick/godirwalk 49 | [prominent color]: https://github.com/EdlinOrg/prominentcolor 50 | 51 | [OpenLayers]: https://openlayers.org/ 52 | [OpenSeadragon]: https://openseadragon.github.io/ 53 | [Node.js]: https://nodejs.org/ 54 | [Vue 3]: https://v3.vuejs.org/ 55 | [BalmUI]: https://next-material.balmjs.com/ 56 | [photofield-ai]: https://github.com/smilyorg/photofield-ai 57 | [tinygpkg]: https://github.com/smilyorg/tinygpkg 58 | -------------------------------------------------------------------------------- /docs/dependencies.md: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | 3 | These tools are not strictly required, but if they are installed in your system, Photofield will use them to improve performance, metadata extraction, thumbnail generation, and video previews. 4 | 5 | - [ExifTool]: Extracts metadata from many more formats than the embedded [goexif]. 6 | - [FFmpeg]: Generates video thumbnails and previews and adds support for more image formats (even basic RAW). 7 | - [djpeg (libjpeg-turbo)]: Accelerates JPEG decoding of big images in cases where there are no other appropriate thumbnails available. 8 | 9 | ## Quick Install 10 | 11 | ### Docker 12 | 13 | All dependencies are included in the [Docker image](/quick-start#docker) by default. 14 | 15 | ### Windows (scoop) 16 | ```sh 17 | scoop install exiftool ffmpeg libjpeg-turbo 18 | ``` 19 | 20 | ### macOS (brew) 21 | ```sh 22 | brew install exiftool ffmpeg libjpeg-turbo 23 | ``` 24 | 25 | ### Ubuntu/Debian 26 | ```sh 27 | sudo apt install exiftool ffmpeg libjpeg-turbo-progs 28 | ``` 29 | 30 | ### CentOS/RHEL/Fedora 31 | ```sh 32 | sudo dnf install exiftool ffmpeg libjpeg-turbo-utils 33 | ``` 34 | 35 | [djpeg (libjpeg-turbo)]: https://libjpeg-turbo.org/ 36 | [ExifTool]: https://exiftool.org/ 37 | [FFmpeg]: https://ffmpeg.org/ 38 | [goexif]: https://github.com/rwcarlsen/goexif 39 | -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | ## Prerequisites 4 | 5 | ### Core 6 | 7 | * [Go] - for the API server 8 | * [Node.js] - for the frontend 9 | * [Task] - for running common commands (replaces `just`) 10 | 11 | ### Recommended 12 | 13 | * **[watchexec]** - auto-reloads the API server during development for faster iteration (required for `task watch`) 14 | * **[ExifTool]** - better metadata extraction, [goexif] is used otherwise 15 | * **[FFmpeg]** - for video thumbnail creation and extended image format support 16 | * **[djpeg (libjpeg-turbo)]** - optimized JPEG decoding for better performance 17 | 18 | ## Installation 19 | 20 | ### Core 21 | 22 | 1. **Install Go**: https://go.dev/doc/install 23 | 2. **Install Node.js**: https://nodejs.org/en/download/ 24 | 3. **Install Task**: `go install github.com/go-task/task/v3/cmd/task@latest` or https://taskfile.dev/installation/ 25 | 26 | ### Recommended 27 | 28 | - **Windows (scoop)**: `scoop install watchexec exiftool ffmpeg libjpeg-turbo` 29 | - **macOS (brew)**: `brew install watchexec exiftool ffmpeg libjpeg-turbo` 30 | - **Ubuntu/Debian**: `sudo apt install watchexec-cli exiftool ffmpeg libjpeg-turbo-progs` 31 | - **CentOS/RHEL/Fedora**: `sudo dnf install exiftool ffmpeg libjpeg-turbo-utils` and [watchexec install](https://github.com/watchexec/watchexec#install) 32 | 33 | ### Project Setup 34 | 35 | 1. Clone the repository 36 | ```sh 37 | git clone https://github.com/smilyorg/photofield.git 38 | cd photofield 39 | ``` 40 | 41 | 2. Install dependencies (geo data, ui, docs) 42 | ```sh 43 | task deps 44 | ``` 45 | 46 | ## Running 47 | 48 | Both the API server and UI server run in development mode with hot reloading. The API server runs on port `8080` and the UI server on port `3000`. 49 | 50 | ### Using Task (Recommended) 51 | 52 | 1. Start the API server with auto-reload 53 | ```sh 54 | task watch 55 | ``` 56 | 57 | 2. In a separate terminal, start the UI server 58 | ```sh 59 | task ui 60 | ``` 61 | 62 | 3. Open http://localhost:3000 63 | 64 | ### Manual Commands 65 | 66 | 1. Start the API server 67 | ```sh 68 | go run . 69 | ``` 70 | 71 | 2. In a separate terminal, start the UI server 72 | ```sh 73 | cd ui 74 | npm run dev 75 | ``` 76 | 77 | 3. Open http://localhost:3000 78 | 79 | ### Migration from `just` 80 | 81 | If you were previously using `just`, replace: 82 | - `just watch` → `task watch` 83 | - `just build` → `task build` 84 | - `just ui` → `task ui` 85 | 86 | Run `task` to see all available commands. 87 | 88 | [Go]: https://golang.org/ 89 | [Node.js]: https://nodejs.org/ 90 | [Task]: https://taskfile.dev/ 91 | [watchexec]: https://github.com/watchexec/watchexec 92 | [ExifTool]: https://exiftool.org/ 93 | [FFmpeg]: https://ffmpeg.org/ 94 | [djpeg (libjpeg-turbo)]: https://libjpeg-turbo.org/ 95 | [goexif]: https://github.com/rwcarlsen/goexif -------------------------------------------------------------------------------- /docs/features/geolocation.md: -------------------------------------------------------------------------------- 1 | # Reverse Geolocation 2 | 3 | Local, embedded reverse geolocation using the custom-built [tinygpkg package]. 4 | 5 | * Supports ~50 thousand places 6 | * Powered by https://www.geoboundaries.org/ 7 | * Adds only 16MB to build (uncompressed) 8 | * Only supports photos with GPS coordinates in the EXIF data 9 | * Supported in the [Timeline] and [Flex] views 10 | 11 | [Timeline]: layouts.md#timeline 12 | [Flex]: layouts.md#flex 13 | 14 | [tinygpkg package]: https://github.com/SmilyOrg/tinygpkg -------------------------------------------------------------------------------- /docs/features/layouts.md: -------------------------------------------------------------------------------- 1 | # Layouts 2 | 3 | Photofield supports various layouts to display collections of photos. Each 4 | layout offers a unique way to organize and view your photos. 5 | 6 | You can set the default layout for each collection in the 7 | [configuration](../configuration), or change the currently displayed layout 8 | through the cog icon in the top right corner. 9 | 10 | ## Album 11 | 12 | The **Album** layout groups photos chronologically by event. This layout is 13 | ideal for organizing photos from different events or occasions. 14 | 15 | ![Album screenshot](../assets/album.jpg) 16 | 17 | ## Timeline 18 | 19 | The **Timeline** layout displays photos in a reverse-chronological order, 20 | similar to Google Photos. This layout is useful for viewing recent photos first. 21 | 22 | ![Timeline layout example](../assets/timeline.jpg) 23 | 24 | ## Wall 25 | 26 | The **Wall** layout creates a square collage of all the photos. This layout is 27 | great for quickly browsing through a large number of photos. 28 | 29 | ![Wall layout example](../assets/wall.jpg) 30 | 31 | ## Map 32 | 33 | The **Map** layout places all the photos on a map. This layout is perfect for 34 | finding photos taken at specific locations. 35 | 36 | ![Map layout example](../assets/map.jpg) 37 | 38 | ## Flex 39 | 40 | The **Flex** layout uses a variant of Knuth & Plass algorithm to create a 41 | smarter layout, especially for photos with odd aspect ratios. 42 | 43 | ![Flex layout example](../assets/flex.png) 44 | 45 | ## Highlights 46 | 47 | The **Highlights** layout varies the row height based on the "sameness" of the 48 | photos. This layout is designed to make travel photo collections more skimmable 49 | by shrinking similar and repeating photos. This layout requires AI to be enabled. 50 | 51 | ![Highlights layout example](../assets/highlights.png) 52 | -------------------------------------------------------------------------------- /docs/features/search.md: -------------------------------------------------------------------------------- 1 | # Search 2 | 3 | Photofield offers powerful search capabilities to help you find photos quickly 4 | and efficiently. The search feature supports various types of queries, including 5 | tag-based searches, semantic searches, and more. 6 | 7 | ::: tip 8 | Features marked with require capabilities 9 | provided by [photofield-ai]. You can configure it in the `ai` section of the 10 | [configuration]. 11 | ::: 12 | 13 | ## Semantic Search 14 | 15 | Semantic search allows you to search for photo contents using descriptive words 16 | like "beach sunset", "a couple kissing", or "cat eyes". 17 | 18 | ![Semantic search example](../assets/semantic-search.jpg) 19 | 20 | By default, the results are sorted by the semantic relevance to the query. 21 | 22 | To filter the results instead of sorting them, you can use the `t` parameter in 23 | the query. For example, `beach sunset t:0.25` will retain the original order of 24 | the photos, but only keep photos very similar to beach sunsets. The value is 25 | arbitrary, higher values are more strict and lower values are less strict. A 26 | good range is usually between 0.1 and 0.3. 27 | 28 | [photofield-ai]: https://github.com/smilyorg/photofield-ai 29 | [configuration]: ../configuration 30 | 31 | | Query | Description | 32 | |-------|-------------| 33 | | `beach sunset` | Sort photos by how much they look like a beach sunset. | 34 | | `beach sunset t:0.25` | Filter photos to beach sunsets only. | 35 | | `beach sunset t:0.15` | Same as above, but less strict. | 36 | | `a couple kissing` | Find photos of couples kissing. | 37 | | `rain` | Find the rainiest-looking photos. | 38 | | `lake t:0.23` | Filter to photos containing a lake. | 39 | | `upside down` | Filter to photos containing a lake. | 40 | | `weird angle t:0.25` | Photos taken from strange angles only. | 41 | 42 | ## Tag Search 43 | 44 | ::: tip 45 | Enable tags in the [configuration] to be able to add and search for tags. 46 | ::: 47 | 48 | You can filter photos in the collection by searching for specific tags. For 49 | example, you can search for `tag:fav` to only show favorited photos, or 50 | `tag:hello tag:world` to only show photos with both `hello` and `world` tags. 51 | 52 | | Query | Description | 53 | |-------|-------------| 54 | | `tag:fav` | Show all favorited photos. | 55 | | `tag:vacation tag:beach` | Show photos tagged with both `vacation` and `beach`. | 56 | 57 | See the [tags documentation](tags.md) for more on tags. 58 | 59 | ## Date Range 60 | 61 | You can search for photos taken within a specific date range using the `created` 62 | parameter. The date range should be specified in the format 63 | `YYYY-MM-DD..YYYY-MM-DD`. 64 | 65 | | Query | Description | 66 | |-------|-------------| 67 | | `created:2023-01-01..2023-12-31` | Find photos taken in the year 2023. | 68 | 69 | ## Deduplication 70 | 71 | You can use the `dedup` parameter to filter out duplicate successive photos. The 72 | value should be a threshold between 0 and 1 representing the similarity between 73 | photos. For example, `dedup:0.9` will filter out photos that are 90% similar to 74 | each other. 75 | 76 | | Query | Description | 77 | |-------|-------------| 78 | | `dedup:0.9` | Filter out photos that are 90% similar to each other. | 79 | | `dedup:0.5` | Filter out photos that are even kind-of similar. | 80 | | `dedup:0.3` | Only show very different photos. | 81 | -------------------------------------------------------------------------------- /docs/features/tags.md: -------------------------------------------------------------------------------- 1 | # Tags 2 | 3 | You can tag photos with arbitrary tags. There is only basic support for tags 4 | right now, but they form a foundation for many other features. 5 | 6 | ::: warning 7 | Tags are currently in an alpha state and can be volatile. They are 8 | not yet stored in the photos themselves, only in the "cache" database. 9 | ::: 10 | 11 | Tags needs to be enabled in the `tags` section of the [configuration] the server 12 | needs to be restarted. 13 | 14 | ```yaml 15 | tags: 16 | enable: true 17 | ``` 18 | 19 | [configuration]: ../configuration 20 | 21 | ## Tagging Photos 22 | 23 | If tags are enabled, the following features are shown. 24 | 25 | 1. The photo view # (hash) button allows for editing tags. 26 | 2. The photo view 🤍 (heart) button toggles the `fav` tag to serve as simple 27 | "liking" functionality. 28 | 3. On the collection view, if you [select](#selection) photos, you can add tags 29 | to all selected photos at once using the # (hash) button in the toolbar. 30 | 31 | ## Selection 32 | 33 | You can select photos by holding `Ctrl / Cmd` and clicking on a photo or 34 | dragging to select. 35 | 36 | :::info 37 | Selections are stored as temporary tags that can be used for filtering or other operations. 38 | ::: 39 | 40 | ## Search 41 | 42 | You can filter photos in the collection by searching for `tag:TAG`. 43 | 44 | For example, you can search for `tag:fav` to only show favorited photos, or 45 | `tag:hello tag:world` to only show photos with both `hello` and `world` tags. 46 | This is an early version of filtering and should be more user-friendly in the 47 | future. 48 | 49 | See the [search documentation](search.md) for more on search. 50 | 51 | ## EXIF 52 | 53 | Automatically add tags from EXIF data. 54 | 55 | The only EXIF tags are the currently hardcoded `make` and `model`, and they are 56 | added to the file as `exif:make:` and `exif:model:` tags 57 | respectively. 58 | 59 | To enable the automatic addition of these tags, you need to enable it in the config. 60 | ```yaml 61 | tags: 62 | enable: true 63 | exif: 64 | enable: true 65 | ``` 66 | -------------------------------------------------------------------------------- /docs/license.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | This project is licensed under the MIT License. 4 | 5 | <<< @/../LICENSE 6 | -------------------------------------------------------------------------------- /docs/maintenance.md: -------------------------------------------------------------------------------- 1 | # Maintenance 2 | 3 | Over time the cache database can grow in size due to version upgrades. 4 | To shrink the database to its minimum size, you can _vacuum_ it. Multiple vacuums in a row have no effect as the vacuum itself rewrites the database from 5 | the ground up. 6 | 7 | While the vacuum is in progress, it will take twice the database size and may 8 | take several minutes if you have lots of photos and a low-power system. 9 | 10 | As an example it took around 5 minutes to vacuum a 260 MiB database containing around 500k photos on a DS418play. The size after vacuuming was 61 MiB as all the 11 | leftover data from database upgrades was cleaned up. 12 | 13 | ## How to Vacuum 14 | 15 | 1. Shut down the server 16 | 2. Run the following command 17 | ::: code-group 18 | ```sh [CLI] 19 | ./photofield -vacuum 20 | ``` 21 | 22 | ```sh [Docker] 23 | docker exec -it photofield ./photofield -vacuum 24 | ``` 25 | ::: 26 | 3. Restart the server -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "docs:dev": "vitepress dev", 4 | "docs:build": "vitepress build", 5 | "docs:preview": "vitepress preview" 6 | }, 7 | "dependencies": { 8 | "vitepress": "^1.3.3" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /docs/performance.md: -------------------------------------------------------------------------------- 1 | # Performance 2 | 3 | ## Quick Wins 4 | 5 | - **Install djpeg (libjpeg-turbo)** - See [Quick Start installation instructions](/quick-start#simple-executable) for 2-6x faster JPEG processing in some cases. 6 | - **Install ExifTool** - Better metadata support, especially for videos. Provides more comprehensive metadata extraction than built-in parsers. 7 | - **Use SSD storage** - Significantly improves indexing and access speed, particularly for the thumbnail cache and database operations. 8 | 9 | ## Configuration Tuning 10 | 11 | ### Cache Settings 12 | ```yaml 13 | media: 14 | caches: 15 | image: 16 | max_size: 1GB # Adjust based on available RAM 17 | ``` 18 | 19 | Set cache size to balance memory usage with performance. Larger caches reduce disk I/O but consume more RAM. 20 | 21 | ### Concurrent Processing 22 | ```yaml 23 | media: 24 | concurrent_meta_loads: 4 # Number of CPU cores 25 | concurrent_color_loads: 8 # Usually 2x CPU cores 26 | ``` 27 | 28 | Tune these values based on your hardware. More concurrent operations can speed up initial indexing but may impact system responsiveness. 29 | 30 | ## Debugging Performance 31 | 32 | Monitor thumbnail usage with debug modes, which can be toggled in Settings (Cog Wheel) -> Downward Arrow. 33 | - **Debug Overdraw** - Shows resolution efficiency and helps identify when thumbnails are too large (red) or too small (blue). 34 | - **Debug Thumbnails** - Shows which thumbnail source is used on top of each image and many other details. 35 | 36 | ## Storage Tips 37 | 38 | - **Separate cache storage** - Put cache database on faster storage if possible. 39 | - **Network storage** - Ensure good bandwidth for NAS setups. Ideally run the API server on the same machine as the storage to minimize latency. 40 | 41 | [djpeg (libjpeg-turbo)]: https://github.com/libjpeg-turbo/libjpeg-turbo 42 | -------------------------------------------------------------------------------- /docs/public/assets/features/cat-eyes.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/public/assets/features/cat-eyes.jpg -------------------------------------------------------------------------------- /docs/public/assets/features/downloads.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/public/assets/features/downloads.png -------------------------------------------------------------------------------- /docs/public/assets/features/file-scroll.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/public/assets/features/file-scroll.gif -------------------------------------------------------------------------------- /docs/public/assets/features/gource.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/public/assets/features/gource.jpeg -------------------------------------------------------------------------------- /docs/public/assets/features/llama.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/public/assets/features/llama.gif -------------------------------------------------------------------------------- /docs/public/assets/features/map.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/public/assets/features/map.jpg -------------------------------------------------------------------------------- /docs/public/assets/features/media-system.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/public/assets/features/media-system.png -------------------------------------------------------------------------------- /docs/public/assets/features/progressive-load.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/public/assets/features/progressive-load.gif -------------------------------------------------------------------------------- /docs/public/assets/features/read-only.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/public/assets/features/read-only.png -------------------------------------------------------------------------------- /docs/public/assets/features/seamless-zoom.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/public/assets/features/seamless-zoom.gif -------------------------------------------------------------------------------- /docs/public/assets/features/slovenia.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/public/assets/features/slovenia.jpg -------------------------------------------------------------------------------- /docs/public/assets/features/tags.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/public/assets/features/tags.jpg -------------------------------------------------------------------------------- /docs/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/public/favicon-32x32.png -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/quick-start.md: -------------------------------------------------------------------------------- 1 | # Quick Start 2 | 3 | ## Simple Executable 4 | 5 | 1. [Download and unpack a release] to a folder with folders of photos. 6 | 2. Run `./photofield` or double-click on `photofield.exe` to start the server. 7 | 3. Open http://localhost:8080 and you should see folders in the directory displayed as collections. 8 | 5. You're done 🥳 9 | 10 | Check out [Dependencies](/dependencies) that can enhance your experience and [Usage](/usage) for more details. 11 | 12 | ## Docker 13 | 14 | Make sure you create an empty `data` directory in the working directory and that 15 | you put some photos in a `photos` directory. 16 | 17 | ```sh 18 | docker run -p 8080:8080 -v "$PWD/data:/app/data" -v "$PWD/photos:/app/photos:ro" ghcr.io/smilyorg/photofield 19 | ``` 20 | 21 | The cache database will be persisted to the `data` dir and the app should be 22 | accessible at http://localhost:8080. It should show the `photos` collection by 23 | default. For further configuration, create a `configuration.yaml` in the 24 | `data` dir. 25 | 26 | ## Docker Compose 27 | 28 | This example binds the usual Synology Moments photo directories and assumes 29 | a certain path structure, modify to your needs graciously. It also assumes you 30 | have configured the `/photo` and `/user` directories as collections in `configuration.yaml`. 31 | 32 | ::: code-group 33 | ```yaml [docker-compose.yaml] 34 | version: '3.3' 35 | services: 36 | 37 | photofield: 38 | image: ghcr.io/smilyorg/photofield:latest 39 | ports: 40 | - 8080:8080 41 | volumes: 42 | - /volume1/docker/photofield/data:/app/data 43 | - /volume1/photo/:/photo:ro 44 | - /volume1/homes/ExampleUser/Drive/Moments:/exampleuser:ro 45 | ``` 46 | ::: -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | This section describes how to use, configure, and maintain the app. It's not very extensive yet, but it should get you started. 4 | 5 | * [Quick Start](/quick-start) to install and run the app quickly if you haven't done so yet 6 | * [User Interface](/user-interface) for a quick overview on how to navigate the UI 7 | * [Configuration](/configuration) for details on how to add your collections, enable features, and tune the app 8 | -------------------------------------------------------------------------------- /docs/user-interface.md: -------------------------------------------------------------------------------- 1 | # User Interface 2 | 3 | ## App Bar 4 | ![App bar explanation](assets/app-bar.png) 5 | 6 | ## Photo Viewer 7 | 8 | * Click to zoom to a photo 9 | * `Escape` or pinch out to get back to the list of photos 10 | * Zoom in/out directly with `Ctrl/Cmd`+`Wheel` 11 | * Pinch-to-zoom on touch devices 12 | * Press/hold `Arrow Left` or `Arrow Right` to quickly switch between photos 13 | * Right-click or long-tap as usual to open a custom context menu allowing you to 14 | copy or download original photos or thumbnails. 15 | 16 | ![context menu](assets/context-menu.jpeg) 17 | 18 | _You can open/copy/copy link the original or access any existing thumbnails 19 | that already exist for it with the bottom list of thumbnails by pixel width._ 20 | 21 | 22 | -------------------------------------------------------------------------------- /e2e/.gitignore: -------------------------------------------------------------------------------- 1 | /playwright/.cache/ 2 | /test-results/ 3 | /test-tmp/ 4 | /playwright-report/ 5 | .features-gen/ 6 | node_modules/ -------------------------------------------------------------------------------- /e2e/configs/holiday.yaml: -------------------------------------------------------------------------------- 1 | collections: 2 | - name: holiday 3 | dirs: 4 | - vacation 5 | -------------------------------------------------------------------------------- /e2e/configs/three-collections.yaml: -------------------------------------------------------------------------------- 1 | collections: 2 | - name: test123 3 | dirs: 4 | - ./ 5 | 6 | - name: test456 7 | dirs: 8 | - ./ 9 | 10 | - name: test789 11 | dirs: 12 | - ./ 13 | -------------------------------------------------------------------------------- /e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "photofield-e2e", 3 | "version": "1.0.0", 4 | "description": "Playwright tests for Photofield, see ./ui/ for frontend", 5 | "main": "vetur.config.js", 6 | "directories": { 7 | "doc": "docs" 8 | }, 9 | "scripts": { 10 | "bddgen": "bddgen", 11 | "test": "npx bddgen && npx playwright test", 12 | "watch:bdd": "nodemon -w ./tests -e feature,js,ts --exec npx bddgen", 13 | "watch:pw": "playwright test --ui", 14 | "watch": "run-p watch:*", 15 | "report": "npx playwright show-report", 16 | "steps": "npx bddgen export" 17 | }, 18 | "keywords": [], 19 | "author": "", 20 | "license": "ISC", 21 | "devDependencies": { 22 | "@playwright/test": "^1.44.1", 23 | "@types/node": "^20.12.13", 24 | "nodemon": "^3.1.2", 25 | "npm-run-all": "^4.1.5", 26 | "playwright-bdd": "^6.4.0", 27 | "typescript": "^5.4.5" 28 | } 29 | } -------------------------------------------------------------------------------- /e2e/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | import { defineBddConfig } from 'playwright-bdd'; 3 | 4 | /** 5 | * Read environment variables from file. 6 | * https://github.com/motdotla/dotenv 7 | */ 8 | // require('dotenv').config(); 9 | 10 | const testDir = defineBddConfig({ 11 | paths: ['./tests/**/*.feature'], 12 | steps: ['./src/**/*.ts'], 13 | formatOptions: { 14 | // Fix for ERR_UNSUPPORTED_ESM_URL_SCHEME on Windows 15 | snippetSyntax: './node_modules/playwright-bdd/dist/snippets/snippetSyntaxTs.js' 16 | }, 17 | importTestFrom: './src/fixtures.ts', 18 | }) 19 | 20 | /** 21 | * See https://playwright.dev/docs/test-configuration. 22 | */ 23 | export default defineConfig({ 24 | // testDir: './tests', 25 | testDir, 26 | /* Run tests in files in parallel */ 27 | fullyParallel: true, 28 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 29 | forbidOnly: !!process.env.CI, 30 | /* Retry on CI only */ 31 | retries: process.env.CI ? 2 : 0, 32 | /* Opt out of parallel tests on CI. */ 33 | workers: process.env.CI ? 1 : undefined, 34 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 35 | reporter: 'html', 36 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 37 | use: { 38 | /* Base URL to use in actions like `await page.goto('/')`. */ 39 | // baseURL: 'http://127.0.0.1:3000', 40 | 41 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 42 | trace: 'on-first-retry', 43 | }, 44 | 45 | // globalTeardown: require.resolve('./tests/teardown.ts'), 46 | 47 | /* Configure projects for major browsers */ 48 | projects: [ 49 | { 50 | name: 'chromium', 51 | use: { ...devices['Desktop Chrome'] }, 52 | }, 53 | 54 | { 55 | name: 'firefox', 56 | use: { ...devices['Desktop Firefox'] }, 57 | }, 58 | 59 | { 60 | name: 'webkit', 61 | use: { ...devices['Desktop Safari'] }, 62 | }, 63 | 64 | /* Test against mobile viewports. */ 65 | // { 66 | // name: 'Mobile Chrome', 67 | // use: { ...devices['Pixel 5'] }, 68 | // }, 69 | // { 70 | // name: 'Mobile Safari', 71 | // use: { ...devices['iPhone 12'] }, 72 | // }, 73 | 74 | /* Test against branded browsers. */ 75 | // { 76 | // name: 'Microsoft Edge', 77 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 78 | // }, 79 | // { 80 | // name: 'Google Chrome', 81 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 82 | // }, 83 | ], 84 | 85 | /* Run your local dev server before starting the tests */ 86 | // webServer: { 87 | // command: 'npm run start', 88 | // url: 'http://127.0.0.1:3000', 89 | // reuseExistingServer: !process.env.CI, 90 | // }, 91 | }); 92 | -------------------------------------------------------------------------------- /e2e/src/teardown.ts: -------------------------------------------------------------------------------- 1 | import { promisify } from "util"; 2 | const exec = promisify(require("child_process").exec); 3 | 4 | async function globalTeardown() { 5 | if (process.platform === "win32") { 6 | await killExiftool(); 7 | } 8 | } 9 | 10 | // Workaround of photofield not cleaning up exiftool.exe properly 11 | async function killExiftool() { 12 | await exec("taskkill /F /IM exiftool.exe"); 13 | } 14 | 15 | export default globalTeardown; 16 | -------------------------------------------------------------------------------- /e2e/tests/cleanup.feature: -------------------------------------------------------------------------------- 1 | Feature: Cleanup 2 | 3 | Scenario: Database files are cleaned up on exit 4 | Given an empty working directory 5 | 6 | When the user runs the app 7 | Then the app logs "app running" 8 | And the file "photofield.cache.db" exists 9 | And the file "photofield.cache.db-shm" exists 10 | And the file "photofield.cache.db-wal" exists 11 | And the file "photofield.thumbs.db" exists 12 | And the file "photofield.thumbs.db-shm" exists 13 | And the file "photofield.thumbs.db-wal" exists 14 | 15 | When the user stops the app 16 | Then the file "photofield.cache.db" exists 17 | And the file "photofield.cache.db-shm" does not exist 18 | And the file "photofield.cache.db-wal" does not exist 19 | And the file "photofield.thumbs.db" exists 20 | And the file "photofield.thumbs.db-shm" does not exist 21 | And the file "photofield.thumbs.db-wal" does not exist 22 | -------------------------------------------------------------------------------- /e2e/tests/connection-error.feature: -------------------------------------------------------------------------------- 1 | Feature: Connection Error Message 2 | 3 | Scenario: UI loads, but API is down 4 | Given an empty working directory 5 | And no running app 6 | When the user opens the home page 7 | Then the page shows "Connection error" 8 | 9 | Scenario: UI loads, API is up intermittently 10 | Given an empty working directory 11 | And a running app 12 | When the user opens the home page 13 | Then the page shows "Photos" 14 | Then the page does not show "Connection error" 15 | And the page shows "No collections" 16 | When the API goes down 17 | And the user waits for 2 seconds 18 | And the user switches away and back to the page 19 | And the user waits for 5 seconds 20 | Then the page shows "Connection error" 21 | When the API comes back up 22 | Then the page shows "Photos" 23 | When the user clicks "Retry" 24 | Then the page does not show "Connecting..." 25 | And the page does not show "Connection error" 26 | 27 | Scenario: Collection page opens, but API is down 28 | Given an empty working directory 29 | And the following files: 30 | | src | dst | 31 | | ../docs/assets/logo-wide.jpg | vacation/a.jpg | 32 | 33 | When the user opens "/collections/vacation" 34 | Then the page shows "Connection error" 35 | -------------------------------------------------------------------------------- /e2e/tests/first-run.feature: -------------------------------------------------------------------------------- 1 | Feature: First User Experience 2 | 3 | Scenario: Empty Folder 4 | Given an empty working directory 5 | When the user runs the app 6 | Then the app logs "app running" 7 | When the user opens the home page 8 | Then the page shows "Photos" 9 | Then the page does not show "Connection error" 10 | And the page shows "No collections" 11 | 12 | Scenario: Empty Folder + Add Folder 13 | Given an empty working directory 14 | 15 | When the user runs the app 16 | Then the app logs "app running" 17 | 18 | When the user opens the home page 19 | Then the page shows "Photos" 20 | And the page shows "No collections" 21 | 22 | When the user adds a folder "vacation" 23 | And waits a second 24 | And the user clicks "Retry" 25 | Then the page does not show "No collections" 26 | 27 | Scenario: One Folder 28 | Given the following files: 29 | | src | dst | 30 | | ../docs/assets/logo-wide.jpg | vacation/a.jpg | 31 | 32 | When the user runs the app 33 | Then the app logs "app running" 34 | 35 | When the user opens the home page 36 | Then the page shows "Photos" 37 | And the page shows "vacation" 38 | 39 | Scenario: One Folder + Add Config 40 | Given the following files: 41 | | src | dst | 42 | | ../docs/assets/logo-wide.jpg | vacation/a.jpg | 43 | 44 | When the user opens the home page 45 | Then the page shows "vacation" 46 | 47 | When the user adds the config "holiday.yaml" 48 | And waits a second 49 | And opens the home page 50 | Then the page shows "holiday" 51 | And the page does not show "vacation" 52 | 53 | Scenario: Add one photo in a dir 54 | Given an empty working directory 55 | 56 | When the user runs the app 57 | Then the app logs "app running" 58 | 59 | When the user opens the home page 60 | Then the page shows "Photos" 61 | And the page shows "No collections" 62 | 63 | When the user adds a folder "photos" 64 | And the user adds the following files: 65 | | src | dst | 66 | | ../docs/assets/logo-wide.jpg | photos/a.jpg | 67 | 68 | When the user clicks "Retry" 69 | Then the page does not show "No collections" 70 | 71 | Scenario: Preconfigured Basic 72 | Given an empty working directory 73 | And the config "three-collections.yaml" 74 | 75 | When the user runs the app 76 | Then the app logs "app running" 77 | And the app logs "config path configuration.yaml" 78 | And the app logs "test123" 79 | And the app logs "test456" 80 | And the app logs "test789" 81 | 82 | When the user opens the home page 83 | Then the page shows "Photos" 84 | 85 | When the user opens the home page 86 | Then the page shows "Photos" 87 | And the page shows "test123" 88 | And the page shows "test456" 89 | And the page shows "test789" -------------------------------------------------------------------------------- /e2e/tests/rescan.feature: -------------------------------------------------------------------------------- 1 | Feature: Rescan 2 | 3 | Scenario: One photo 4 | Given an empty working directory 5 | And a running app 6 | And the following files: 7 | | src | dst | 8 | | ../docs/assets/logo-wide.jpg | photos/a.jpg | 9 | 10 | When the user opens "/collections/photos" 11 | And the user clicks "photos" 12 | Then the page shows "0 files indexed" 13 | 14 | When the user clicks "Rescan" 15 | Then the page shows "1 file indexed" 16 | 17 | When the user clicks "photos" 18 | Then the page shows photo "photos/a.jpg" 19 | -------------------------------------------------------------------------------- /embed-docs-stub.go: -------------------------------------------------------------------------------- 1 | //go:build !embeddocs 2 | // +build !embeddocs 3 | 4 | package main 5 | 6 | import "embed" 7 | 8 | var StaticDocsFs embed.FS 9 | var StaticDocsPath = "" 10 | -------------------------------------------------------------------------------- /embed-docs.go: -------------------------------------------------------------------------------- 1 | //go:build embeddocs 2 | // +build embeddocs 3 | 4 | package main 5 | 6 | import "embed" 7 | 8 | //go:embed docs/.vitepress/dist 9 | var StaticDocsFs embed.FS 10 | var StaticDocsPath = "docs/.vitepress/dist" 11 | -------------------------------------------------------------------------------- /embed-geo-stub.go: -------------------------------------------------------------------------------- 1 | //go:build !embedgeo 2 | // +build !embedgeo 3 | 4 | package main 5 | 6 | import "embed" 7 | 8 | var GeoFs embed.FS 9 | -------------------------------------------------------------------------------- /embed-geo.go: -------------------------------------------------------------------------------- 1 | //go:build embedgeo 2 | // +build embedgeo 3 | 4 | package main 5 | 6 | import "embed" 7 | 8 | // tinygpkg-data release: v0.2.0 9 | //go:embed data/geo/geoBoundariesCGAZ_ADM2_s5_twkb_p3.gpkg 10 | var GeoFs embed.FS 11 | -------------------------------------------------------------------------------- /embed-ui-stub.go: -------------------------------------------------------------------------------- 1 | //go:build !embedui 2 | // +build !embedui 3 | 4 | package main 5 | 6 | import "embed" 7 | 8 | var StaticFs embed.FS 9 | -------------------------------------------------------------------------------- /embed-ui.go: -------------------------------------------------------------------------------- 1 | //go:build embedui 2 | // +build embedui 3 | 4 | package main 5 | 6 | import "embed" 7 | 8 | //go:embed ui/dist 9 | var StaticFs embed.FS 10 | -------------------------------------------------------------------------------- /fonts/Roboto/Roboto-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/fonts/Roboto/Roboto-Black.ttf -------------------------------------------------------------------------------- /fonts/Roboto/Roboto-BlackItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/fonts/Roboto/Roboto-BlackItalic.ttf -------------------------------------------------------------------------------- /fonts/Roboto/Roboto-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/fonts/Roboto/Roboto-Bold.ttf -------------------------------------------------------------------------------- /fonts/Roboto/Roboto-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/fonts/Roboto/Roboto-BoldItalic.ttf -------------------------------------------------------------------------------- /fonts/Roboto/Roboto-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/fonts/Roboto/Roboto-Italic.ttf -------------------------------------------------------------------------------- /fonts/Roboto/Roboto-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/fonts/Roboto/Roboto-Light.ttf -------------------------------------------------------------------------------- /fonts/Roboto/Roboto-LightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/fonts/Roboto/Roboto-LightItalic.ttf -------------------------------------------------------------------------------- /fonts/Roboto/Roboto-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/fonts/Roboto/Roboto-Medium.ttf -------------------------------------------------------------------------------- /fonts/Roboto/Roboto-MediumItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/fonts/Roboto/Roboto-MediumItalic.ttf -------------------------------------------------------------------------------- /fonts/Roboto/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/fonts/Roboto/Roboto-Regular.ttf -------------------------------------------------------------------------------- /fonts/Roboto/Roboto-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/fonts/Roboto/Roboto-Thin.ttf -------------------------------------------------------------------------------- /fonts/Roboto/Roboto-ThinItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/fonts/Roboto/Roboto-ThinItalic.ttf -------------------------------------------------------------------------------- /internal/clip/clip.go: -------------------------------------------------------------------------------- 1 | package clip 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/x448/float16" 9 | ) 10 | 11 | var ErrMismatchedLength = errors.New("slice lengths do not match") 12 | 13 | type Float float16.Float16 14 | 15 | func (e Float) Float32() float32 { 16 | return float16.Float16(e).Float32() 17 | } 18 | 19 | func DotProductFloat32Float(a []float32, b []Float) (float32, error) { 20 | l := len(a) 21 | if l != len(b) { 22 | return 0, fmt.Errorf("slice lengths do not match, a %d b %d", l, len(b)) 23 | } 24 | 25 | dot := float32(0) 26 | for i := 0; i < l; i++ { 27 | dot += a[i] * b[i].Float32() 28 | } 29 | return dot, nil 30 | } 31 | 32 | func DotProductFloat32Float32(a []float32, b []float32) (float32, error) { 33 | l := len(a) 34 | if l != len(b) { 35 | return 0, fmt.Errorf("slice lengths do not match, a %d b %d", l, len(b)) 36 | } 37 | 38 | dot := float32(0) 39 | for i := 0; i < l; i++ { 40 | dot += a[i] * b[i] 41 | } 42 | return dot, nil 43 | } 44 | 45 | func CosineSimilarityEmbeddingFloat32(e Embedding, f []float32, invnorm float32) (float32, error) { 46 | dot, err := DotProductFloat32Float32(e.Float32(), f) 47 | if err != nil { 48 | return 0, err 49 | } 50 | return dot * invnorm * e.InvNormFloat32(), nil 51 | } 52 | 53 | func CosineSimilarityFloat32Float32(a []float32, ainvnorm float32, b []float32, binvnorm float32) (float32, error) { 54 | dot, err := DotProductFloat32Float32(a, b) 55 | if err != nil { 56 | return 0, err 57 | } 58 | return dot * ainvnorm * binvnorm, nil 59 | } 60 | 61 | // Most real world inverse vector norms of embeddings fall 62 | // within ~500 of 11843, so it's more efficient to store 63 | // the inverse vector norm as an offset of this number. 64 | const InvNormMean = 11843 65 | 66 | type Clip interface { 67 | EmbedImagePath(path string) (Embedding, error) 68 | EmbedImageReader(r io.Reader) (Embedding, error) 69 | EmbedText(text string) (Embedding, error) 70 | } 71 | 72 | type Embedding interface { 73 | Byte() []byte 74 | Float() []Float 75 | Float32() []float32 76 | InvNormUint16() uint16 77 | InvNormFloat32() float32 78 | } 79 | -------------------------------------------------------------------------------- /internal/codec/image.go: -------------------------------------------------------------------------------- 1 | //go:build !libjpeg 2 | // +build !libjpeg 3 | 4 | package codec 5 | 6 | import ( 7 | "image" 8 | "image/jpeg" 9 | "io" 10 | ) 11 | 12 | func DecodeJpeg(reader io.ReadSeeker) (image.Image, error) { 13 | return jpeg.Decode(reader) 14 | } 15 | 16 | func EncodeJpeg(w io.Writer, image image.Image, quality int) error { 17 | return jpeg.Encode(w, image, &jpeg.Options{ 18 | Quality: quality, 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /internal/codec/libjpeg.go: -------------------------------------------------------------------------------- 1 | //go:build libjpeg 2 | // +build libjpeg 3 | 4 | package codec 5 | 6 | import ( 7 | "image" 8 | "io" 9 | 10 | "github.com/pixiv/go-libjpeg/jpeg" 11 | ) 12 | 13 | func DecodeJpeg(reader io.ReadSeeker) (image.Image, error) { 14 | return jpeg.Decode(reader, &jpeg.DecoderOptions{}) 15 | } 16 | 17 | func EncodeJpeg(w io.Writer, image image.Image) error { 18 | return jpeg.Encode(w, image, &jpeg.EncoderOptions{ 19 | Quality: 80, 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /internal/fs/rewrite/fs.go: -------------------------------------------------------------------------------- 1 | package rewrite 2 | 3 | import ( 4 | "io" 5 | "io/fs" 6 | "regexp" 7 | "strings" 8 | "testing/fstest" 9 | "time" 10 | ) 11 | 12 | type FileSystem struct { 13 | FS fs.FS 14 | rewritten fstest.MapFS 15 | } 16 | 17 | func FS(fs fs.FS, exts []string, regex string, replace string) (*FileSystem, error) { 18 | rfs := &FileSystem{ 19 | FS: fs, 20 | rewritten: make(fstest.MapFS), 21 | } 22 | var err error 23 | re, err := regexp.Compile(regex) 24 | if err != nil { 25 | return nil, err 26 | } 27 | err = rfs.replace(exts, re, replace) 28 | if err != nil { 29 | return nil, err 30 | } 31 | return rfs, nil 32 | } 33 | 34 | func (rfs *FileSystem) Open(name string) (fs.File, error) { 35 | if f, err := rfs.rewritten.Open(name); err == nil { 36 | return f, nil 37 | } 38 | return rfs.FS.Open(name) 39 | } 40 | 41 | func (rfs *FileSystem) replace(exts []string, re *regexp.Regexp, repl string) error { 42 | fs.WalkDir(rfs.FS, ".", func(path string, d fs.DirEntry, err error) error { 43 | if err != nil { 44 | return err 45 | } 46 | if d.IsDir() { 47 | return nil 48 | } 49 | 50 | if !strings.HasSuffix(path, ".html") && 51 | !strings.HasSuffix(path, ".css") && 52 | !strings.HasSuffix(path, ".js") { 53 | return nil 54 | } 55 | 56 | f, err := rfs.FS.Open(path) 57 | if err != nil { 58 | return err 59 | } 60 | defer f.Close() 61 | 62 | data, err := io.ReadAll(f) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | str := string(data) 68 | 69 | r := re.ReplaceAllString(str, repl) 70 | contains := str != r 71 | if contains { 72 | rfs.rewritten[path] = &fstest.MapFile{ 73 | Data: []byte(r), 74 | Mode: 0644, 75 | ModTime: time.Now(), 76 | } 77 | } 78 | return nil 79 | }) 80 | return nil 81 | } 82 | -------------------------------------------------------------------------------- /internal/fs/watch_test.go: -------------------------------------------------------------------------------- 1 | package fs 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestWatcher(t *testing.T) { 11 | dir, err := os.MkdirTemp("", "watcher-test") 12 | if err != nil { 13 | t.Fatal(err) 14 | } 15 | defer os.RemoveAll(dir) 16 | 17 | w, err := NewPathsWatcher([]string{dir}) 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | defer w.Close() 22 | 23 | // Create a file 24 | file := filepath.Join(dir, "test.txt") 25 | if err := os.WriteFile(file, []byte("hello"), 0644); err != nil { 26 | t.Fatal(err) 27 | } 28 | 29 | // Wait for the event 30 | select { 31 | case e := <-w.Events: 32 | if e.Path != file || e.Op != Update { 33 | t.Fatalf("unexpected event: %+v", e) 34 | } 35 | case <-time.After(1 * time.Second): 36 | t.Fatal("timeout waiting for event") 37 | } 38 | 39 | // Modify the file 40 | if err := os.WriteFile(file, []byte("world"), 0644); err != nil { 41 | t.Fatal(err) 42 | } 43 | 44 | // Wait for the event 45 | select { 46 | case e := <-w.Events: 47 | if e.Path != file || e.Op != Update { 48 | t.Fatalf("unexpected event: %+v", e) 49 | } 50 | case <-time.After(1 * time.Second): 51 | t.Fatal("timeout waiting for event") 52 | } 53 | 54 | // Rename the file 55 | newFile := filepath.Join(dir, "test2.txt") 56 | if err := os.Rename(file, newFile); err != nil { 57 | t.Fatal(err) 58 | } 59 | 60 | // Wait for the event 61 | select { 62 | case e := <-w.Events: 63 | if e.Path != newFile || e.OldPath != file || e.Op != Rename { 64 | t.Fatalf("unexpected event: %+v", e) 65 | } 66 | case <-time.After(1 * time.Second): 67 | t.Fatal("timeout waiting for event") 68 | } 69 | 70 | // Wait for the events 71 | // select { 72 | // case e := <-w.Events: 73 | // if e.Path != file || e.Op != Remove { 74 | // t.Fatalf("unexpected event: %+v", e) 75 | // } 76 | // case <-time.After(1 * time.Second): 77 | // t.Fatal("timeout waiting for event") 78 | // } 79 | // select { 80 | // case e := <-w.Events: 81 | // if e.Path != newFile || e.Op != Update { 82 | // t.Fatalf("unexpected event: %+v", e) 83 | // } 84 | // case <-time.After(1 * time.Second): 85 | // t.Fatal("timeout waiting for event") 86 | // } 87 | 88 | // Create a directory 89 | dir2 := filepath.Join(dir, "testdir") 90 | if err := os.Mkdir(dir2, 0755); err != nil { 91 | t.Fatal(err) 92 | } 93 | 94 | // Wait for the event 95 | select { 96 | case e := <-w.Events: 97 | if e.Path != dir2 || e.Op != Update { 98 | t.Fatalf("unexpected event: %+v", e) 99 | } 100 | case <-time.After(1 * time.Second): 101 | t.Fatal("timeout waiting for event") 102 | } 103 | 104 | // Rename the directory 105 | newDir := filepath.Join(dir, "testdir2") 106 | if err := os.Rename(dir2, newDir); err != nil { 107 | t.Fatal(err) 108 | } 109 | 110 | // Wait for the event 111 | select { 112 | case e := <-w.Events: 113 | if e.Path != newDir || e.OldPath != dir2 || e.Op != Rename { 114 | t.Fatalf("unexpected event: %+v", e) 115 | } 116 | case <-time.After(1 * time.Second): 117 | t.Fatal("timeout waiting for event") 118 | } 119 | 120 | // select { 121 | // case e := <-w.Events: 122 | // if e.Path != dir2 || e.Op != Remove { 123 | // t.Fatalf("unexpected event: %+v", e) 124 | // } 125 | // case <-time.After(1 * time.Second): 126 | // t.Fatal("timeout waiting for event") 127 | // } 128 | // select { 129 | // case e := <-w.Events: 130 | // if e.Path != newDir || e.Op != Update { 131 | // t.Fatalf("unexpected event: %+v", e) 132 | // } 133 | // case <-time.After(1 * time.Second): 134 | // t.Fatal("timeout waiting for event") 135 | // } 136 | } 137 | -------------------------------------------------------------------------------- /internal/geo/cache.go: -------------------------------------------------------------------------------- 1 | package geo 2 | 3 | import ( 4 | "fmt" 5 | "photofield/internal/metrics" 6 | "unsafe" 7 | 8 | "github.com/dgraph-io/ristretto" 9 | "github.com/peterstace/simplefeatures/geom" 10 | "github.com/smilyorg/tinygpkg/gpkg" 11 | ) 12 | 13 | type Cache struct { 14 | cache *ristretto.Cache[int64, geom.Geometry] 15 | } 16 | 17 | func NewCache() (*Cache, error) { 18 | g := &Cache{} 19 | c, err := ristretto.NewCache(&ristretto.Config[int64, geom.Geometry]{ 20 | NumCounters: 100000, // number of keys to track frequency of, 10x max expected key count 21 | MaxCost: 64_000_000, // maximum size/cost of cache 22 | BufferItems: 64, // number of keys per Get buffer. 23 | Metrics: true, 24 | Cost: func(g geom.Geometry) int64 { 25 | return estimateGeometryMemorySize(g) 26 | }, 27 | }) 28 | if err != nil { 29 | return nil, fmt.Errorf("failed to create geometry cache: %w", err) 30 | } 31 | c.Close() 32 | metrics.AddRistretto("geometry_cache", c) 33 | g.cache = c 34 | return g, nil 35 | } 36 | 37 | func (g *Cache) Close() { 38 | if g == nil || g.cache == nil { 39 | return 40 | } 41 | g.cache.Clear() 42 | g.cache.Close() 43 | g.cache = nil 44 | } 45 | 46 | func (g *Cache) Get(fid gpkg.FeatureId) (geom.Geometry, error) { 47 | v, ok := g.cache.Get(int64(fid)) 48 | if !ok { 49 | return geom.Geometry{}, gpkg.ErrNotFound 50 | } 51 | return v, nil 52 | } 53 | 54 | func (g *Cache) Set(fid gpkg.FeatureId, geom geom.Geometry) error { 55 | g.cache.Set(int64(fid), geom, 0) 56 | return nil 57 | } 58 | 59 | func estimateGeometryMemorySize(g geom.Geometry) int64 { 60 | switch g.Type() { 61 | case geom.TypeGeometryCollection: 62 | m := int64(0) 63 | c := g.MustAsGeometryCollection() 64 | for i := 0; i < c.NumGeometries(); i++ { 65 | m += estimateGeometryMemorySize(c.GeometryN(i)) 66 | } 67 | return m 68 | case geom.TypePoint: 69 | return int64(unsafe.Sizeof(geom.Point{})) 70 | case geom.TypeLineString: 71 | l := g.MustAsLineString() 72 | c := l.Coordinates() 73 | return int64(c.Length()*c.CoordinatesType().Dimension()*8) + int64(unsafe.Sizeof(l)) 74 | case geom.TypePolygon: 75 | p := g.MustAsPolygon() 76 | m := int64(0) 77 | m += estimateGeometryMemorySize(p.ExteriorRing().AsGeometry()) 78 | for i := 0; i < p.NumInteriorRings(); i++ { 79 | m += estimateGeometryMemorySize(p.InteriorRingN(i).AsGeometry()) 80 | } 81 | return m + int64(unsafe.Sizeof(p)) 82 | case geom.TypeMultiPoint: 83 | mp := g.MustAsMultiPoint() 84 | m := int64(0) 85 | for i := 0; i < mp.NumPoints(); i++ { 86 | m += estimateGeometryMemorySize(mp.PointN(i).AsGeometry()) 87 | } 88 | return m + int64(unsafe.Sizeof(mp)) 89 | case geom.TypeMultiLineString: 90 | mls := g.MustAsMultiLineString() 91 | m := int64(0) 92 | for i := 0; i < mls.NumLineStrings(); i++ { 93 | m += estimateGeometryMemorySize(mls.LineStringN(i).AsGeometry()) 94 | } 95 | return m + int64(unsafe.Sizeof(mls)) 96 | case geom.TypeMultiPolygon: 97 | mp := g.MustAsMultiPolygon() 98 | m := int64(0) 99 | for i := 0; i < mp.NumPolygons(); i++ { 100 | m += estimateGeometryMemorySize(mp.PolygonN(i).AsGeometry()) 101 | } 102 | return m + int64(unsafe.Sizeof(mp)) 103 | default: 104 | panic("unsupported geometry type") 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /internal/image/cache.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "photofield/internal/metrics" 5 | "unsafe" 6 | 7 | "github.com/dgraph-io/ristretto" 8 | ) 9 | 10 | type InfoCache struct { 11 | cache *ristretto.Cache[uint32, Info] 12 | } 13 | 14 | func (c *InfoCache) Get(id ImageId) (Info, bool) { 15 | value, found := c.cache.Get((uint32)(id)) 16 | if found { 17 | return value, true 18 | } 19 | return Info{}, false 20 | } 21 | 22 | func (c *InfoCache) Set(id ImageId, info Info) error { 23 | c.cache.Set((uint32)(id), info, (int64)(unsafe.Sizeof(info))) 24 | return nil 25 | } 26 | 27 | func (c *InfoCache) Delete(id ImageId) { 28 | c.cache.Del((uint32)(id)) 29 | } 30 | 31 | func newInfoCache() InfoCache { 32 | cache, err := ristretto.NewCache(&ristretto.Config[uint32, Info]{ 33 | NumCounters: 1e6, // number of keys to track frequency of (1M). 34 | MaxCost: 1 << 24, // maximum cost of cache (16MB). 35 | BufferItems: 64, // number of keys per Get buffer. 36 | Metrics: true, 37 | }) 38 | if err != nil { 39 | panic(err) 40 | } 41 | metrics.AddRistretto("image_info_cache", cache) 42 | return InfoCache{ 43 | cache: cache, 44 | } 45 | } 46 | 47 | func (c *InfoCache) Close() { 48 | if c == nil || c.cache == nil { 49 | return 50 | } 51 | c.cache.Clear() 52 | c.cache.Close() 53 | c.cache = nil 54 | } 55 | 56 | type PathCache struct { 57 | cache *ristretto.Cache[uint32, string] 58 | } 59 | 60 | func (c *PathCache) Get(id ImageId) (string, bool) { 61 | value, found := c.cache.Get((uint32)(id)) 62 | if found { 63 | return value, true 64 | } 65 | return "", false 66 | } 67 | 68 | func (c *PathCache) Set(id ImageId, path string) error { 69 | c.cache.Set((uint32)(id), path, (int64)(len(path))) 70 | return nil 71 | } 72 | 73 | func (c *PathCache) Delete(id ImageId) { 74 | c.cache.Del((uint32)(id)) 75 | } 76 | 77 | func newPathCache() PathCache { 78 | cache, err := ristretto.NewCache(&ristretto.Config[uint32, string]{ 79 | NumCounters: 10e3, // number of keys to track frequency of (10k). 80 | MaxCost: 1 << 22, // maximum cost of cache (4MB). 81 | BufferItems: 64, // number of keys per Get buffer. 82 | Metrics: true, 83 | }) 84 | if err != nil { 85 | panic(err) 86 | } 87 | metrics.AddRistretto("path_cache", cache) 88 | return PathCache{ 89 | cache: cache, 90 | } 91 | } 92 | 93 | func (c *PathCache) Close() { 94 | if c == nil || c.cache == nil { 95 | return 96 | } 97 | c.cache.Clear() 98 | c.cache.Close() 99 | c.cache = nil 100 | } 101 | -------------------------------------------------------------------------------- /internal/image/color.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | 7 | "github.com/EdlinOrg/prominentcolor" 8 | ) 9 | 10 | func extractProminentColor(img image.Image) (color.RGBA, error) { 11 | centroids, err := prominentcolor.KmeansWithAll(1, img, prominentcolor.ArgumentDefault, prominentcolor.DefaultSize, prominentcolor.GetDefaultMasks()) 12 | if err != nil { 13 | centroids, err = prominentcolor.KmeansWithAll(1, img, prominentcolor.ArgumentDefault, prominentcolor.DefaultSize, make([]prominentcolor.ColorBackgroundMask, 0)) 14 | if err != nil { 15 | return color.RGBA{}, err 16 | } 17 | } 18 | promColor := centroids[0] 19 | return color.RGBA{ 20 | A: 0xFF, 21 | R: uint8(promColor.Color.R), 22 | G: uint8(promColor.Color.G), 23 | B: uint8(promColor.Color.B), 24 | }, nil 25 | } 26 | -------------------------------------------------------------------------------- /internal/image/decoder.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "bytes" 5 | goimage "image" 6 | "image/jpeg" 7 | "io" 8 | "log" 9 | "photofield/tag" 10 | "strconv" 11 | "time" 12 | ) 13 | 14 | type Decoder struct { 15 | loader metadataLoader 16 | goexifLoader *GoExifRwcarlsenLoader 17 | } 18 | 19 | type metadataLoader interface { 20 | DecodeInfo(path string, info *Info) ([]tag.Tag, error) 21 | DecodeBytes(path string, tagName string) ([]byte, error) 22 | Close() 23 | } 24 | 25 | func NewDecoder(exifToolCount int, exifToolPath string) *Decoder { 26 | decoder := Decoder{} 27 | decoder.goexifLoader = NewGoExifRwcarlsenLoader() 28 | if exifToolCount > 0 { 29 | var err error 30 | decoder.loader, err = NewExifToolMostlyGeekLoader(exifToolCount, exifToolPath) 31 | if err != nil { 32 | log.Printf("unable to use exiftool, defaulting to goexif - no video metadata support (%v)\n", err.Error()) 33 | decoder.loader = decoder.goexifLoader 34 | } 35 | } else { 36 | decoder.loader = decoder.goexifLoader 37 | } 38 | return &decoder 39 | } 40 | 41 | func (decoder *Decoder) Close() { 42 | if decoder == nil { 43 | return 44 | } 45 | decoder.loader.Close() 46 | } 47 | 48 | func parseDateTime(value string) (t time.Time, hasTimezone bool, hasSubsec bool, err error) { 49 | t, err = time.Parse("2006:01:02 15:04:05Z07:00", value) 50 | if err == nil { 51 | hasTimezone = true 52 | hasSubsec = false 53 | return 54 | } 55 | t, err = time.Parse("2006:01:02 15:04:05", value) 56 | if err == nil { 57 | hasTimezone = false 58 | hasSubsec = false 59 | return 60 | } 61 | t, err = time.Parse("2006:01:02 15:04:05.99999999Z07:00", value) 62 | if err == nil { 63 | hasTimezone = true 64 | hasSubsec = true 65 | return 66 | } 67 | t, err = time.Parse("2006:01:02 15:04:05.99999999", value) 68 | if err == nil { 69 | hasTimezone = false 70 | hasSubsec = true 71 | return 72 | } 73 | return 74 | } 75 | 76 | func (decoder *Decoder) DecodeInfo(path string, info *Info) ([]tag.Tag, error) { 77 | return decoder.loader.DecodeInfo(path, info) 78 | } 79 | 80 | func (decoder *Decoder) DecodeImage(path string, tagName string) (goimage.Image, Info, error) { 81 | imageBytes, err := decoder.loader.DecodeBytes(path, tagName) 82 | if err != nil { 83 | return nil, Info{}, err 84 | } 85 | info := Info{} 86 | r := bytes.NewReader(imageBytes) 87 | decoder.goexifLoader.DecodeInfoReader(r, &info) 88 | 89 | r.Seek(0, io.SeekStart) 90 | img, err := jpeg.Decode(r) 91 | return img, info, err 92 | } 93 | 94 | func parseOrientation(orientation string) Orientation { 95 | n, err := strconv.Atoi(orientation) 96 | if err != nil || n < 1 || n > 8 { 97 | return Normal 98 | } 99 | return Orientation(n) 100 | } 101 | 102 | func getOrientationFromRotation(rotation string) Orientation { 103 | switch rotation { 104 | case "0": 105 | return Normal 106 | case "90": 107 | return Rotate90 108 | case "180": 109 | return Rotate180 110 | case "270": 111 | return Rotate270 112 | default: 113 | return Normal 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /internal/image/goexif-rwcarlsen.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "image" 5 | "io" 6 | "os" 7 | "photofield/tag" 8 | 9 | "github.com/rwcarlsen/goexif/exif" 10 | ) 11 | 12 | type GoExifRwcarlsenLoader struct{} 13 | 14 | func NewGoExifRwcarlsenLoader() *GoExifRwcarlsenLoader { 15 | return &GoExifRwcarlsenLoader{} 16 | } 17 | 18 | func getOrientationFromExif(x *exif.Exif) string { 19 | if x == nil { 20 | return "1" 21 | } 22 | orient, err := x.Get(exif.Orientation) 23 | if err != nil { 24 | return "1" 25 | } 26 | if orient != nil { 27 | return orient.String() 28 | } 29 | return "1" 30 | } 31 | 32 | func (decoder *GoExifRwcarlsenLoader) DecodeInfo(path string, info *Info) ([]tag.Tag, error) { 33 | file, err := os.Open(path) 34 | if err != nil { 35 | return nil, err 36 | } 37 | defer file.Close() 38 | return nil, decoder.DecodeInfoReader(file, info) 39 | } 40 | 41 | func (decoder *GoExifRwcarlsenLoader) DecodeInfoReader(r io.ReadSeeker, info *Info) error { 42 | x, err := exif.Decode(r) 43 | if err == nil { 44 | info.DateTime, _ = x.DateTime() 45 | } 46 | 47 | orientation := parseOrientation(getOrientationFromExif(x)) 48 | 49 | r.Seek(0, io.SeekStart) 50 | conf, _, err := image.DecodeConfig(r) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | if orientation.SwapsDimensions() { 56 | conf.Width, conf.Height = conf.Height, conf.Width 57 | } 58 | 59 | info.Width, info.Height = conf.Width, conf.Height 60 | info.Orientation = orientation 61 | 62 | return nil 63 | } 64 | 65 | func (decoder *GoExifRwcarlsenLoader) DecodeBytes(path string, tagName string) ([]byte, error) { 66 | file, err := os.Open(path) 67 | if err != nil { 68 | return nil, err 69 | } 70 | defer file.Close() 71 | 72 | x, err := exif.Decode(file) 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | tag, err := x.Get(exif.FieldName(tagName)) 78 | if err != nil { 79 | return nil, err 80 | } 81 | 82 | return tag.Val, nil 83 | } 84 | 85 | func (decoder *GoExifRwcarlsenLoader) Close() {} 86 | -------------------------------------------------------------------------------- /internal/image/indexFiles.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "path/filepath" 8 | "photofield/internal/metrics" 9 | "strings" 10 | "time" 11 | 12 | "github.com/karrick/godirwalk" 13 | ) 14 | 15 | var ErrSkip = errors.New("skipping the rest") 16 | 17 | func walkFiles(dir string, extensions []string, maxFiles int) <-chan string { 18 | out := make(chan string) 19 | go func() { 20 | finished := metrics.Elapsed(fmt.Sprintf("index %s", dir)) 21 | defer finished() 22 | 23 | lastLogTime := time.Now() 24 | files := 0 25 | err := godirwalk.Walk(dir, &godirwalk.Options{ 26 | Unsorted: true, 27 | Callback: func(path string, walk_dir *godirwalk.Dirent) error { 28 | if strings.Contains(path, "@eaDir") { 29 | return filepath.SkipDir 30 | } 31 | 32 | suffix := "" 33 | for _, ext := range extensions { 34 | if strings.HasSuffix(strings.ToLower(path), ext) { 35 | suffix = ext 36 | break 37 | } 38 | } 39 | if suffix == "" { 40 | return nil 41 | } 42 | 43 | files++ 44 | now := time.Now() 45 | if now.Sub(lastLogTime) > 1*time.Second { 46 | lastLogTime = now 47 | log.Printf("indexing %s %d files\n", dir, files) 48 | } 49 | out <- path 50 | if maxFiles > 0 && files >= maxFiles { 51 | return ErrSkip 52 | } 53 | return nil 54 | }, 55 | }) 56 | if err != nil && err != ErrSkip { 57 | log.Printf("Error indexing files: %s\n", err.Error()) 58 | } 59 | 60 | close(out) 61 | }() 62 | return out 63 | } 64 | -------------------------------------------------------------------------------- /internal/image/indexMetadata.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | func (source *Source) indexMetadata(in <-chan interface{}) { 8 | for elem := range in { 9 | m := elem.(MissingInfo) 10 | id := m.Id 11 | path := m.Path 12 | 13 | var info Info 14 | tags, err := source.decoder.DecodeInfo(path, &info) 15 | if err != nil { 16 | fmt.Println("Unable to load image info meta", err, path) 17 | continue 18 | } 19 | source.database.Write(path, info, UpdateMeta) 20 | if source.Config.TagConfig.Exif.Enable { 21 | source.database.WriteTags(id, tags) 22 | } 23 | source.imageInfoCache.Delete(id) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /internal/image/range.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import "photofield/rangetree" 4 | 5 | type Ids = *rangetree.Tree 6 | type IdRange = rangetree.Range 7 | 8 | func NewIds() Ids { 9 | return rangetree.New() 10 | } 11 | 12 | // Returns a range from low to high (inclusive) 13 | func IdFromTo(low, high int) IdRange { 14 | return rangetree.FromTo(low, high) 15 | } 16 | -------------------------------------------------------------------------------- /internal/image/sourceInfo.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | func (source *Source) heuristicFromPath(path string) (Info, error) { 13 | var info Info 14 | 15 | info.Width = 4000 16 | info.Height = 3000 17 | info.Color = 0xFFE8EAED 18 | 19 | baseName := filepath.Base(path) 20 | name := strings.TrimSuffix(baseName, filepath.Ext(baseName)) 21 | 22 | for _, format := range source.DateFormats { 23 | date, err := time.Parse(format, name) 24 | if err == nil { 25 | info.DateTime = date 26 | break 27 | } 28 | } 29 | 30 | if info.DateTime.IsZero() { 31 | fileInfo, err := os.Stat(path) 32 | if err == nil { 33 | info.DateTime = fileInfo.ModTime() 34 | } 35 | } 36 | 37 | return info, nil 38 | } 39 | 40 | func (source *Source) GetInfo(id ImageId) Info { 41 | var info Info 42 | var found bool 43 | 44 | logging := false 45 | 46 | totalStartTime := time.Now() 47 | 48 | startTime := time.Now() 49 | info, found = source.imageInfoCache.Get(id) 50 | cacheGetMs := time.Since(startTime).Milliseconds() 51 | if found { 52 | // if (logging) log.Printf("image info %5d ms get cache\n", cacheGetMs) 53 | return info 54 | } 55 | 56 | startTime = time.Now() 57 | result, found := source.database.Get(id) 58 | info = result.Info 59 | dbGetMs := time.Since(startTime).Milliseconds() 60 | needsMeta := result.NeedsMeta() 61 | if found && !needsMeta { 62 | startTime = time.Now() 63 | source.imageInfoCache.Set(id, info) 64 | cacheSetMs := time.Since(startTime).Milliseconds() 65 | if logging { 66 | log.Printf("image info %5d ms get cache, %5d ms get db, %5d ms set cache\n", cacheGetMs, dbGetMs, cacheSetMs) 67 | } 68 | } 69 | 70 | if found && !needsMeta { 71 | return info 72 | } 73 | 74 | startTime = time.Now() 75 | { 76 | path, err := source.GetImagePath(id) 77 | if err == nil { 78 | info, err = source.heuristicFromPath(path) 79 | if err != nil { 80 | fmt.Println("Unable to load image info heuristic", err, path) 81 | } 82 | } else { 83 | fmt.Println("Unable to get path from image id", err, id) 84 | } 85 | } 86 | heuristicGetMs := time.Since(startTime).Milliseconds() 87 | 88 | startTime = time.Now() 89 | source.imageInfoCache.Set(id, info) 90 | cacheSetMs := time.Since(startTime).Milliseconds() 91 | 92 | totalMs := time.Since(totalStartTime).Milliseconds() 93 | 94 | logging = totalMs > 1000 95 | 96 | if logging { 97 | log.Printf("image info %5d ms get cache, %5d ms get db, %5d ms get heuristic, %5d ms set cache\n", cacheGetMs, dbGetMs, heuristicGetMs, cacheSetMs) 98 | } 99 | return info 100 | } 101 | -------------------------------------------------------------------------------- /internal/layout/dag/dag.go: -------------------------------------------------------------------------------- 1 | package dag 2 | 3 | import ( 4 | "photofield/internal/image" 5 | ) 6 | 7 | type Id = image.ImageId 8 | type Index = int 9 | 10 | type Photo struct { 11 | Id Id 12 | AspectRatio float32 13 | Aux bool 14 | } 15 | 16 | type Aux struct { 17 | Text string 18 | } 19 | 20 | type Node struct { 21 | ShortestParent Index 22 | Cost float32 23 | TotalAspect float32 24 | } 25 | -------------------------------------------------------------------------------- /internal/layout/search.go: -------------------------------------------------------------------------------- 1 | package layout 2 | 3 | import ( 4 | "log" 5 | "math" 6 | "photofield/internal/image" 7 | "photofield/internal/metrics" 8 | "photofield/internal/render" 9 | 10 | "time" 11 | ) 12 | 13 | func LayoutSearch(infos <-chan image.SimilarityInfo, layout Layout, scene *render.Scene, source *image.Source) { 14 | 15 | layout.ImageSpacing = 0.02 * layout.ImageHeight 16 | layout.LineSpacing = 0.02 * layout.ImageHeight 17 | 18 | sceneMargin := 10. 19 | falloff := 5. 20 | 21 | scene.Bounds.W = layout.ViewportWidth 22 | 23 | rect := render.Rect{ 24 | X: sceneMargin, 25 | Y: sceneMargin + 60, 26 | W: scene.Bounds.W - sceneMargin*2, 27 | H: 0, 28 | } 29 | 30 | scene.Solids = make([]render.Solid, 0) 31 | scene.Texts = make([]render.Text, 0) 32 | 33 | layoutDone := metrics.Elapsed("layout placing") 34 | layoutCounter := metrics.Counter{ 35 | Name: "layout", 36 | Interval: 1 * time.Second, 37 | } 38 | 39 | scene.Photos = scene.Photos[:0] 40 | rowIndex := 0 41 | index := 0 42 | lastLogTime := time.Now() 43 | mostSimilar := float32(0) 44 | imageHeight := layout.ImageHeight 45 | 46 | for info := range infos { 47 | photo := render.Photo{ 48 | Id: info.Id, 49 | Sprite: render.Sprite{}, 50 | } 51 | 52 | if index == 0 { 53 | mostSimilar = info.Similarity 54 | } 55 | 56 | aspectRatio := info.AspectRatio() 57 | imageWidth := float64(imageHeight) * aspectRatio 58 | 59 | if rect.X+imageWidth > rect.W { 60 | scale := layoutFitRow(scene.Photos[rowIndex:], rect, layout.ImageSpacing) 61 | rowIndex = len(scene.Photos) 62 | rect.X = sceneMargin 63 | rect.Y += imageHeight*scale + layout.LineSpacing 64 | 65 | nsim := info.Similarity / mostSimilar 66 | pnsim := math.Pow(float64(nsim), falloff) 67 | imageHeight = layout.ImageHeight * pnsim 68 | imageWidth = float64(imageHeight) * aspectRatio 69 | } 70 | 71 | photo.Sprite.PlaceFitHeight( 72 | rect.X, 73 | rect.Y, 74 | imageHeight, 75 | float64(info.Width), 76 | float64(info.Height), 77 | ) 78 | 79 | scene.Photos = append(scene.Photos, photo) 80 | 81 | rect.X += imageWidth + layout.ImageSpacing 82 | 83 | now := time.Now() 84 | if now.Sub(lastLogTime) > 1*time.Second { 85 | lastLogTime = now 86 | log.Printf("layout %d\n", index) 87 | } 88 | 89 | layoutCounter.Set(index) 90 | index++ 91 | scene.FileCount = index 92 | } 93 | layoutDone() 94 | 95 | rect.X = sceneMargin 96 | rect.Y += layout.ImageHeight + layout.LineSpacing 97 | 98 | scene.Bounds.H = rect.Y + sceneMargin 99 | scene.RegionSource = PhotoRegionSource{ 100 | Source: source, 101 | } 102 | 103 | } 104 | -------------------------------------------------------------------------------- /internal/layout/square.go: -------------------------------------------------------------------------------- 1 | package layout 2 | 3 | import ( 4 | "log" 5 | "math" 6 | "photofield/internal/image" 7 | "photofield/internal/render" 8 | "time" 9 | ) 10 | 11 | func LayoutSquare(scene *render.Scene, source *image.Source) { 12 | 13 | // imageWidth := 120. 14 | photoCount := len(scene.Photos) 15 | 16 | imageWidth := 100. 17 | imageHeight := imageWidth * 2 / 3 18 | 19 | edgeCount := int(math.Sqrt(float64(photoCount))) 20 | 21 | margin := 1. 22 | 23 | cols := edgeCount 24 | rows := int(math.Ceil(float64(photoCount) / float64(cols))) 25 | 26 | scene.Bounds = render.Rect{ 27 | X: 0, 28 | Y: 0, 29 | W: float64(cols+2) * (imageWidth + margin), 30 | H: math.Ceil(float64(rows+2)) * (imageHeight + margin), 31 | } 32 | 33 | // cols := int(scene.size.width/(imageWidth+margin)) - 2 34 | 35 | log.Println("layout") 36 | lastLogTime := time.Now() 37 | for i := range scene.Photos { 38 | photo := &scene.Photos[i] 39 | col := i % cols 40 | row := i / cols 41 | photo.Place((imageWidth+margin)*float64(1+col), (imageHeight+margin)*float64(1+row), imageWidth, imageHeight, source) 42 | now := time.Now() 43 | if now.Sub(lastLogTime) > 1*time.Second { 44 | lastLogTime = now 45 | log.Printf("layout %d / %d\n", i, photoCount) 46 | } 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /internal/layout/strip.go: -------------------------------------------------------------------------------- 1 | package layout 2 | 3 | import ( 4 | // . "photofield/internal" 5 | 6 | "log" 7 | "photofield/internal/image" 8 | "photofield/internal/metrics" 9 | "photofield/internal/render" 10 | 11 | "time" 12 | ) 13 | 14 | func LayoutStrip(infos <-chan image.SourcedInfo, layout Layout, scene *render.Scene, source *image.Source) { 15 | 16 | layout.ImageSpacing = 0.02 * layout.ViewportWidth 17 | 18 | rect := render.Rect{ 19 | X: 0, 20 | Y: 0, 21 | W: layout.ViewportWidth, 22 | H: layout.ViewportHeight, 23 | } 24 | 25 | scene.Bounds.H = float64(rect.H) 26 | 27 | scene.Solids = make([]render.Solid, 0) 28 | scene.Texts = make([]render.Text, 0) 29 | 30 | layoutPlaced := metrics.Elapsed("layout placing") 31 | layoutCounter := metrics.Counter{ 32 | Name: "layout", 33 | Interval: 1 * time.Second, 34 | } 35 | 36 | lastLogTime := time.Now() 37 | 38 | scene.Photos = scene.Photos[:0] 39 | index := 0 40 | for info := range infos { 41 | imageRect := render.Rect{ 42 | X: 0, 43 | Y: 0, 44 | W: float64(info.Width), 45 | H: float64(info.Height), 46 | } 47 | 48 | scene.Photos = append(scene.Photos, render.Photo{ 49 | Id: info.Id, 50 | Sprite: render.Sprite{ 51 | Rect: imageRect.FitInside(rect), 52 | }, 53 | }) 54 | 55 | rect.X += float64(rect.W) + layout.ImageSpacing 56 | 57 | now := time.Now() 58 | if now.Sub(lastLogTime) > 1*time.Second { 59 | lastLogTime = now 60 | log.Printf("layout strip %d\n", index) 61 | } 62 | 63 | layoutCounter.Set(index) 64 | index++ 65 | scene.FileCount = index 66 | } 67 | layoutPlaced() 68 | 69 | scene.Bounds.W = rect.X 70 | 71 | scene.RegionSource = PhotoRegionSource{ 72 | Source: source, 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /internal/layout/wall.go: -------------------------------------------------------------------------------- 1 | package layout 2 | 3 | import ( 4 | "log" 5 | "math" 6 | "photofield/internal/image" 7 | "photofield/internal/metrics" 8 | "photofield/internal/render" 9 | "time" 10 | ) 11 | 12 | func LayoutWall(infos <-chan image.SourcedInfo, layout Layout, scene *render.Scene, source *image.Source) { 13 | 14 | section := Section{} 15 | 16 | loadCounter := metrics.Counter{ 17 | Name: "load infos", 18 | Interval: 1 * time.Second, 19 | } 20 | 21 | index := 0 22 | for info := range infos { 23 | section.infos = append(section.infos, info) 24 | loadCounter.Set(index) 25 | index++ 26 | } 27 | 28 | photoCount := len(section.infos) 29 | 30 | edgeCount := int(math.Sqrt(float64(photoCount))) 31 | if edgeCount < 1 { 32 | edgeCount = 1 33 | } 34 | 35 | sceneMargin := 10. 36 | scene.Bounds.W = layout.ViewportWidth 37 | cols := edgeCount 38 | 39 | bounds := render.Rect{ 40 | X: sceneMargin, 41 | Y: sceneMargin + 64, 42 | W: scene.Bounds.W - sceneMargin*2, 43 | H: scene.Bounds.H - sceneMargin*2, 44 | } 45 | 46 | layoutConfig := Layout{} 47 | layoutConfig.ImageSpacing = bounds.W / float64(cols) * 0.02 48 | layoutConfig.LineSpacing = layoutConfig.ImageSpacing 49 | imageWidth := bounds.W / float64(cols) 50 | 51 | log.Printf("layout wall width %v cols %v\n", scene.Bounds.W, cols) 52 | 53 | imageHeight := imageWidth * 2 / 3 * 1.2 54 | 55 | log.Printf("layout wall image %f %f\n", imageWidth, imageHeight) 56 | 57 | layoutConfig.ImageHeight = imageHeight 58 | 59 | layoutFinished := metrics.Elapsed("layout") 60 | newBounds := addSectionToScene(§ion, scene, bounds, layoutConfig, source) 61 | layoutFinished() 62 | 63 | scene.Bounds.H = newBounds.Y + newBounds.H + sceneMargin 64 | } 65 | -------------------------------------------------------------------------------- /internal/metrics/timing.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "log" 5 | "time" 6 | ) 7 | 8 | func Elapsed(name string) func() { 9 | start := time.Now() 10 | return func() { 11 | log.Printf("%-20s %5d ms\n", name, time.Since(start).Milliseconds()) 12 | } 13 | } 14 | 15 | func ElapsedWithCount(name string, count int) func() { 16 | start := time.Now() 17 | return func() { 18 | millis := time.Since(start).Milliseconds() 19 | log.Printf("%-20s %5d ms all, %.2f ms / photo\n", name, millis, float64(millis)/float64(count)) 20 | } 21 | } 22 | 23 | type Counter struct { 24 | Name string 25 | Interval time.Duration 26 | lastTime time.Time 27 | lastValue int 28 | } 29 | 30 | func (counter *Counter) Set(value int) { 31 | now := time.Now() 32 | elapsed := now.Sub(counter.lastTime) 33 | if elapsed >= counter.Interval { 34 | speed := float64(value-counter.lastValue) / elapsed.Seconds() 35 | if !counter.lastTime.IsZero() { 36 | log.Printf("%v %7v, %0.2f / sec\n", counter.Name, value, speed) 37 | } 38 | counter.lastTime = now 39 | counter.lastValue = value 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /internal/queue/queue.go: -------------------------------------------------------------------------------- 1 | package queue 2 | 3 | import ( 4 | "log" 5 | "photofield/internal/metrics" 6 | "time" 7 | 8 | "github.com/sheerun/queue" 9 | ) 10 | 11 | type Queue struct { 12 | queue *queue.Queue 13 | ID string 14 | Name string 15 | Worker func(<-chan interface{}) 16 | WorkerCount int 17 | Stop chan bool 18 | } 19 | 20 | func (q *Queue) Run() { 21 | if q.queue == nil { 22 | q.queue = queue.New() 23 | q.Stop = make(chan bool) 24 | } 25 | 26 | loadCount := 0 27 | lastLoadCount := 0 28 | lastLogTime := time.Now() 29 | logInterval := 2 * time.Second 30 | m := metrics.AddQueue(q.ID, q.queue) 31 | 32 | logging := false 33 | 34 | items := make(chan interface{}) 35 | defer close(items) 36 | 37 | if q.WorkerCount == 0 { 38 | q.WorkerCount = 1 39 | } 40 | for i := 0; i < q.WorkerCount; i++ { 41 | if q.Worker != nil { 42 | go q.Worker(items) 43 | } 44 | } 45 | 46 | for { 47 | if q.Worker != nil { 48 | item := q.queue.Pop() 49 | if item == nil { 50 | log.Printf("%s queue stopping\n", q.Name) 51 | close(q.Stop) 52 | return 53 | } 54 | items <- item 55 | } 56 | m.Done.Inc() 57 | 58 | now := time.Now() 59 | elapsed := now.Sub(lastLogTime) 60 | if elapsed > logInterval || q.queue.Length() == 0 { 61 | perSec := float64(loadCount-lastLoadCount) / elapsed.Seconds() 62 | pendingCount := q.queue.Length() 63 | percent := 100 64 | if loadCount+pendingCount > 0 { 65 | percent = loadCount * 100 / (loadCount + pendingCount) 66 | } 67 | // log.Printf("%s %4d%% completed, %5d loaded, %5d pending, %.2f / sec\n", q.Name, percent, loadCount, pendingCount, perSec) 68 | perSecDiv := 1 69 | if perSec > 1 { 70 | perSecDiv = int(perSec) 71 | } 72 | timeLeft := time.Duration(pendingCount/perSecDiv) * time.Second 73 | log.Printf("%s %4d%% completed, %5d loaded, %5d pending, %.2f / sec, %s left\n", q.Name, percent, loadCount, pendingCount, perSec, timeLeft) 74 | lastLoadCount = loadCount 75 | lastLogTime = now 76 | } 77 | 78 | loadCount++ 79 | 80 | if logging { 81 | // log.Printf("image info load for id %5d, %5d pending, %5d ms get file, %5d ms set db, %5d ms set cache\n", id, len(backlog), fileGetMs, dbSetMs, cacheSetMs) 82 | log.Printf("%s queue %5d pending\n", q.Name, q.queue.Length()) 83 | } 84 | } 85 | } 86 | 87 | func (q *Queue) Close() { 88 | if q == nil || q.queue == nil { 89 | return 90 | } 91 | q.queue.Clean() 92 | q.queue.Append(nil) 93 | <-q.Stop 94 | q.queue = nil 95 | } 96 | 97 | func (q *Queue) Length() int { 98 | if q.queue == nil { 99 | return 0 100 | } 101 | return q.queue.Length() 102 | } 103 | 104 | func (q *Queue) AppendItems(items <-chan interface{}) { 105 | if q.queue == nil { 106 | return 107 | } 108 | for item := range items { 109 | q.queue.Append(item) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /internal/render/solid.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "image/color" 5 | 6 | "github.com/tdewolff/canvas" 7 | ) 8 | 9 | type Solid struct { 10 | Sprite Sprite 11 | Color color.Color 12 | } 13 | 14 | func NewSolidFromRect(rect Rect, color color.Color) Solid { 15 | solid := Solid{} 16 | solid.Color = color 17 | solid.Sprite.Rect = rect 18 | return solid 19 | } 20 | 21 | func (solid *Solid) Draw(c *canvas.Context, scales Scales) { 22 | if solid.Sprite.IsVisible(c, scales) { 23 | prevFill := c.FillColor 24 | c.SetFillColor(solid.Color) 25 | solid.Sprite.Draw(c) 26 | c.SetFillColor(prevFill) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /internal/render/sprite.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "math" 5 | 6 | "github.com/tdewolff/canvas" 7 | ) 8 | 9 | type Sprite struct { 10 | Rect Rect 11 | } 12 | 13 | func (sprite *Sprite) PlaceFitHeight( 14 | x float64, 15 | y float64, 16 | fitHeight float64, 17 | contentWidth float64, 18 | contentHeight float64, 19 | ) { 20 | scale := fitHeight / contentHeight 21 | if math.IsNaN(scale) || math.IsInf(scale, 0) { 22 | scale = 1 23 | } 24 | 25 | sprite.Rect = Rect{ 26 | X: x, 27 | Y: y, 28 | W: contentWidth * scale, 29 | H: contentHeight * scale, 30 | } 31 | } 32 | 33 | func (sprite *Sprite) PlaceFitWidth( 34 | x float64, 35 | y float64, 36 | fitWidth float64, 37 | contentWidth float64, 38 | contentHeight float64, 39 | ) { 40 | scale := fitWidth / contentWidth 41 | if math.IsNaN(scale) || math.IsInf(scale, 0) { 42 | scale = 1 43 | } 44 | 45 | sprite.Rect = Rect{ 46 | X: x, 47 | Y: y, 48 | W: contentWidth * scale, 49 | H: contentHeight * scale, 50 | } 51 | } 52 | 53 | func (sprite *Sprite) PlaceFit( 54 | x float64, 55 | y float64, 56 | fitWidth float64, 57 | fitHeight float64, 58 | contentWidth float64, 59 | contentHeight float64, 60 | ) { 61 | imageRatio := contentWidth / contentHeight 62 | 63 | var scale float64 64 | if fitWidth/fitHeight < imageRatio { 65 | scale = fitWidth / contentWidth 66 | // y = y - fitHeight*0.5 + scale*contentHeight*0.5 67 | } else { 68 | scale = fitHeight / contentHeight 69 | // x = x - width*0.5 + scale*contentWidth*0.5 70 | } 71 | 72 | sprite.Rect = Rect{ 73 | X: x, 74 | Y: y, 75 | W: contentWidth * scale, 76 | H: contentHeight * scale, 77 | } 78 | } 79 | 80 | func (sprite *Sprite) Draw(c *canvas.Context) { 81 | c.RenderPath( 82 | canvas.Rectangle(sprite.Rect.W, sprite.Rect.H), 83 | c.Style, 84 | c.View().Mul(sprite.Rect.GetMatrix()), 85 | ) 86 | } 87 | 88 | func (sprite *Sprite) DrawWithStyle(c *canvas.Context, style canvas.Style) { 89 | c.RenderPath( 90 | canvas.Rectangle(sprite.Rect.W, sprite.Rect.H), 91 | style, 92 | c.View().Mul(sprite.Rect.GetMatrix()), 93 | ) 94 | } 95 | 96 | func (sprite *Sprite) DrawInsetWithStyle(c *canvas.Context, style canvas.Style, width float64) { 97 | c.RenderPath( 98 | canvas.Rectangle(sprite.Rect.W-width*2, sprite.Rect.H-width*2), 99 | style, 100 | c.View().Mul(sprite.Rect.GetMatrix().Translate(width, width)), 101 | ) 102 | } 103 | 104 | func (sprite *Sprite) DrawText(config *Render, c *canvas.Context, scales Scales, font *canvas.FontFace, txt string) { 105 | text := NewTextFromRect(sprite.Rect, font, txt) 106 | text.Draw(config, c, scales) 107 | } 108 | 109 | func (sprite *Sprite) IsVisible(c *canvas.Context, scales Scales) bool { 110 | rect := canvas.Rect{X: 0, Y: 0, W: sprite.Rect.W, H: sprite.Rect.H} 111 | canvasToUnit := canvas.Identity. 112 | Scale(scales.Tile, scales.Tile). 113 | Mul(c.View().Mul(sprite.Rect.GetMatrix())) 114 | unitRect := rect.Transform(canvasToUnit) 115 | return unitRect.X <= 1 && unitRect.Y <= 1 && unitRect.X+unitRect.W >= 0 && unitRect.Y+unitRect.H >= 0 116 | } 117 | -------------------------------------------------------------------------------- /internal/render/text.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "image/color" 5 | "photofield/internal/image" 6 | 7 | "github.com/tdewolff/canvas" 8 | ) 9 | 10 | type Text struct { 11 | Sprite Sprite 12 | Font *canvas.FontFace 13 | Text string 14 | HAlign canvas.TextAlign 15 | VAlign canvas.TextAlign 16 | } 17 | 18 | func NewTextFromRect(rect Rect, font *canvas.FontFace, txt string) Text { 19 | text := Text{} 20 | text.Text = txt 21 | text.Font = font 22 | text.Sprite.Rect = rect 23 | return text 24 | } 25 | 26 | func (text *Text) Draw(config *Render, c *canvas.Context, scales Scales) { 27 | if text.Sprite.IsVisible(c, scales) { 28 | pixelArea := text.Sprite.Rect.GetPixelArea(c, image.Size{X: 1, Y: 1}) 29 | if pixelArea < config.MaxSolidPixelArea { 30 | // Skip rendering small text 31 | return 32 | } 33 | 34 | face := *text.Font 35 | face.Color = config.Color.(color.RGBA) 36 | 37 | textLine := canvas.NewTextBox(face, text.Text, text.Sprite.Rect.W, text.Sprite.Rect.H, text.HAlign, text.VAlign, 0, 0) 38 | rect := text.Sprite.Rect 39 | rect.Y -= rect.H 40 | c.RenderText(textLine, c.View().Mul(rect.GetMatrix())) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /io/bench/bench.go: -------------------------------------------------------------------------------- 1 | package bench 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "math/rand" 8 | "photofield/io" 9 | "testing" 10 | ) 11 | 12 | type Sample struct { 13 | Id io.ImageId 14 | Path string 15 | Size io.Size 16 | } 17 | 18 | type sourceWithSamples struct { 19 | source io.Source 20 | samples []Sample 21 | } 22 | 23 | func BenchmarkSources(seed int64, sources io.Sources, samples []Sample, count int) { 24 | log.Printf("benchmark build samples") 25 | workingSources := make([]sourceWithSamples, 0, len(sources)) 26 | for _, source := range sources { 27 | workingSources = append(workingSources, sourceWithSamples{ 28 | source, 29 | workingSamples(source, samples), 30 | }) 31 | } 32 | log.Printf("benchmark run") 33 | maxLen := 20 34 | for i := 0; i < count; i++ { 35 | for _, s := range workingSources { 36 | if len(s.samples) == 0 { 37 | fmt.Printf("# BenchmarkSourceGet/%-*s\t%s\n", maxLen, s.source.Name(), "no samples") 38 | continue 39 | } 40 | r := testing.Benchmark(func(b *testing.B) { 41 | BenchmarkSource(b, seed, s.source, s.samples) 42 | }) 43 | if r.N == 0 { 44 | fmt.Printf("# BenchmarkSourceGet/%-*s\t%s\n", maxLen, s.source.Name(), "error") 45 | continue 46 | } 47 | fmt.Printf("BenchmarkSourceGet/%-*s\t%s\t%s\n", maxLen, s.source.Name(), r.String(), r.MemString()) 48 | } 49 | } 50 | } 51 | 52 | func workingSamples(source io.Source, samples []Sample) []Sample { 53 | working := make([]Sample, 0, len(samples)) 54 | for _, sample := range samples { 55 | if source.Exists(context.Background(), sample.Id, sample.Path) { 56 | working = append(working, sample) 57 | } 58 | } 59 | return working 60 | } 61 | 62 | func BenchmarkSource(b *testing.B, seed int64, source io.Source, samples []Sample) { 63 | b.StopTimer() 64 | ctx := context.Background() 65 | b.ReportMetric(float64(len(samples)), "samples") 66 | 67 | rnd := rand.New(rand.NewSource(seed)) 68 | 69 | for i := 0; i < b.N; i++ { 70 | sample := samples[rnd.Intn(len(samples))] 71 | resized := source.Size(sample.Size) 72 | b.StartTimer() 73 | r := source.Get(ctx, sample.Id, sample.Path) 74 | b.StopTimer() 75 | if r.Error != nil { 76 | b.Fatal(r.Error) 77 | } 78 | ns := float64(b.Elapsed().Nanoseconds()) 79 | origmp := float64(sample.Size.Area()) / 1e6 80 | b.ReportMetric(ns/origmp/float64(b.N), "ns/origmp/op") 81 | resmp := float64(resized.Area()) / 1e6 82 | b.ReportMetric(ns/resmp/float64(b.N), "ns/resmp/op") 83 | gotsize := io.Size{X: r.Image.Bounds().Dx(), Y: r.Image.Bounds().Dy()} 84 | gotmp := float64(gotsize.Area()) / 1e6 85 | b.ReportMetric(ns/gotmp/float64(b.N), "ns/gotmp/op") 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /io/cached/cached.go: -------------------------------------------------------------------------------- 1 | package cached 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "photofield/io" 8 | "photofield/io/ristretto" 9 | "time" 10 | 11 | goio "io" 12 | 13 | "golang.org/x/sync/singleflight" 14 | ) 15 | 16 | type Cached struct { 17 | Source io.Source 18 | Cache ristretto.Ristretto 19 | loading singleflight.Group 20 | } 21 | 22 | func (c *Cached) Close() error { 23 | return errors.Join( 24 | c.Source.Close(), 25 | c.Cache.Close(), 26 | ) 27 | } 28 | 29 | func (c *Cached) Name() string { 30 | return c.Source.Name() 31 | } 32 | 33 | func (c *Cached) DisplayName() string { 34 | return c.Source.DisplayName() 35 | } 36 | 37 | func (c *Cached) Ext() string { 38 | return c.Source.Ext() 39 | } 40 | 41 | func (c *Cached) Size(size io.Size) io.Size { 42 | return c.Source.Size(size) 43 | } 44 | 45 | func (c *Cached) GetDurationEstimate(size io.Size) time.Duration { 46 | return c.Source.GetDurationEstimate(size) 47 | } 48 | 49 | func (c *Cached) Rotate() bool { 50 | return false 51 | } 52 | 53 | func (c *Cached) Exists(ctx context.Context, id io.ImageId, path string) bool { 54 | return c.Source.Exists(ctx, id, path) 55 | } 56 | 57 | func (c *Cached) Get(ctx context.Context, id io.ImageId, path string) io.Result { 58 | r := c.Cache.GetWithName(ctx, id, c.Source.Name()) 59 | // fmt.Printf("%v %v %v\n", id, c.Source.Name(), r.Error) 60 | if r.Image != nil || r.Error != nil { 61 | // fmt.Printf("%v %v cache found\n", id, c.Source.Name()) 62 | // println("found in cache") 63 | r.FromCache = true 64 | return r 65 | } 66 | // r = c.Source.Get(ctx, id, path) 67 | r = c.load(ctx, id, path) 68 | // fmt.Printf("%v %v cache load end\n", id, c.Source.Name()) 69 | // c.Ristretto.SetWithName(ctx, id, c.Source.Name(), r) 70 | // fmt.Printf("%v cache set\n", id) 71 | // println("saved to cache", s) 72 | return r 73 | } 74 | 75 | func (c *Cached) Reader(ctx context.Context, id io.ImageId, path string, fn func(r goio.ReadSeeker, err error)) { 76 | r, ok := c.Source.(io.Reader) 77 | if !ok { 78 | fn(nil, fmt.Errorf("reader not supported by %s", c.Source.Name())) 79 | return 80 | } 81 | r.Reader(ctx, id, path, fn) 82 | } 83 | 84 | func (c *Cached) load(ctx context.Context, id io.ImageId, path string) io.Result { 85 | key := fmt.Sprintf("%d", id) 86 | // fmt.Printf("%v cache load begin %v\n", id, key) 87 | ri, _, _ := c.loading.Do(key, func() (interface{}, error) { 88 | // fmt.Printf("%p %v %s %v cache get begin\n", c, c.Source, c.Source.Name(), id) 89 | r := c.Source.Get(ctx, id, path) 90 | // fmt.Printf("%p %v %s %v cache get end\n", c, c.Source, c.Source.Name(), id) 91 | c.Cache.SetWithName(ctx, id, c.Source.Name(), r) 92 | // fmt.Printf("%v cache set\n", id) 93 | return r, nil 94 | }) 95 | // fmt.Printf("%v cache load end %v\n", id, key) 96 | return ri.(io.Result) 97 | } 98 | 99 | func (c *Cached) Set(ctx context.Context, id io.ImageId, path string, r io.Result) bool { 100 | return false 101 | } 102 | -------------------------------------------------------------------------------- /io/configured/configured.go: -------------------------------------------------------------------------------- 1 | package configured 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "photofield/io" 7 | "runtime/trace" 8 | "time" 9 | 10 | goio "io" 11 | 12 | "github.com/goccy/go-yaml" 13 | ) 14 | 15 | type Cost struct { 16 | Time Duration `json:"time"` 17 | TimePerOriginalMegapixel Duration `json:"time_per_original_megapixel"` 18 | TimePerResizedMegapixel Duration `json:"time_per_resized_megapixel"` 19 | } 20 | 21 | type Duration time.Duration 22 | 23 | func (d *Duration) UnmarshalYAML(b []byte) error { 24 | var s string 25 | if err := yaml.Unmarshal(b, &s); err != nil { 26 | return err 27 | } 28 | dur, err := time.ParseDuration(s) 29 | if err != nil { 30 | return err 31 | } 32 | *d = Duration(dur) 33 | return nil 34 | } 35 | 36 | func (d Duration) MarshalYAML() (interface{}, error) { 37 | return d.String(), nil 38 | } 39 | 40 | func (d Duration) String() string { 41 | return time.Duration(d).String() 42 | } 43 | 44 | type Configured struct { 45 | NameStr string 46 | Cost Cost 47 | Source io.Source 48 | } 49 | 50 | func New(name string, cost Cost, source io.Source) *Configured { 51 | c := Configured{ 52 | NameStr: name, 53 | Cost: cost, 54 | Source: source, 55 | } 56 | if c.NameStr == "" { 57 | c.NameStr = c.Source.Name() 58 | } 59 | return &c 60 | } 61 | 62 | func (c *Configured) Close() error { 63 | return c.Source.Close() 64 | } 65 | 66 | func (c *Configured) Name() string { 67 | return c.NameStr 68 | } 69 | 70 | func (c *Configured) DisplayName() string { 71 | return c.Source.DisplayName() 72 | } 73 | 74 | func (c *Configured) Ext() string { 75 | return c.Source.Ext() 76 | } 77 | 78 | func (c *Configured) Size(size io.Size) io.Size { 79 | return c.Source.Size(size) 80 | } 81 | 82 | func (c *Configured) GetDurationEstimate(original io.Size) time.Duration { 83 | resized := c.Size(original) 84 | t := c.Cost.Time 85 | tomp := c.Cost.TimePerOriginalMegapixel 86 | trmp := c.Cost.TimePerResizedMegapixel 87 | d := Duration(t + (tomp*Duration(original.Area())+trmp*Duration(resized.Area()))/1e6) 88 | return time.Duration(d) 89 | } 90 | 91 | func (c *Configured) Rotate() bool { 92 | return c.Source.Rotate() 93 | } 94 | 95 | func (c *Configured) Exists(ctx context.Context, id io.ImageId, path string) bool { 96 | return c.Source.Exists(ctx, id, path) 97 | } 98 | 99 | func (c *Configured) Get(ctx context.Context, id io.ImageId, path string) io.Result { 100 | defer trace.StartRegion(ctx, "configured.Get").End() 101 | return c.Source.Get(ctx, id, path) 102 | } 103 | 104 | func (c *Configured) Reader(ctx context.Context, id io.ImageId, path string, fn func(r goio.ReadSeeker, err error)) { 105 | r, ok := c.Source.(io.Reader) 106 | if !ok { 107 | fn(nil, fmt.Errorf("reader not supported by %s", c.Source.Name())) 108 | return 109 | } 110 | r.Reader(ctx, id, path, fn) 111 | } 112 | 113 | func (c *Configured) Decode(ctx context.Context, r goio.Reader) io.Result { 114 | d, ok := c.Source.(io.Decoder) 115 | if !ok { 116 | return io.Result{Error: fmt.Errorf("decoder not supported by %s", c.Source.Name())} 117 | } 118 | return d.Decode(ctx, r) 119 | } 120 | -------------------------------------------------------------------------------- /io/exiftool/exiftool.go: -------------------------------------------------------------------------------- 1 | package exiftool 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "image" 8 | "log" 9 | "os/exec" 10 | "photofield/io" 11 | "time" 12 | 13 | "image/jpeg" 14 | 15 | "github.com/mostlygeek/go-exiftool" 16 | ) 17 | 18 | type Exif struct { 19 | Tag string `json:"tag"` 20 | exifTool *exiftool.Pool 21 | } 22 | 23 | // FindPath locates the exiftool binary in the system PATH 24 | func FindPath() string { 25 | path, err := exec.LookPath("exiftool") 26 | if err != nil { 27 | log.Printf("exiftool not found: %s\n", err.Error()) 28 | return "" 29 | } 30 | log.Printf("exiftool found at %s\n", path) 31 | return path 32 | } 33 | 34 | func New(tag string, exifToolPath string) *Exif { 35 | e := Exif{ 36 | Tag: tag, 37 | } 38 | 39 | // Use provided path or fallback to "exiftool" 40 | path := "exiftool" 41 | if exifToolPath != "" { 42 | path = exifToolPath 43 | } 44 | 45 | exifTool, err := exiftool.NewPool( 46 | path, 4, 47 | "-n", // Machine-readable values 48 | "-S", // Short tag names with no padding 49 | ) 50 | e.exifTool = exifTool 51 | if err != nil { 52 | panic(err) 53 | } 54 | return &e 55 | } 56 | 57 | func (e Exif) Close() error { 58 | e.exifTool.Stop() 59 | return nil 60 | } 61 | 62 | func (e Exif) Name() string { 63 | return fmt.Sprintf("exiftool-%s", e.Tag) 64 | } 65 | 66 | func (e Exif) Size(size io.Size) io.Size { 67 | return io.Size{X: 120, Y: 120}.Fit(size, io.FitInside) 68 | } 69 | 70 | func (e Exif) GetDurationEstimate(size io.Size) time.Duration { 71 | return 17 * time.Millisecond 72 | } 73 | 74 | func (e Exif) Get(ctx context.Context, id io.ImageId, path string) (image.Image, error) { 75 | b, err := e.decodeBytes(path) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | r := bytes.NewReader(b) 81 | return jpeg.Decode(r) 82 | } 83 | 84 | func (e Exif) Set(ctx context.Context, id io.ImageId, path string, img image.Image, err error) bool { 85 | return false 86 | } 87 | 88 | func (e Exif) decodeBytes(path string) ([]byte, error) { 89 | return e.exifTool.ExtractFlags(path, "-b", "-"+e.Tag) 90 | } 91 | -------------------------------------------------------------------------------- /io/filtered/filtered.go: -------------------------------------------------------------------------------- 1 | package filtered 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "path/filepath" 7 | "photofield/io" 8 | "runtime/trace" 9 | "strings" 10 | "time" 11 | 12 | goio "io" 13 | ) 14 | 15 | type Filtered struct { 16 | Source io.Source 17 | Extensions []string 18 | } 19 | 20 | func (f *Filtered) Close() error { 21 | return f.Source.Close() 22 | } 23 | 24 | func (f *Filtered) Name() string { 25 | return f.Source.Name() 26 | } 27 | 28 | func (f *Filtered) DisplayName() string { 29 | return f.Source.DisplayName() 30 | } 31 | 32 | func (f *Filtered) Ext() string { 33 | return f.Source.Ext() 34 | } 35 | 36 | func (f *Filtered) Size(size io.Size) io.Size { 37 | return f.Source.Size(size) 38 | } 39 | 40 | func (f *Filtered) GetDurationEstimate(size io.Size) time.Duration { 41 | return f.Source.GetDurationEstimate(size) 42 | } 43 | 44 | func (f *Filtered) Rotate() bool { 45 | return f.Source.Rotate() 46 | } 47 | 48 | func (f *Filtered) SupportsExtension(path string) bool { 49 | if len(f.Extensions) == 0 { 50 | return true 51 | } 52 | ext := strings.ToLower(filepath.Ext(path)) 53 | for _, e := range f.Extensions { 54 | if ext == e { 55 | return true 56 | } 57 | } 58 | return false 59 | } 60 | 61 | func (f *Filtered) Exists(ctx context.Context, id io.ImageId, path string) bool { 62 | if !f.SupportsExtension(path) { 63 | return false 64 | } 65 | return f.Source.Exists(ctx, id, path) 66 | } 67 | 68 | func (f *Filtered) Get(ctx context.Context, id io.ImageId, path string) io.Result { 69 | defer trace.StartRegion(ctx, "filtered.Get").End() 70 | if !f.SupportsExtension(path) { 71 | return io.Result{Error: fmt.Errorf("extension not supported")} 72 | } 73 | return f.Source.Get(ctx, id, path) 74 | } 75 | 76 | func (f *Filtered) Reader(ctx context.Context, id io.ImageId, path string, fn func(r goio.ReadSeeker, err error)) { 77 | if !f.SupportsExtension(path) { 78 | fn(nil, fmt.Errorf("extension not supported")) 79 | return 80 | } 81 | r, ok := f.Source.(io.Reader) 82 | if !ok { 83 | fn(nil, fmt.Errorf("reader not supported by %s", f.Source.Name())) 84 | return 85 | } 86 | r.Reader(ctx, id, path, fn) 87 | } 88 | 89 | func (f *Filtered) Decode(ctx context.Context, r goio.Reader) io.Result { 90 | d, ok := f.Source.(io.Decoder) 91 | if !ok { 92 | return io.Result{Error: fmt.Errorf("decoder not supported by %s", f.Source.Name())} 93 | } 94 | return d.Decode(ctx, r) 95 | } 96 | -------------------------------------------------------------------------------- /io/goimage/goimage.go: -------------------------------------------------------------------------------- 1 | package goimage 2 | 3 | import ( 4 | "context" 5 | "image" 6 | "os" 7 | "photofield/io" 8 | "runtime/trace" 9 | "time" 10 | 11 | goio "io" 12 | 13 | _ "image/jpeg" 14 | _ "image/png" 15 | 16 | "golang.org/x/image/draw" 17 | ) 18 | 19 | type Image struct { 20 | Width int 21 | Height int 22 | Decoder func(goio.Reader) (image.Image, error) 23 | } 24 | 25 | func (o Image) Close() error { 26 | return nil 27 | } 28 | 29 | func (o Image) Name() string { 30 | return "original" 31 | } 32 | 33 | func (o Image) DisplayName() string { 34 | return "Original" 35 | } 36 | 37 | func (o Image) Ext() string { 38 | return "" 39 | } 40 | 41 | func (o Image) Resized() bool { 42 | return o.Width != 0 && o.Height != 0 43 | } 44 | 45 | func (o Image) Size(size io.Size) io.Size { 46 | if o.Resized() { 47 | return io.Size{ 48 | X: o.Width, 49 | Y: o.Height, 50 | } 51 | } 52 | return size 53 | } 54 | 55 | func (o Image) GetDurationEstimate(size io.Size) time.Duration { 56 | return 30 * time.Nanosecond * time.Duration(size.Area()) 57 | } 58 | 59 | func (o Image) Rotate() bool { 60 | return true 61 | } 62 | 63 | func resize(img image.Image, maxWidth, maxHeight int) image.Image { 64 | origW := img.Bounds().Size().X 65 | origH := img.Bounds().Size().Y 66 | aspectRatio := float64(origW) / float64(origH) 67 | 68 | desiredW := maxWidth 69 | desiredH := maxHeight 70 | if float64(desiredW)/float64(desiredH) > aspectRatio { 71 | desiredW = int(float64(desiredH) * aspectRatio) 72 | } else { 73 | desiredH = int(float64(desiredW) / aspectRatio) 74 | } 75 | resized := image.NewRGBA(image.Rect(0, 0, desiredW, desiredH)) 76 | draw.ApproxBiLinear.Scale(resized, resized.Bounds(), img, img.Bounds(), draw.Src, nil) 77 | return resized 78 | } 79 | 80 | func (o Image) Exists(ctx context.Context, id io.ImageId, path string) bool { 81 | return true 82 | } 83 | 84 | func (o Image) Get(ctx context.Context, id io.ImageId, path string) io.Result { 85 | defer trace.StartRegion(ctx, "image.Get").End() 86 | trace.Log(ctx, "path", path) 87 | 88 | f, err := os.Open(path) 89 | if err != nil { 90 | return io.Result{Error: err} 91 | } 92 | defer f.Close() 93 | 94 | var img image.Image 95 | if o.Decoder != nil { 96 | img, err = o.Decoder(f) 97 | } else { 98 | img, _, err = image.Decode(f) 99 | } 100 | 101 | if o.Resized() && err == nil { 102 | img = resize(img, o.Width, o.Height) 103 | } 104 | 105 | return io.Result{ 106 | Image: img, 107 | Error: err, 108 | Orientation: io.SourceInfoOrientation, 109 | } 110 | } 111 | 112 | func (o Image) Reader(ctx context.Context, id io.ImageId, path string, fn func(r goio.ReadSeeker, err error)) { 113 | f, err := os.Open(path) 114 | if err != nil { 115 | fn(nil, err) 116 | return 117 | } 118 | defer f.Close() 119 | 120 | fn(f, nil) 121 | } 122 | 123 | func (o Image) Decode(ctx context.Context, r goio.Reader) io.Result { 124 | img, _, err := image.Decode(r) 125 | if o.Resized() && err == nil { 126 | img = resize(img, o.Width, o.Height) 127 | } 128 | return io.Result{ 129 | Image: img, 130 | Error: err, 131 | Orientation: io.SourceInfoOrientation, 132 | } 133 | } 134 | 135 | func (o Image) Set(ctx context.Context, id io.ImageId, path string, r io.Result) bool { 136 | return false 137 | } 138 | -------------------------------------------------------------------------------- /io/mutex/mutex.go: -------------------------------------------------------------------------------- 1 | package mutex 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "photofield/io" 7 | "sync" 8 | "time" 9 | ) 10 | 11 | type Mutex struct { 12 | Source io.Source 13 | loading sync.Map 14 | } 15 | 16 | func (m Mutex) Close() error { 17 | return m.Source.Close() 18 | } 19 | 20 | type loadingResult struct { 21 | result io.Result 22 | loaded chan struct{} 23 | } 24 | 25 | func (m Mutex) Name() string { 26 | return fmt.Sprintf("%s (mutex)", m.Source.Name()) 27 | } 28 | 29 | func (m Mutex) Size(size io.Size) io.Size { 30 | return m.Source.Size(size) 31 | } 32 | 33 | func (m Mutex) GetDurationEstimate(size io.Size) time.Duration { 34 | return m.Source.GetDurationEstimate(size) 35 | } 36 | 37 | func (m Mutex) Rotate() bool { 38 | return false 39 | } 40 | 41 | func (m Mutex) Get(ctx context.Context, id io.ImageId, path string) io.Result { 42 | loading := &loadingResult{} 43 | loading.loaded = make(chan struct{}) 44 | key := id 45 | stored, loaded := m.loading.LoadOrStore(key, loading) 46 | if loaded { 47 | loading = stored.(*loadingResult) 48 | fmt.Printf("%v blocking on channel\n", key) 49 | <-loading.loaded 50 | fmt.Printf("%v channel unblocked\n", key) 51 | return loading.result 52 | } 53 | 54 | fmt.Printf("%v not found, loading, mutex locked\n", key) 55 | loading.result = m.Source.Get(ctx, id, path) 56 | fmt.Printf("%v loaded, closing channel\n", key) 57 | close(loading.loaded) 58 | return loading.result 59 | } 60 | 61 | func (m Mutex) Set(ctx context.Context, id io.ImageId, path string, r io.Result) bool { 62 | return false 63 | } 64 | -------------------------------------------------------------------------------- /io/sqlite/sqlite_test.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "os" 7 | "path" 8 | "photofield/io" 9 | "testing" 10 | ) 11 | 12 | var dir = "../../../photos/" 13 | 14 | func TestRoundtrip(t *testing.T) { 15 | p := path.Join(dir, "test/P1110220-ffmpeg-256-cjpeg-70.jpg") 16 | bytes, err := os.ReadFile(p) 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | s := New(path.Join(dir, "test/photofield.thumbs.db"), embed.FS{}) 22 | 23 | id := uint32(1) 24 | 25 | s.Write(id, bytes) 26 | r := s.Get(context.Background(), io.ImageId(id), p) 27 | if r.Error != nil { 28 | t.Fatal(r.Error) 29 | } 30 | b := r.Image.Bounds() 31 | if b.Dx() != 256 || b.Dy() != 171 { 32 | t.Errorf("unexpected size %d x %d", b.Dx(), b.Dy()) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | # Superseded by Taskfile.yml 2 | 3 | set dotenv-load := true 4 | 5 | default: 6 | @just --list --list-heading $'photofield\n' 7 | 8 | build *args: 9 | go build {{args}} 10 | 11 | build-ui: 12 | cd ui && npm run build 13 | 14 | build-docs: 15 | cd docs && npm run docs:build 16 | 17 | build-local: 18 | goreleaser build --snapshot --single-target --clean 19 | 20 | e2e *args: 21 | cd e2e && npm run watch 22 | 23 | # Download geopackage to be embedded via -tags embedgeo 24 | assets: 25 | mkdir -p data/geo 26 | gpkg_file="$(grep -e '//go:embed data/geo/' embed-geo.go | cut -d / -f 5)" && \ 27 | gpkg_ver="$(grep -e '// tinygpkg-data release:' embed-geo.go | cut -d ' ' -f 4)" && \ 28 | gpkg_dst="data/geo/$gpkg_file" && \ 29 | echo "Downloading $gpkg_ver/$gpkg_file" && \ 30 | wget -q -O "$gpkg_dst" https://github.com/SmilyOrg/tinygpkg-data/releases/download/$gpkg_ver/$gpkg_file && \ 31 | echo "Downloaded to $gpkg_dst" 32 | 33 | release-local: 34 | goreleaser release --snapshot --clean 35 | 36 | run *args: build 37 | ./photofield {{args}} 38 | 39 | run-embed *args: 40 | go build -tags embedui,embeddocs 41 | PHOTOFIELD_API_PREFIX="/api" ./photofield {{args}} 42 | 43 | run-ui *args: 44 | go build -tags embedui 45 | PHOTOFIELD_API_PREFIX="/api" ./photofield {{args}} 46 | 47 | run-geo *args: 48 | go build -tags embedgeo 49 | ./photofield {{args}} 50 | 51 | bench collection: build 52 | ./photofield -bench -bench.collection {{collection}} -test.benchtime 1s -test.count 6 53 | 54 | ui: 55 | cd ui && npm run dev 56 | 57 | docs: 58 | cd docs && npm run docs:dev 59 | 60 | watch: 61 | watchexec --exts go -r just run 62 | 63 | watch-build: 64 | watchexec --exts go,yaml -r 'just build && echo build successful' 65 | 66 | db-add migration_file_name: 67 | migrate create -ext sql -dir db/migrations -seq {{migration_file_name}} 68 | 69 | db *args: 70 | migrate -database sqlite://data/photofield.cache.db -path db/migrations {{args}} 71 | 72 | dbt-add migration_file_name: 73 | migrate create -ext sql -dir db/migrations-thumbs -seq {{migration_file_name}} 74 | 75 | dbt *args: 76 | migrate -database sqlite://data/photofield.thumbs.db -path db/migrations-thumbs {{args}} 77 | 78 | api-codegen: 79 | oapi-codegen -generate="types,chi-server" -package=openapi api.yaml > internal/openapi/api.gen.go 80 | 81 | grafana-export: 82 | @hamara export --host=localhost:9091 --key=$GRAFANA_API_KEY > docker/grafana/provisioning/datasources/default.yaml 83 | 84 | pprof := "http://localhost:8080/debug/pprof" 85 | 86 | prof-cpu seconds="10": 87 | go tool pprof -http=: {{pprof}}/profile?seconds={{seconds}} 88 | 89 | prof-heap: 90 | go tool pprof -http=: {{pprof}}/heap 91 | 92 | prof-reload: 93 | go test -benchmem -benchtime 10s '-run=^$' -bench '^BenchmarkReload$' photofield 94 | 95 | monitor: 96 | docker compose up prometheus grafana pyroscope 97 | 98 | test-reload: 99 | mkdir -p profiles/ 100 | go test -v '-run=^TestReloadLeaks$' photofield 101 | go tool pprof -http ':' -diff_base profiles/reload-before.pprof profiles/reload-after.pprof 102 | -------------------------------------------------------------------------------- /reload_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "runtime" 7 | "runtime/pprof" 8 | "testing" 9 | ) 10 | 11 | func TestReloadLeaks(t *testing.T) { 12 | n := 10 13 | maxObjectsDiff := int64(1000) 14 | dataDir := "./data" 15 | initDefaults() 16 | appConfig, err := loadConfig(dataDir) 17 | if err != nil { 18 | log.Printf("unable to load configuration: %v", err) 19 | return 20 | } 21 | 22 | applyConfig(appConfig) 23 | 24 | var memStats runtime.MemStats 25 | 26 | runtime.GC() 27 | runtime.GC() 28 | runtime.ReadMemStats(&memStats) 29 | initialObjects := memStats.HeapObjects 30 | initialSize := memStats.HeapAlloc 31 | 32 | // Capture heap profile before reloading 33 | beforeProfile, err := os.Create("profiles/reload-before.pprof") 34 | if err != nil { 35 | log.Printf("unable to create heap profile: %v", err) 36 | return 37 | } 38 | pprof.WriteHeapProfile(beforeProfile) 39 | beforeProfile.Close() 40 | 41 | for i := 0; i < n; i++ { 42 | applyConfig(appConfig) 43 | 44 | runtime.GC() 45 | runtime.ReadMemStats(&memStats) 46 | objectsDiff := int64(memStats.HeapObjects) - int64(initialObjects) 47 | sizeDiff := int64(memStats.HeapAlloc) - int64(initialSize) 48 | log.Printf("after %v reloads, %v objects leaked, %.2f per reload, %v bytes leaked, %.2f per reload", i+1, objectsDiff, float64(objectsDiff)/float64(i+1), sizeDiff, float64(sizeDiff)/float64(i+1)) 49 | } 50 | 51 | runtime.ReadMemStats(&memStats) 52 | objectsDiff := int64(memStats.HeapObjects) - int64(initialObjects) 53 | if objectsDiff > maxObjectsDiff { 54 | t.Errorf("after %v reloads, %v objects leaked, %.2f per reload", n, objectsDiff, float64(objectsDiff)/float64(n)) 55 | } 56 | 57 | // Capture heap profile after reloading 58 | afterProfile, err := os.Create("profiles/reload-after.pprof") 59 | if err != nil { 60 | log.Printf("unable to create heap profile: %v", err) 61 | return 62 | } 63 | pprof.WriteHeapProfile(afterProfile) 64 | afterProfile.Close() 65 | 66 | } 67 | 68 | func BenchmarkReload(b *testing.B) { 69 | dataDir := "./data" 70 | initDefaults() 71 | appConfig, err := loadConfig(dataDir) 72 | if err != nil { 73 | log.Printf("unable to load configuration: %v", err) 74 | return 75 | } 76 | 77 | for i := 0; i < b.N; i++ { 78 | applyConfig(appConfig) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /tag/config.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | type Config struct { 4 | Enable bool `json:"enable"` 5 | 6 | // Deprecated: Use Enable instead, this is for backwards compatibility 7 | Enabled bool `json:"enabled"` 8 | 9 | Exif struct { 10 | Enable bool `json:"enable"` 11 | } `json:"exif"` 12 | } 13 | -------------------------------------------------------------------------------- /tag/exif.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "unicode" 7 | 8 | "github.com/gosimple/slug" 9 | ) 10 | 11 | var exifNames = []string{ 12 | "Make", 13 | "Model", 14 | // "ISO", 15 | // "ShutterSpeed", 16 | // "Aperture", 17 | // "ExposureCompensation", 18 | // "FocalLength35efl", 19 | // "FocusMode", 20 | // "WhiteBalance", 21 | // "MeteringMode", 22 | // "SelfTimer", 23 | } 24 | 25 | var exifSlugs []string 26 | var ExifTagToName = map[string]string{} 27 | var ExifFlags []string 28 | 29 | func init() { 30 | for _, name := range exifNames { 31 | slug := pascalCaseToKebabCase(name) 32 | exifSlugs = append(exifSlugs, slug) 33 | ExifTagToName[name] = slug 34 | ExifFlags = append(ExifFlags, fmt.Sprintf("-%s", name)) 35 | } 36 | } 37 | 38 | func pascalCaseToKebabCase(s string) string { 39 | var result []rune 40 | lastUpper := false 41 | lastDigit := false 42 | for i, r := range s { 43 | upper := unicode.IsUpper(r) 44 | digit := unicode.IsDigit(r) 45 | if upper || digit { 46 | if i > 0 && !lastUpper && !lastDigit { 47 | result = append(result, '-') 48 | } 49 | result = append(result, unicode.ToLower(r)) 50 | } else { 51 | result = append(result, r) 52 | } 53 | lastUpper = upper 54 | lastDigit = digit 55 | } 56 | return string(result) 57 | } 58 | 59 | func NewExif(name string, value string) Tag { 60 | var t Tag 61 | v := "" 62 | _, err := strconv.ParseFloat(value, 64) 63 | if err == nil { 64 | v = value 65 | } else { 66 | v = slug.Make(value) 67 | } 68 | t.Name = fmt.Sprintf("exif:%s:%s", name, v) 69 | return t 70 | } 71 | -------------------------------------------------------------------------------- /tag/selection.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | import "fmt" 4 | 5 | func NewSelection(collectionId string) (Tag, error) { 6 | var t Tag 7 | 8 | rand, err := randomId() 9 | if err != nil { 10 | return t, err 11 | } 12 | 13 | t.Name = fmt.Sprintf("sys:select:col:%s:%s", collectionId, rand) 14 | return t, nil 15 | } 16 | -------------------------------------------------------------------------------- /tag/tag.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "hash/crc32" 7 | "time" 8 | 9 | gonanoid "github.com/matoous/go-nanoid/v2" 10 | ) 11 | 12 | type Id uint32 13 | 14 | type Tag struct { 15 | Id Id `json:"id"` 16 | Name string `json:"name"` 17 | UpdatedAt time.Time `json:"updated_at,omitempty"` 18 | FileCount int `json:"file_count"` 19 | } 20 | 21 | func (t Tag) ETag() string { 22 | b, _ := t.UpdatedAt.MarshalBinary() 23 | h := crc32.ChecksumIEEE(b) 24 | return fmt.Sprintf(`%x`, h) 25 | } 26 | 27 | type ExternalTag struct { 28 | Id string `json:"id"` 29 | Name string `json:"name"` 30 | UpdatedAt string `json:"updated_at,omitempty"` 31 | FileCount int `json:"file_count"` 32 | ETag string `json:"etag,omitempty"` 33 | } 34 | 35 | func randomId() (string, error) { 36 | return gonanoid.Generate("6789BCDFGHJKLMNPQRTWbcdfghjkmnpqrtwz", 10) 37 | } 38 | 39 | func (t Tag) MarshalJSON() ([]byte, error) { 40 | return json.Marshal(ExternalTag{ 41 | Id: t.Name, 42 | Name: t.Name, 43 | UpdatedAt: t.UpdatedAt.Format(time.RFC3339), 44 | FileCount: t.FileCount, 45 | ETag: t.ETag(), 46 | }) 47 | } 48 | 49 | func (t *Tag) UnmarshalJSON(data []byte) error { 50 | var externalTag ExternalTag 51 | err := json.Unmarshal(data, &externalTag) 52 | if err != nil { 53 | return err 54 | } 55 | tag := Tag{ 56 | Name: externalTag.Name, 57 | } 58 | *t = tag 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /tools/jupyter/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | 4 | jupyter: 5 | image: jupyter/datascience-notebook 6 | container_name: jupyter 7 | ports: 8 | - 8888:8888 9 | environment: 10 | - JUPYTER_ENABLE_LAB=yes 11 | labels: 12 | - "traefik.http.services.jupyter.loadbalancer.server.port=8888" 13 | volumes: 14 | - ./data:/home/jovyan/work 15 | -------------------------------------------------------------------------------- /tools/profile-allocs.ps1: -------------------------------------------------------------------------------- 1 | $name = $args[0] 2 | if ([string]::IsNullOrWhiteSpace($name)) { 3 | echo "No name provided" 4 | return 5 | } 6 | # wget http://localhost:8080/debug/pprof/heap -o profiles/$name 7 | wget http://localhost:8080/debug/pprof/allocs -o profiles/$name 8 | go tool pprof -http=: profiles/$name 9 | -------------------------------------------------------------------------------- /tools/profile-block.ps1: -------------------------------------------------------------------------------- 1 | $name = $args[0] 2 | if ([string]::IsNullOrWhiteSpace($name)) { 3 | echo "No name provided" 4 | return 5 | } 6 | # wget http://localhost:8080/debug/pprof/heap -o profiles/$name 7 | wget http://localhost:8080/debug/pprof/block -o profiles/$name 8 | go tool pprof -http=: profiles/$name 9 | -------------------------------------------------------------------------------- /tools/profile-cpu.ps1: -------------------------------------------------------------------------------- 1 | $name = $args[0] 2 | if ([string]::IsNullOrWhiteSpace($name)) { 3 | echo "No name provided" 4 | return 5 | } 6 | wget http://localhost:8080/debug/pprof/profile?seconds=20 -o profiles/$name 7 | go tool pprof -http=: profiles/$name -------------------------------------------------------------------------------- /tools/profile-goroutine.ps1: -------------------------------------------------------------------------------- 1 | $name = $args[0] 2 | if ([string]::IsNullOrWhiteSpace($name)) { 3 | echo "No name provided" 4 | return 5 | } 6 | wget http://localhost:8080/debug/pprof/goroutine?seconds=20 -o profiles/$name 7 | go tool pprof -http=: profiles/$name -------------------------------------------------------------------------------- /tools/profile-heap.ps1: -------------------------------------------------------------------------------- 1 | $name = $args[0] 2 | if ([string]::IsNullOrWhiteSpace($name)) { 3 | echo "No name provided" 4 | return 5 | } 6 | # wget http://localhost:8080/debug/pprof/heap -o profiles/$name 7 | wget http://localhost:8080/debug/pprof/heap -o profiles/$name 8 | go tool pprof -http=: profiles/$name 9 | -------------------------------------------------------------------------------- /tools/profile-mutex.ps1: -------------------------------------------------------------------------------- 1 | $name = $args[0] 2 | if ([string]::IsNullOrWhiteSpace($name)) { 3 | echo "No name provided" 4 | return 5 | } 6 | # wget http://localhost:8080/debug/pprof/heap -o profiles/$name 7 | wget http://localhost:8080/debug/pprof/mutex -o profiles/$name 8 | go tool pprof -http=: profiles/$name 9 | -------------------------------------------------------------------------------- /tools/profile-trace.ps1: -------------------------------------------------------------------------------- 1 | $name = $args[0] 2 | if ([string]::IsNullOrWhiteSpace($name)) { 3 | echo "No name provided" 4 | return 5 | } 6 | # wget http://localhost:8080/debug/pprof/heap -o profiles/$name 7 | wget http://localhost:8080/debug/pprof/trace?seconds=3 -o profiles/$name 8 | go tool pprof -http=: profiles/$name 9 | -------------------------------------------------------------------------------- /ui/.env.development: -------------------------------------------------------------------------------- 1 | VITE_API_HOST=http://localhost:8080 2 | -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | *.local 5 | stats.html 6 | -------------------------------------------------------------------------------- /ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Photos 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /ui/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "./src/**/*" 4 | ], 5 | "compilerOptions": { 6 | "jsx": "preserve" 7 | } 8 | } -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "photofield", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vite build" 8 | }, 9 | "dependencies": { 10 | "@fontsource/roboto": "^4.5.7", 11 | "@unhead/vue": "^1.11.10", 12 | "@vitejs/plugin-vue": "^1.10.2", 13 | "@vueuse/core": "^11.1.0", 14 | "@vueuse/router": "^11.1.0", 15 | "balm-ui": "^10.9.0", 16 | "copy-image-clipboard": "^1.0.1", 17 | "date-fns": "^2.28.0", 18 | "deep-iterator": "^1.1.0", 19 | "fast-deep-equal": "^3.1.3", 20 | "kalmanjs": "^1.1.0", 21 | "ol": "^6.15.1", 22 | "plyr": "^3.7.2", 23 | "qs": "^6.11.0", 24 | "swrv": "^1.0.0-beta.8", 25 | "throttle-debounce": "^5.0.0", 26 | "vue": "3.3.7", 27 | "vue-concurrency": "^4.0.1", 28 | "vue-multiselect": "^3.0.0-beta.1", 29 | "vue-router": "^4.1.5" 30 | }, 31 | "devDependencies": { 32 | "eslint": "^8.19.0", 33 | "eslint-plugin-vue": "^9.2.0", 34 | "rollup-plugin-visualizer": "^5.6.0", 35 | "vite": "^2.9.14", 36 | "vite-plugin-compression": "^0.5.1" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ui/public/about.txt: -------------------------------------------------------------------------------- 1 | This favicon was generated using the following font: 2 | 3 | - Font Title: Bebas Neue 4 | - Font Author: Copyright 2019 The Bebas Neue Project Authors (https://github.com/dharmatype/Bebas-Neue) 5 | - Font Source: http://fonts.gstatic.com/s/bebasneue/v2/JTUSjIg69CK48gW7PXooxW5rygbi49c.ttf 6 | - Font License: SIL Open Font License, 1.1 (http://scripts.sil.org/OFL)) 7 | -------------------------------------------------------------------------------- /ui/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/ui/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /ui/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/ui/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /ui/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/ui/public/apple-touch-icon.png -------------------------------------------------------------------------------- /ui/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/ui/public/favicon-16x16.png -------------------------------------------------------------------------------- /ui/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/ui/public/favicon-32x32.png -------------------------------------------------------------------------------- /ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/ui/public/favicon.ico -------------------------------------------------------------------------------- /ui/public/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Photos", 3 | "name": "Photos (by Photofield)", 4 | "categories": ["photo", "photo & video"], 5 | "icons": [ 6 | { 7 | "src": "/favicon-16x16.png", 8 | "sizes": "16x16", 9 | "type": "image/png" 10 | }, 11 | { 12 | "src": "/favicon-32x32.png", 13 | "sizes": "32x32", 14 | "type": "image/png" 15 | }, 16 | { 17 | "src": "/apple-touch-icon.png", 18 | "sizes": "180x180", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "/android-chrome-192x192.png", 23 | "sizes": "192x192", 24 | "type": "image/png" 25 | }, 26 | { 27 | "src": "/android-chrome-512x512.png", 28 | "sizes": "512x512", 29 | "type": "image/png" 30 | }, 31 | { 32 | "src": "/maskable-512x512.png", 33 | "sizes": "512x512", 34 | "type": "image/png", 35 | "purpose": "maskable" 36 | } 37 | ], 38 | "start_url": "/", 39 | "display": "standalone", 40 | "background_color": "#ffffff", 41 | "theme_color": "#ffffff", 42 | "description": "A non-invasive local photo viewer with a focus on speed and simplicity." 43 | } -------------------------------------------------------------------------------- /ui/public/maskable-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmilyOrg/photofield/d67b5528a09308374f4ff2f1262f210e595bcd84/ui/public/maskable-512x512.png -------------------------------------------------------------------------------- /ui/src/Root.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 11 | -------------------------------------------------------------------------------- /ui/src/components/CenterMessage.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 22 | 23 | 55 | -------------------------------------------------------------------------------- /ui/src/components/CollectionDebug.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 71 | 72 | -------------------------------------------------------------------------------- /ui/src/components/CollectionLink.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | 16 | -------------------------------------------------------------------------------- /ui/src/components/CollectionPanel.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 67 | 68 | -------------------------------------------------------------------------------- /ui/src/components/CollectionSettings.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 60 | 61 | -------------------------------------------------------------------------------- /ui/src/components/ColorModeSwitch.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | -------------------------------------------------------------------------------- /ui/src/components/DateStrip.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 26 | 27 | -------------------------------------------------------------------------------- /ui/src/components/DetailItem.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 48 | 49 | -------------------------------------------------------------------------------- /ui/src/components/DisplaySettings.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 86 | 87 | -------------------------------------------------------------------------------- /ui/src/components/Downloads.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 30 | 31 | -------------------------------------------------------------------------------- /ui/src/components/ErrorBar.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 77 | 78 | 145 | -------------------------------------------------------------------------------- /ui/src/components/ExpandButton.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | 16 | -------------------------------------------------------------------------------- /ui/src/components/Home.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 47 | 48 | 63 | -------------------------------------------------------------------------------- /ui/src/components/PageTitle.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /ui/src/components/PhotoSkeleton.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 18 | 19 | -------------------------------------------------------------------------------- /ui/src/components/PixelCount.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 36 | 37 | -------------------------------------------------------------------------------- /ui/src/components/RectDebug.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 64 | 65 | 70 | -------------------------------------------------------------------------------- /ui/src/components/ResponseLoader.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 64 | 65 | 83 | -------------------------------------------------------------------------------- /ui/src/components/ResponseRetryButton.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 37 | 38 | -------------------------------------------------------------------------------- /ui/src/components/SearchInput.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 107 | 108 | -------------------------------------------------------------------------------- /ui/src/components/Tags.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 109 | 110 | -------------------------------------------------------------------------------- /ui/src/components/TaskList.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 44 | 45 | -------------------------------------------------------------------------------- /ui/src/components/ToolbarTasks.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 43 | 44 | -------------------------------------------------------------------------------- /ui/src/components/openlayers/geoview.js: -------------------------------------------------------------------------------- 1 | import { get as getProjection, fromLonLat, toLonLat } from 'ol/proj'; 2 | 3 | const proj = getProjection("EPSG:3857"); 4 | 5 | function toView(geoview, sceneBounds) { 6 | if (!geoview || !sceneBounds) return null; 7 | 8 | const coord = fromLonLat(geoview, proj); 9 | const zoom = geoview[2]; 10 | const fullExtent = proj.getExtent(); 11 | const fw = fullExtent[2] - fullExtent[0]; 12 | const fh = fullExtent[3] - fullExtent[1]; 13 | const sx = sceneBounds.w / fw; 14 | const sy = sceneBounds.h / fh; 15 | 16 | const power = Math.pow(2, zoom); 17 | const sw = fw / power; 18 | const sh = fh / power; 19 | const extent = [ 20 | coord[0] - sw, 21 | coord[1] - sh, 22 | coord[0] + sw, 23 | coord[1] + sh, 24 | ]; 25 | return { 26 | x: (extent[0] - fullExtent[0]) * sx, 27 | y: (fullExtent[3] - extent[3]) * sy, 28 | w: (extent[2] - extent[0]) * sx, 29 | h: (extent[3] - extent[1]) * sy, 30 | }; 31 | } 32 | 33 | function fromView(view, sceneBounds) { 34 | if (!view || !sceneBounds) return null; 35 | 36 | const fullExtent = proj.getExtent(); 37 | const fw = fullExtent[2] - fullExtent[0]; 38 | const fh = fullExtent[3] - fullExtent[1]; 39 | const sx = sceneBounds.w / fw; 40 | const sy = sceneBounds.h / fh; 41 | 42 | const extent = [ 43 | view.x / sx + fullExtent[0], 44 | fullExtent[3] - view.y / sy, 45 | (view.x + view.w) / sx + fullExtent[0], 46 | fullExtent[3] - (view.y + view.h) / sy, 47 | ]; 48 | const center = [ 49 | (extent[0] + extent[2]) / 2, 50 | (extent[1] + extent[3]) / 2, 51 | ]; 52 | const sw = (extent[2] - extent[0]) * 0.5; 53 | const sh = (extent[1] - extent[3]) * 0.5; 54 | const power = Math.max(fw / sw, fh / sh); 55 | const zoom = Math.log2(power); 56 | const lonlat = toLonLat(center, proj); 57 | return [lonlat[0], lonlat[1], zoom]; 58 | } 59 | 60 | function equal(a, b) { 61 | if (!a || !b) return false; 62 | return ( 63 | Math.abs(a[0] - b[0]) < 1e-4 && 64 | Math.abs(a[1] - b[1]) < 1e-4 && 65 | Math.abs(a[2] - b[2]) < 1e-1 66 | ); 67 | } 68 | 69 | export default { 70 | toView, 71 | fromView, 72 | equal, 73 | }; -------------------------------------------------------------------------------- /ui/src/index.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | margin: 0; 3 | } 4 | 5 | .plyr { 6 | min-width: 0 !important; 7 | } 8 | 9 | html.hide-scrollbar { 10 | overflow-y: scroll; 11 | scrollbar-width: none; /* Firefox */ 12 | -ms-overflow-style: none; /* Internet Explorer 10+ */ 13 | } 14 | html.hide-scrollbar::-webkit-scrollbar { /* WebKit */ 15 | display: none; 16 | width: 0; 17 | height: 0; 18 | } 19 | 20 | html.no-scroll { 21 | overflow: hidden; 22 | } 23 | 24 | -------------------------------------------------------------------------------- /ui/src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import Root from './Root.vue' 3 | import './index.css' 4 | import router from './router' 5 | 6 | import BalmUI from 'balm-ui'; // Official Google Material Components 7 | import BalmUIPlus from 'balm-ui/dist/balm-ui-plus'; // BalmJS Team Material Components 8 | import 'balm-ui/dist/balm-ui.css'; 9 | 10 | import "plyr/dist/plyr.css"; 11 | 12 | import "@fontsource/roboto/300.css"; 13 | import "@fontsource/roboto/400.css"; 14 | import "@fontsource/roboto/500.css"; 15 | 16 | import "vue-multiselect/dist/vue-multiselect.css"; 17 | import { createHead } from '@unhead/vue'; 18 | 19 | const app = createApp(Root); 20 | 21 | const head = createHead() 22 | app.use(head) 23 | app.use(BalmUI); 24 | app.use(BalmUIPlus); 25 | 26 | app.use(router); 27 | 28 | app.mount('#app') 29 | 30 | window.app = app; 31 | -------------------------------------------------------------------------------- /ui/src/router/index.js: -------------------------------------------------------------------------------- 1 | import { createWebHistory, createRouter } from "vue-router"; 2 | import App from "../App.vue"; 3 | import Home from "../components/Home.vue"; 4 | import CollectionView from "../components/CollectionView.vue"; 5 | 6 | const router = createRouter({ 7 | history: createWebHistory(), 8 | routes: [ 9 | { 10 | path: "/", 11 | component: App, 12 | props: true, 13 | children: [ 14 | { 15 | name: "home", 16 | path: "/", 17 | component: Home, 18 | props: true, 19 | }, 20 | { 21 | name: "collection", 22 | path: "/collections/:collectionId", 23 | component: CollectionView, 24 | props: true, 25 | }, 26 | { 27 | name: "region", 28 | path: "/collections/:collectionId/:regionId", 29 | component: CollectionView, 30 | props: true, 31 | }, 32 | ], 33 | } 34 | ], 35 | }); 36 | 37 | export default router; 38 | -------------------------------------------------------------------------------- /ui/src/simulation.js: -------------------------------------------------------------------------------- 1 | export default class Simulation { 2 | 3 | constructor(options) { 4 | this.runs = options.runs; 5 | this.actions = options.actions; 6 | this.scrollbar = options.scrollbar; 7 | let actionStart = 0; 8 | this.actions.forEach(action => { 9 | action.start = actionStart; 10 | actionStart += action.duration; 11 | }); 12 | } 13 | 14 | run(target) { 15 | this.target = target; 16 | this.promise = new Promise(resolve => { 17 | this.finish = resolve; 18 | }); 19 | this.runResults = []; 20 | this.runIndex = -1; 21 | this.nextRun(); 22 | return this.promise; 23 | } 24 | 25 | nextRun() { 26 | if (this.runIndex >= 0) { 27 | this.runResults.push({ 28 | params: this.runs[this.runIndex], 29 | frames: this.frames, 30 | }); 31 | } 32 | this.runIndex++; 33 | if (this.runIndex >= this.runs.length) { 34 | this.finish(this.runResults); 35 | return; 36 | } 37 | const run = this.runs[this.runIndex]; 38 | this.initRun(run); 39 | } 40 | 41 | initRun(run) { 42 | for (const name in run) { 43 | if (Object.hasOwnProperty.call(run, name)) { 44 | const value = run[name]; 45 | this.target[name] = value; 46 | } 47 | } 48 | console.log("run", this.runIndex); 49 | this.runStartTime = performance.now(); 50 | this.frameStartTime = this.runStartTime; 51 | this.frames = []; 52 | window.requestAnimationFrame(this.nextFrame.bind(this)); 53 | } 54 | 55 | nextFrame() { 56 | const now = performance.now(); 57 | const elapsed = now - this.runStartTime; 58 | const frameTime = now - this.frameStartTime; 59 | this.frameStartTime = now; 60 | const action = this.getAction(elapsed); 61 | this.applyAction(action, elapsed, frameTime); 62 | if (elapsed >= action.start + action.duration) { 63 | this.nextRun(); 64 | return; 65 | } 66 | window.requestAnimationFrame(this.nextFrame.bind(this)); 67 | } 68 | 69 | getAction(elapsed) { 70 | let action = null; 71 | for (let i = 0; i < this.actions.length; i++) { 72 | action = this.actions[i]; 73 | if (elapsed < action.start + action.duration) { 74 | break; 75 | } 76 | } 77 | return action; 78 | } 79 | 80 | applyAction(action, elapsed, frameTime) { 81 | if (!action) return; 82 | const t = Math.max(0, Math.min(1, (elapsed - action.start) / action.duration)); 83 | const scroll = action.scroll; 84 | let speed = 0; 85 | if (scroll) { 86 | let y = scroll.from; 87 | if (scroll.to !== undefined) { 88 | const distance = scroll.to - scroll.from; 89 | speed = distance * 1000 / action.duration; 90 | y = scroll.from + t * distance; 91 | } 92 | this.scrollbar.scroll([0, y]); 93 | } 94 | this.frames.push([ elapsed, frameTime, t, speed ]); 95 | } 96 | 97 | 98 | 99 | 100 | } -------------------------------------------------------------------------------- /ui/vite.config.js: -------------------------------------------------------------------------------- 1 | import vue from '@vitejs/plugin-vue' 2 | import { visualizer } from "rollup-plugin-visualizer" 3 | import viteCompression from 'vite-plugin-compression' 4 | import { defineConfig } from 'vite' 5 | 6 | export default defineConfig({ 7 | plugins: [ 8 | vue(), 9 | viteCompression(), 10 | visualizer(), 11 | ], 12 | resolve: { 13 | dedupe: ["vue"], 14 | }, 15 | build: { 16 | commonjsOptions: { 17 | transformMixedEsModules: true, 18 | }, 19 | }, 20 | }) 21 | -------------------------------------------------------------------------------- /vetur.config.js: -------------------------------------------------------------------------------- 1 | // vetur.config.js 2 | /** @type {import('vls').VeturConfig} */ 3 | module.exports = { 4 | projects: [ 5 | './ui', 6 | ] 7 | } 8 | --------------------------------------------------------------------------------