Merge remote-tracking branch 'origin/develop' into PostgreSQL

This commit is contained in:
Keith Martin 2026-05-21 21:37:08 +10:00
commit ea3ba601dd
53 changed files with 1009 additions and 182 deletions

View file

@ -12,6 +12,10 @@
# multiple parallel dev environments without colliding on the shared network.
# COMPOSE_NETWORK_NAME=photoprism
# Optional services to start by default. Comma-separated list of profile names
# read by Docker Compose itself (so no --profile flag is required).
# COMPOSE_PROFILES=postgres
# Network interfaces to which Traefik and other services bind on the host.
# Defaults expose Traefik on every interface (so *.localssl.dev is reachable
# from the LAN) and keep direct service ports on loopback only.
@ -25,6 +29,7 @@
# Public base URL used in share links, OIDC & PWA URIs.
# PHOTOPRISM_SITE_URL=https://app.localssl.dev/
# Ports to which MariaDB and PostgreSQL should bind.
# Ports to which MariaDB and PostgreSQL should bind. PostgreSQL only starts
# when the "postgres" (or "all") profile is enabled via COMPOSE_PROFILES.
# MARIADB_PORT=4001
# POSTGRES_PORT=4002

View file

@ -95,7 +95,7 @@ services:
PHOTOPRISM_HTTP_PORT: 2342
PHOTOPRISM_HTTP_COMPRESSION: "zstd,gzip" # improves transfer speed and bandwidth utilization (none, gzip, zstd, or comma-separated list e.g. "zstd,gzip")
MYSQL_TCP_PORT: "${MARIADB_PORT:-4001}" # default MariaDB database port used by the "mariadb" client (see .my.cnf)
PGPORT: "${POSTGRES_PORT:-4002}" # default PostgreSQL database port used by "qsql"
PGPORT: "${POSTGRES_PORT:-4002}" # default PostgreSQL database port used by "psql"
PHOTOPRISM_DATABASE_DRIVER: "mysql"
PHOTOPRISM_DATABASE_SERVER: "mariadb:${MARIADB_PORT:-4001}"
PHOTOPRISM_DATABASE_NAME: "photoprism"
@ -129,7 +129,7 @@ services:
PHOTOPRISM_THUMB_LIBRARY: "auto" # image processing library to be used for generating thumbnails (auto, vips)
PHOTOPRISM_THUMB_UNCACHED: "true" # enables on-demand thumbnail rendering (high memory and cpu usage)
## Intel Quick Sync Video (QSV) (https://docs.photoprism.app/getting-started/advanced/transcoding/#intel-quick-sync):
PHOTOPRISM_FFMPEG_ENCODER: "intel" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, or vaapi)
PHOTOPRISM_FFMPEG_ENCODER: "intel" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, vaapi, or vulkan)
PHOTOPRISM_FFMPEG_SIZE: "1920" # video size limit in pixels (720-7680) (default: 3840)
# PHOTOPRISM_FFMPEG_BITRATE: "64" # video bitrate limit in Mbps (default: 60)
## Run/install on first startup (options: update tensorflow https intel gpu davfs yt-dlp):

View file

@ -98,7 +98,7 @@ services:
PHOTOPRISM_HTTP_PORT: 2342
PHOTOPRISM_HTTP_COMPRESSION: "zstd,gzip" # improves transfer speed and bandwidth utilization (none, gzip, zstd, or comma-separated list e.g. "zstd,gzip")
MYSQL_TCP_PORT: "${MARIADB_PORT:-4001}" # default MariaDB database port used by the "mariadb" client (see .my.cnf)
PGPORT: "${POSTGRES_PORT:-4002}" # default PostgreSQL database port used by "qsql"
PGPORT: "${POSTGRES_PORT:-4002}" # default PostgreSQL database port used by "psql"
PHOTOPRISM_DATABASE_DRIVER: "mysql"
PHOTOPRISM_DATABASE_SERVER: "mariadb:${MARIADB_PORT:-4001}"
PHOTOPRISM_DATABASE_NAME: "photoprism"
@ -140,7 +140,7 @@ services:
## NVIDIA GPU Hardware Acceleration (see https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html):
NVIDIA_VISIBLE_DEVICES: "all"
NVIDIA_DRIVER_CAPABILITIES: "all"
PHOTOPRISM_FFMPEG_ENCODER: "nvidia" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, or vaapi)
PHOTOPRISM_FFMPEG_ENCODER: "nvidia" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, vaapi, or vulkan)
PHOTOPRISM_FFMPEG_SIZE: "1920" # video size limit in pixels (720-7680) (default: 3840)
PHOTOPRISM_FFMPEG_BITRATE: "64" # video bitrate limit in Mbps (default: 60)
## Additional dependencies and tools:
@ -175,7 +175,7 @@ services:
ollama:
image: ollama/ollama:latest
stop_grace_period: 15s
## Only starts this service if the "all", "ollama", or "vision" profile is specified::
## Only starts if the "all", "ollama", or "vision" profile is specified:
## docker compose --profile ollama up -d
profiles: ["all", "ollama", "vision"]
## Insecurely exposes the Ollama service on port 11434

View file

@ -103,7 +103,7 @@ services:
PHOTOPRISM_HTTP_PORT: 2342
PHOTOPRISM_HTTP_COMPRESSION: "zstd,gzip" # improves transfer speed and bandwidth utilization (none, gzip, zstd, or comma-separated list e.g. "zstd,gzip")
MYSQL_TCP_PORT: "${MARIADB_PORT:-4001}" # default MariaDB database port used by the "mariadb" client (see .my.cnf)
PGPORT: "${POSTGRES_PORT:-4002}" # default PostgreSQL database port used by "qsql"
PGPORT: "${POSTGRES_PORT:-4002}" # default PostgreSQL database port used by "psql"
PHOTOPRISM_DATABASE_DRIVER: "mysql"
PHOTOPRISM_DATABASE_SERVER: "mariadb:${MARIADB_PORT:-4001}"
PHOTOPRISM_DATABASE_NAME: "photoprism"
@ -137,7 +137,7 @@ services:
PHOTOPRISM_THUMB_LIBRARY: "auto" # image processing library to be used for generating thumbnails (auto, vips)
PHOTOPRISM_THUMB_UNCACHED: "true" # enables on-demand thumbnail rendering (high memory and cpu usage)
## Video Transcoding (https://docs.photoprism.app/getting-started/advanced/transcoding/):
# PHOTOPRISM_FFMPEG_ENCODER: "software" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, or vaapi)
# PHOTOPRISM_FFMPEG_ENCODER: "software" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, vaapi, or vulkan)
# LIBVA_DRIVER_NAME: "i965" # For Intel architectures Haswell and older which do not support QSV yet but use VAAPI instead
PHOTOPRISM_FFMPEG_SIZE: "1920" # video size limit in pixels (720-7680) (default: 3840)
# PHOTOPRISM_FFMPEG_BITRATE: "64" # video bitrate limit in Mbps (default: 60)
@ -189,7 +189,7 @@ services:
## Release Notes: https://mariadb.com/kb/en/changes-improvements-in-mariadb-1011/
mariadb:
image: mariadb:11
stop_grace_period: 10s
stop_grace_period: 15s
security_opt: # see https://github.com/MariaDB/mariadb-docker/issues/434#issuecomment-1136151239
- seccomp:unconfined
- apparmor:unconfined
@ -224,6 +224,9 @@ services:
## Release Notes: https://www.postgresql.org/docs/release/
postgres:
image: postgres:18-alpine
## Only starts if the "all" or "postgres" profile is specified:
## docker compose --profile postgres up -d
profiles: [ "all", "postgres" ]
stop_grace_period: 15s
expose:
- "${POSTGRES_PORT:-4002}"
@ -246,6 +249,8 @@ services:
## Web UI: https://qdrant.localssl.dev/dashboard
qdrant:
image: qdrant/qdrant:latest
## Only starts if the "all" or "qdrant" profile is specified:
## docker compose --profile qdrant up -d
profiles: [ "all", "qdrant" ]
stop_grace_period: 10s
links:
@ -277,10 +282,10 @@ services:
## docker compose exec ollama ollama pull gemma3:latest
ollama:
image: ollama/ollama:latest
stop_grace_period: 10s
## Only starts this service if the "all", "ollama", or "vision" profile is specified::
## Only starts if the "all", "ollama", or "vision" profile is specified:
## docker compose --profile ollama up -d
profiles: [ "all", "ollama", "vision" ]
stop_grace_period: 10s
## Optionally publish the Ollama API on the host (no built-in auth — private/internal networks only):
# ports:
# - "${SERVICES_BIND_HOST:-127.0.0.1}:11434:11434" # Ollama API
@ -328,10 +333,10 @@ services:
## see https://github.com/open-webui/open-webui
open-webui:
image: ghcr.io/open-webui/open-webui:main
stop_grace_period: 10s
## Only starts this service if the "all", "ollama", "open-webui", or "vision" profile is specified::
## docker compose --profile ollama up -d
## Only starts if the "all", "ollama", "open-webui", or "vision" profile is specified:
## docker compose --profile open-webui up -d
profiles: [ "all", "ollama", "open-webui", "vision" ]
stop_grace_period: 10s
## Publish Open WebUI on the host (default access is via https://chat.localssl.dev/ through Traefik):
ports:
- "${SERVICES_BIND_HOST:-127.0.0.1}:8080:8080" # Open WebUI
@ -356,10 +361,10 @@ services:
## see https://github.com/photoprism/photoprism-vision
photoprism-vision:
image: photoprism/vision:latest
stop_grace_period: 15s
## Only starts this service if the "all" or "vision" profile is specified::
## Only starts if the "all" or "vision" profile is specified:
## docker compose --profile vision up -d
profiles: [ "all", "vision" ]
stop_grace_period: 15s
working_dir: "/app"
links:
- "traefik:localssl.dev"
@ -443,8 +448,10 @@ services:
## Login with "user / photoprism" and "admin / photoprism".
keycloak:
image: quay.io/keycloak/keycloak:25.0
stop_grace_period: 10s
## Only starts if the "all", "auth", or "keycloak" profile is specified:
## docker compose --profile keycloak up -d
profiles: [ "all", "auth", "keycloak" ]
stop_grace_period: 10s
command: "start-dev" # development mode, do not use this in production!
links:
- "traefik:localssl.dev"
@ -477,8 +484,10 @@ services:
## ./photoprism client add --id=cs5cpu17n6gj2qo5 --secret=xcCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e -s metrics -n Prometheus -e 60 -t 1
prometheus:
image: prom/prometheus:latest
stop_grace_period: 10s
## Only starts if the "all", "auth", or "prometheus" profile is specified:
## docker compose --profile prometheus up -d
profiles: [ "all", "auth", "prometheus" ]
stop_grace_period: 10s
labels:
- "traefik.enable=true"
- "traefik.docker.network=${COMPOSE_NETWORK_NAME:-photoprism}"

View file

@ -105,6 +105,10 @@ export default {
type: Function,
default: null,
},
maxLength: {
type: Number,
default: 0,
},
},
emits: ["update:items"],
data() {
@ -266,6 +270,14 @@ export default {
return;
}
// Block the create path when the typed title exceeds the configured cap
// otherwise the backend setter clips with an ellipsis and the user is
// shown a green success against a renamed entity they didn't intend.
if (this.maxLength > 0 && title.length > this.maxLength) {
this.$notify.error(this.$gettext("%{s} is too long", { s: this.$gettext("Name") }));
return;
}
let resolvedApplied = false;
if (typeof this.resolveItemFromText === "function") {
const resolved = this.resolveItemFromText(title);

View file

@ -72,7 +72,7 @@
</v-dialog>
</template>
<script>
import Album from "model/album";
import Album, { MaxLength as AlbumMaxLength } from "model/album";
import { createAlbumSelectionWatcher } from "common/albums";
// TODO: Handle cases where users have more than 10000 albums.
@ -136,6 +136,14 @@ export default {
}
});
// Block the create path when any typed name exceeds the backend cap
// otherwise Album.SetTitle silently truncates with an ellipsis and the
// dialog reports success against a renamed album the user didn't intend.
if (namesToCreate.some((title) => (title || "").trim().length > AlbumMaxLength.Title)) {
this.$notify.error(this.$gettext("%{s} is too long", { s: this.$gettext("Name") }));
return;
}
// Deduplicate existing UIDs
const uniqueExistingUids = [...new Set(existingUids)];

View file

@ -420,6 +420,7 @@
:empty-text="$gettext('No albums assigned')"
:loading="loading"
:disabled="false"
:max-length="AlbumMaxLength.Title"
class="input-albums"
@update:items="onAlbumsUpdate"
/>
@ -437,6 +438,7 @@
:empty-text="$gettext('No labels assigned')"
:loading="loading"
:disabled="false"
:max-length="LabelMaxLength.Name"
class="input-labels"
@update:items="onLabelsUpdate"
/>
@ -501,6 +503,8 @@ import IconLivePhoto from "../icon/live-photo.vue";
import { Batch } from "model/batch";
import Thumb from "model/thumb";
import { MaxLength as PhotoMaxLength } from "model/photo";
import { MaxLength as AlbumMaxLength } from "model/album";
import { MaxLength as LabelMaxLength } from "model/label";
import { rules } from "common/form";
import PMetaLocationDialog from "component/meta/location/dialog.vue";
import PMetaLocationInput from "component/meta/location/input.vue";
@ -557,6 +561,8 @@ export default {
options,
rules,
PhotoMaxLength,
AlbumMaxLength,
LabelMaxLength,
firstVisibleElementIndex: 0,
lastVisibleElementIndex: 0,
mouseDown: {

View file

@ -291,6 +291,14 @@ export default {
return;
}
// Block the create path when the typed name exceeds the backend cap
// otherwise the backend clips the name and returns success, leaving the
// user with a green "added X" toast against a truncated label.
if (typed.length > LabelMaxLength.Name) {
this.$notify.error(this.$gettext("%{s} is too long", { s: this.$gettext("Name") }));
return;
}
// Apply the same canonical-match dedup the sidebar uses for L3:
// typing `Hello Cat` resolves to an existing `hello-cat` label so
// the backend isn't asked to create a near-duplicate. normalizeTitle

View file

@ -69,9 +69,11 @@ func (m *Model) GetModel() (model, name, version string) {
return "", "", ""
}
// Normalise the configured values.
name = clean.TypeLower(m.Name)
version = clean.TypeLowerDash(m.Version)
// Sanitize the configured values without lowercasing: upstream catalogs
// (Ollama tags, Hugging Face IDs served by OpenAI-compatible endpoints)
// match identifiers verbatim, so case must round-trip from vision.yml.
name = clean.Type(m.Name)
version = clean.Type(m.Version)
// Build a base name from the highest-priority override:
// 1) Service-specific override (expanded for env vars)
@ -82,7 +84,7 @@ func (m *Model) GetModel() (model, name, version string) {
case serviceModel != "":
name = serviceModel
case strings.TrimSpace(m.Model) != "":
name = clean.TypeLower(m.Model)
name = clean.Type(m.Model)
}
// Return if no model is configured.

View file

@ -139,10 +139,20 @@ func TestModel_GetModel(t *testing.T) {
Model: "CUSTOM-MODEL",
Engine: ollama.EngineName,
},
wantModel: "custom-model:latest",
wantName: "custom-model",
wantModel: "CUSTOM-MODEL:latest",
wantName: "CUSTOM-MODEL",
wantVersion: "latest",
},
{
name: "OpenAIPreservesHuggingFaceCase",
model: &Model{
Engine: openai.EngineName,
Service: Service{Model: "QuantTrio/Qwen3-VL-30B-A3B-Instruct-AWQ"},
},
wantModel: "QuantTrio/Qwen3-VL-30B-A3B-Instruct-AWQ",
wantName: "QuantTrio/Qwen3-VL-30B-A3B-Instruct-AWQ",
wantVersion: "",
},
{
name: "ServiceOverrideWithVersion",
model: &Model{

View file

@ -65,6 +65,8 @@ func (m *Service) Endpoint() (uri, method string) {
}
// GetModel returns the model identifier override for the endpoint, if any.
// Case is preserved because upstream catalogs (e.g. Hugging Face IDs on
// OpenAI-compatible servers) match identifiers verbatim.
func (m *Service) GetModel() string {
if m.Disabled {
return ""
@ -72,7 +74,7 @@ func (m *Service) GetModel() string {
ensureEnv()
return clean.TypeLower(os.ExpandEnv(m.Model))
return clean.Type(os.ExpandEnv(m.Model))
}
// EndpointKey returns the access token belonging to the remote service endpoint, if any.

View file

@ -68,7 +68,7 @@ func TestServiceEndpoint(t *testing.T) {
func TestServiceCredentialsAndHeaders(t *testing.T) {
t.Setenv("VISION_USER", "alice")
t.Setenv("VISION_PASS", "hunter2")
t.Setenv("VISION_MODEL", "GEMMA3:Latest")
t.Setenv("VISION_MODEL", "QuantTrio/Qwen3-VL-30B-A3B-Instruct-AWQ")
t.Setenv("VISION_ORG", "org-123")
t.Setenv("VISION_PROJECT", "proj-abc")
t.Setenv("VISION_THINK", "false")
@ -87,7 +87,7 @@ func TestServiceCredentialsAndHeaders(t *testing.T) {
t.Fatalf("basic auth: got %q/%q", user, pass)
}
if got := svc.GetModel(); got != "gemma3:latest" {
if got := svc.GetModel(); got != "QuantTrio/Qwen3-VL-30B-A3B-Instruct-AWQ" {
t.Fatalf("model override: got %q", got)
}

View file

@ -89,6 +89,7 @@ var FacesCommands = &cli.Command{
},
Action: facesOptimizeAction,
},
FacesConfigCommand,
},
}

View file

@ -0,0 +1,76 @@
package commands
import (
"encoding/json"
"fmt"
"strings"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/service/hub"
"github.com/photoprism/photoprism/pkg/txt/report"
)
// FacesConfigCommand displays the config options relevant for face detection
// and recognition. It mirrors `photoprism config` but restricts the report to
// the face-related rows produced by Config.FaceReport.
var FacesConfigCommand = &cli.Command{
Name: "config",
Usage: "Displays the config options relevant for face detection and recognition",
Flags: report.CliFlags,
Action: facesConfigAction,
}
// FacesConfigReports specifies which face-related reports to display.
var FacesConfigReports = []Report{
{Title: "Face Detection & Recognition", NoWrap: true, Report: func(conf *config.Config) ([][]string, []string) {
return conf.FaceReport()
}},
}
// facesConfigAction prints face-related config option names and values.
func facesConfigAction(ctx *cli.Context) error {
conf := config.NewConfig(ctx)
conf.SetLogLevel(logrus.FatalLevel)
hub.Disable()
if err := conf.InitCore(); err != nil {
log.Debug(err)
}
format, formatErr := report.CliFormatStrict(ctx)
if formatErr != nil {
return formatErr
}
if format == report.JSON {
type section struct {
Title string `json:"title"`
Items []map[string]string `json:"items"`
}
sections := make([]section, 0, len(FacesConfigReports))
for _, rep := range FacesConfigReports {
rows, cols := rep.Report(conf)
sections = append(sections, section{Title: rep.Title, Items: report.RowsToObjects(rows, cols)})
}
b, _ := json.Marshal(map[string]any{"sections": sections})
fmt.Println(string(b))
return nil
}
for _, rep := range FacesConfigReports {
rows, cols := rep.Report(conf)
opt := report.Options{Format: format, NoWrap: rep.NoWrap}
result, _ := report.Render(rows, cols, opt)
switch opt.Format {
case report.Markdown:
fmt.Printf("### %s\n\n", rep.Title)
case report.Default:
fmt.Printf("%s\n\n", strings.ToUpper(rep.Title))
}
fmt.Println(result)
}
return nil
}

View file

@ -0,0 +1,62 @@
package commands
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
)
func TestFacesConfigCommand(t *testing.T) {
output, err := RunWithTestContext(FacesConfigCommand, []string{})
if err != nil {
t.Fatal(err)
}
// Spot-check face-related rows are reported.
assert.Contains(t, output, "face-engine")
assert.Contains(t, output, "face-size")
assert.Contains(t, output, "face-cluster-dist")
assert.Contains(t, output, "facenet-model-path")
assert.Contains(t, output, "disable-faces")
// Non-face options must not leak into the focused report.
assert.NotContains(t, output, "originals-path")
assert.NotContains(t, output, "ffmpeg-bin")
assert.NotContains(t, output, "auth-mode")
}
func TestFacesConfigCommandJSON(t *testing.T) {
output, err := RunWithTestContext(FacesConfigCommand, []string{"config", "--json"})
if err != nil {
t.Fatal(err)
}
var payload struct {
Sections []struct {
Title string `json:"title"`
Items []map[string]string `json:"items"`
} `json:"sections"`
}
if err := json.Unmarshal([]byte(output), &payload); err != nil {
t.Fatalf("invalid JSON output: %v\noutput: %s", err, output)
}
if len(payload.Sections) != 1 {
t.Fatalf("expected exactly one section, got %d", len(payload.Sections))
}
names := make(map[string]string, len(payload.Sections[0].Items))
for _, item := range payload.Sections[0].Items {
names[item["name"]] = item["value"]
}
for _, want := range []string{"face-engine", "face-size", "face-cluster-dist", "facenet-model-path"} {
if _, ok := names[want]; !ok {
t.Errorf("expected JSON to contain %q, got keys: %v", want, names)
}
}
}

View file

@ -5,24 +5,31 @@ import (
"strings"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/http/scheme"
)
// CdnBaseUrl returns the configured CDN URL normalized as a base URL, or "" if unset.
// Strips the scheme's default port so semantically equal Site and CDN URLs compare equal.
func (c *Config) CdnBaseUrl() string {
return scheme.NormalizeBaseURL(c.options.CdnUrl)
}
// CdnUrl returns the optional content delivery network URI without trailing slash.
func (c *Config) CdnUrl(res string) string {
if c.options.CdnUrl == "" || c.options.CdnUrl == c.options.SiteUrl {
cdnUrl := c.CdnBaseUrl()
if cdnUrl == "" || cdnUrl == c.SiteUrl() {
return res
}
return strings.TrimRight(c.options.CdnUrl, "/") + res
return strings.TrimRight(cdnUrl, "/") + res
}
// UseCdn checks if a Content Deliver Network (CDN) is used to serve static content.
func (c *Config) UseCdn() bool {
if c.options.CdnUrl == "" || c.options.CdnUrl == c.options.SiteUrl {
return false
}
cdnUrl := c.CdnBaseUrl()
return true
return cdnUrl != "" && cdnUrl != c.SiteUrl()
}
// NoCdn checks if there is no Content Deliver Network (CDN) configured to serve static content.
@ -32,9 +39,11 @@ func (c *Config) NoCdn() bool {
// CdnDomain returns the content delivery network domain name if specified.
func (c *Config) CdnDomain() string {
if c.options.CdnUrl == "" || c.options.CdnUrl == c.options.SiteUrl {
cdnUrl := c.CdnBaseUrl()
if cdnUrl == "" || cdnUrl == c.SiteUrl() {
return ""
} else if u, err := url.Parse(c.options.CdnUrl); err != nil {
} else if u, err := url.Parse(cdnUrl); err != nil {
return ""
} else {
return u.Hostname()
@ -43,7 +52,9 @@ func (c *Config) CdnDomain() string {
// CdnVideo checks if videos should be streamed using the configured CDN.
func (c *Config) CdnVideo() bool {
if c.options.CdnUrl == "" || c.options.CdnUrl == c.options.SiteUrl {
cdnUrl := c.CdnBaseUrl()
if cdnUrl == "" || cdnUrl == c.SiteUrl() {
return false
}

View file

@ -32,6 +32,39 @@ func TestConfig_CdnUrl(t *testing.T) {
assert.True(t, c.UseCdn())
}
func TestConfig_CdnUrl_DefaultPortEqualsSite(t *testing.T) {
c := NewConfig(CliTestContext())
c.options.SiteUrl = "https://host/"
c.options.CdnUrl = "https://host:443/"
assert.False(t, c.UseCdn())
assert.True(t, c.NoCdn())
assert.Equal(t, "/api/v1", c.CdnUrl(ApiUri))
assert.Equal(t, "", c.CdnDomain())
assert.False(t, c.CdnVideo())
c.options.SiteUrl = "https://host:443/"
c.options.CdnUrl = "https://host/"
assert.False(t, c.UseCdn())
assert.True(t, c.NoCdn())
c.options.SiteUrl = "http://host/"
c.options.CdnUrl = "http://host:80/"
assert.False(t, c.UseCdn())
}
func TestConfig_CdnBaseUrl(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, "", c.CdnBaseUrl())
c.options.CdnUrl = " "
assert.Equal(t, "", c.CdnBaseUrl())
c.options.CdnUrl = "https://cdn.example.com:443/"
assert.Equal(t, "https://cdn.example.com/", c.CdnBaseUrl())
c.options.CdnUrl = "http://foo:2342/foo/"
assert.Equal(t, "http://foo:2342/foo/", c.CdnBaseUrl())
}
func TestConfig_CdnDomain(t *testing.T) {
c := NewConfig(CliTestContext())

View file

@ -18,6 +18,8 @@ func TestConfig_FFmpegEncoder(t *testing.T) {
assert.Equal(t, encode.SoftwareAvc, c.FFmpegEncoder())
c.options.FFmpegEncoder = "intel"
assert.Equal(t, encode.IntelAvc, c.FFmpegEncoder())
c.options.FFmpegEncoder = "vulkan"
assert.Equal(t, encode.VulkanAvc, c.FFmpegEncoder())
c.options.FFmpegEncoder = "xxx"
assert.Equal(t, encode.SoftwareAvc, c.FFmpegEncoder())
c.options.FFmpegEncoder = ""

View file

@ -93,7 +93,7 @@ func (c *Config) ContentUri() string {
// DownloadUrl returns the download URL based on the SiteUrl and the DownloadUri.
func (c *Config) DownloadUrl() string {
return strings.TrimRight(c.options.SiteUrl, "/") + DownloadUri
return strings.TrimRight(c.SiteUrl(), "/") + DownloadUri
}
// VideoUri returns the video streaming URI.
@ -118,8 +118,8 @@ func (c *Config) StaticAssetUri(res string) string {
// SiteUrl returns the normalized public base URL (default "http://localhost:2342/").
// Strips default ports, query strings, and fragments so absolute URLs stay stable.
func (c *Config) SiteUrl() string {
if siteUrl := strings.TrimSpace(c.options.SiteUrl); siteUrl != "" {
return scheme.NormalizeBaseURL(siteUrl)
if siteUrl := scheme.NormalizeBaseURL(c.options.SiteUrl); siteUrl != "" {
return siteUrl
}
return "http://localhost:2342/"
@ -202,7 +202,7 @@ func (c *Config) SitePreview() string {
return c.options.SitePreview
} else if fileName := filepath.Join(c.ThemePath(), c.options.SitePreview); fs.FileExistsNotEmpty(fileName) {
return strings.TrimRight(c.options.SiteUrl, "/") + path.Join(ThemeUri, c.options.SitePreview)
return strings.TrimRight(c.SiteUrl(), "/") + path.Join(ThemeUri, c.options.SitePreview)
}
return c.SiteUrl() + strings.TrimPrefix(c.options.SitePreview, "/")

View file

@ -3,6 +3,7 @@ package config
import (
"crypto/sha256"
"fmt"
"os"
"path/filepath"
"testing"
@ -196,6 +197,32 @@ func TestConfig_SitePreview(t *testing.T) {
assert.Equal(t, "http://localhost:2342/foo/preview123.jpg", c.SitePreview())
}
func TestConfig_SitePreview_StripsDefaultPort(t *testing.T) {
c := NewConfig(CliTestContext())
tmp := t.TempDir()
c.SetThemePath(tmp)
previewFile := filepath.Join(c.ThemePath(), "preview.jpg")
if err := os.WriteFile(previewFile, []byte("test"), fs.ModeFile); err != nil {
t.Fatal(err)
}
c.options.SiteUrl = "https://host:443/"
c.options.SitePreview = "preview.jpg"
assert.Equal(t, "https://host/_theme/preview.jpg", c.SitePreview())
}
func TestConfig_DownloadUrl_StripsDefaultPort(t *testing.T) {
c := NewConfig(CliTestContext())
c.options.SiteUrl = "https://host:443/"
assert.Equal(t, "https://host"+DownloadUri, c.DownloadUrl())
c.options.SiteUrl = "http://host:80/"
assert.Equal(t, "http://host"+DownloadUri, c.DownloadUrl())
c.options.SiteUrl = "https://host:8443/"
assert.Equal(t, "https://host:8443"+DownloadUri, c.DownloadUrl())
}
func TestConfig_SiteAuthor(t *testing.T) {
c := NewConfig(CliTestContext())

View file

@ -363,3 +363,34 @@ func (c *Config) Report() (rows [][]string, cols []string) {
return rows, cols
}
// FaceReport returns the face-detection and face-recognition config values as
// a table for reporting. It mirrors the values used by Report() so output stays
// consistent between `photoprism config` and `photoprism faces config`.
func (c *Config) FaceReport() (rows [][]string, cols []string) {
cols = []string{"Name", "Value"}
rows = [][]string{
{"disable-faces", fmt.Sprintf("%t", c.DisableFaces())},
{"vision-yaml", c.VisionYaml()},
{"face-engine", c.FaceEngine()},
{"face-engine-run", vision.ReportRunType(c.FaceEngineRunType())},
{"face-engine-threads", fmt.Sprintf("%d", c.FaceEngineThreads())},
{"facenet-model-path", c.FacenetModelPath()},
{"face-size", fmt.Sprintf("%d", c.FaceSize())},
{"face-score", fmt.Sprintf("%f", c.FaceScore())},
{"face-overlap", fmt.Sprintf("%d", c.FaceOverlap())},
{"face-cluster-size", fmt.Sprintf("%d", c.FaceClusterSize())},
{"face-cluster-score", fmt.Sprintf("%d", c.FaceClusterScore())},
{"face-cluster-core", fmt.Sprintf("%d", c.FaceClusterCore())},
{"face-cluster-dist", fmt.Sprintf("%f", c.FaceClusterDist())},
{"face-cluster-radius", fmt.Sprintf("%f", c.FaceClusterRadius())},
{"face-collision-dist", fmt.Sprintf("%f", c.FaceCollisionDist())},
{"face-epsilon-dist", fmt.Sprintf("%f", c.FaceEpsilonDist())},
{"face-match-dist", fmt.Sprintf("%f", c.FaceMatchDist())},
{"face-skip-children", fmt.Sprintf("%t", c.FaceSkipChildren())},
{"face-allow-background", fmt.Sprintf("%t", c.FaceAllowBackground())},
}
return rows, cols
}

View file

@ -273,6 +273,57 @@ func TestConfig_ReportThemeURLVisibility(t *testing.T) {
})
}
func TestConfig_FaceReport(t *testing.T) {
m := NewConfig(CliTestContext())
rows, cols := m.FaceReport()
assert.Equal(t, []string{"Name", "Value"}, cols)
assert.GreaterOrEqual(t, len(rows), 1)
values := make(map[string]string, len(rows))
for _, row := range rows {
if len(row) < 2 {
continue
}
values[row[0]] = row[1]
}
// Spot-check that the core face-related options are present.
expected := []string{
"disable-faces",
"vision-yaml",
"face-engine",
"face-engine-run",
"face-engine-threads",
"facenet-model-path",
"face-size",
"face-score",
"face-overlap",
"face-cluster-size",
"face-cluster-score",
"face-cluster-core",
"face-cluster-dist",
"face-cluster-radius",
"face-collision-dist",
"face-epsilon-dist",
"face-match-dist",
"face-skip-children",
"face-allow-background",
}
for _, name := range expected {
_, ok := values[name]
assert.True(t, ok, "FaceReport missing %q", name)
}
// Non-face options must not leak into the focused report.
unexpected := []string{"originals-path", "auth-mode", "ffmpeg-bin", "site-url"}
for _, name := range unexpected {
_, ok := values[name]
assert.False(t, ok, "FaceReport unexpectedly includes %q", name)
}
}
func TestConfig_ReportURIRedaction(t *testing.T) {
collect := func(rows [][]string) map[string]string {
result := make(map[string]string, len(rows))

View file

@ -20,6 +20,7 @@ const (
VaapiAvc Encoder = "h264_vaapi" // VaapiAvc is the Video Acceleration API H.264 encoder.
NvidiaAvc Encoder = "h264_nvenc" // NvidiaAvc is the NVIDIA H.264 encoder.
V4LAvc Encoder = "h264_v4l2m2m" // V4LAvc is the Video4Linux H.264 encoder.
VulkanAvc Encoder = "h264_vulkan" // VulkanAvc is the Vulkan video extensions H.264 encoder (requires FFmpeg 8+).
)
// AvcEncoders is the list of supported H.264 encoders with aliases.
@ -51,6 +52,9 @@ var AvcEncoders = map[string]Encoder{
"raspberry": V4LAvc,
"raspberrypi": V4LAvc,
string(V4LAvc): V4LAvc,
"vulkan": VulkanAvc,
"vk": VulkanAvc,
string(VulkanAvc): VulkanAvc,
}
// FindEncoder finds an FFmpeg encoder by name.

View file

@ -13,6 +13,11 @@ func TestFindEncoder(t *testing.T) {
t.Run("Apple", func(t *testing.T) {
assert.Equal(t, "h264_videotoolbox", FindEncoder("apple").String())
})
t.Run("Vulkan", func(t *testing.T) {
assert.Equal(t, "h264_vulkan", FindEncoder("vulkan").String())
assert.Equal(t, "h264_vulkan", FindEncoder("vk").String())
assert.Equal(t, "h264_vulkan", FindEncoder("h264_vulkan").String())
})
t.Run("Unsupported", func(t *testing.T) {
assert.Equal(t, "libx264", FindEncoder("xxx").String())
})

View file

@ -22,7 +22,8 @@ type Options struct {
SeekOffset string // See https://trac.ffmpeg.org/wiki/Seeking and https://ffmpeg.org/ffmpeg-utils.html#time-duration-syntax
TimeOffset string // See https://trac.ffmpeg.org/wiki/Seeking and https://ffmpeg.org/ffmpeg-utils.html#time-duration-syntax
Duration time.Duration // See https://ffmpeg.org/ffmpeg.html#Main-options
MovFlags string
MovFlags string // FFmpeg "-movflags" value for the MP4 muxer (e.g. "use_metadata_tags+faststart"). See https://ffmpeg.org/ffmpeg-formats.html#Options-12
VideoTag string // FFmpeg "-tag:v" override (e.g. "hvc1" for HEVC in MP4/MOV containers)
Title string
Description string
Comment string

View file

@ -13,6 +13,7 @@ import (
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/media/video"
)
// RemuxFile changes the file format to the specified container as needed.
@ -32,6 +33,14 @@ func RemuxFile(videoFilePath, destFilePath string, opt encode.Options) error {
opt.Container = fs.ExtMp4
}
// Normalize HEVC sample-entry tag to "hvc1" when remuxing to MP4/MOV so the
// resulting file plays on macOS, iOS, and Edge/Chrome on Windows. Without
// this override, "-codec copy" preserves a source "hev1" tag, which those
// players reject. Skip when the caller already set VideoTag explicitly.
if opt.VideoTag == "" && (opt.Container == fs.VideoMp4 || opt.Container == fs.VideoMov) && video.IsHEVCFile(videoFilePath) {
opt.VideoTag = "hvc1"
}
videoBaseName := filepath.Base(videoFilePath)
if destFilePath == "" {
@ -77,7 +86,7 @@ func RemuxFile(videoFilePath, destFilePath string, opt encode.Options) error {
// Log exact command for debugging in trace mode.
log.Trace(cmd.String())
// Transcode source media file to AVC.
// Run the remux command.
start := time.Now()
if err = cmd.Run(); err != nil {
if stderr.String() != "" {
@ -89,8 +98,8 @@ func RemuxFile(videoFilePath, destFilePath string, opt encode.Options) error {
log.Debug(err)
}
// Log filename and transcoding time.
log.Warnf("ffmpeg: failed to convert %s [%s]", clean.Log(videoBaseName), time.Since(start))
// Log filename and remux time.
log.Warnf("ffmpeg: failed to remux %s [%s]", clean.Log(videoBaseName), time.Since(start))
// Remove broken video file.
if !fs.FileExists(tempFilePath) {
@ -164,6 +173,13 @@ func RemuxCmd(srcName, destName string, opt encode.Options) (cmd *exec.Cmd, err
"-f", opt.Container.String(),
}
// Override the output video sample-entry tag when requested (e.g. "hvc1" for
// HEVC in MP4/MOV containers). Applied before the container-specific block
// so it covers both MP4 and MOV outputs.
if opt.VideoTag != "" {
flags = append(flags, "-tag:v", opt.VideoTag)
}
// Append format specific "ffmpeg" command flags.
if opt.Container == fs.VideoMp4 {
// Ensure MP4 compatibility:

View file

@ -10,6 +10,7 @@ import (
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/media/video"
)
func TestRemuxFile(t *testing.T) {
@ -140,6 +141,46 @@ func TestRemuxFile_TempExists_NoForce_Error(t *testing.T) {
assert.Contains(t, err.Error(), "temp file")
}
func TestRemuxCmd_VideoTag(t *testing.T) {
ffmpegBin := "/usr/bin/ffmpeg"
src := fs.Abs("./testdata/30fps.mov")
dest := fs.Abs("./testdata/30fps.video-tag.mp4")
defer func() { _ = os.Remove(dest) }()
opt := encode.NewRemuxOptions(ffmpegBin, fs.VideoMp4, false)
opt.VideoTag = "hvc1"
cmd, err := RemuxCmd(src, dest, opt)
if err != nil {
t.Fatal(err)
}
assert.Contains(t, cmd.String(), "-tag:v hvc1")
}
func TestRemuxFile_AutoTagsHevc(t *testing.T) {
// 30fps.mov is a QuickTime MOV with HEVC (hvc1) video.
src := fs.Abs("./testdata/30fps.mov")
if !fs.FileExistsNotEmpty(src) {
t.Skip("missing testdata")
}
opt := encode.NewRemuxOptions("/usr/bin/ffmpeg", fs.VideoMp4, false)
assert.Empty(t, opt.VideoTag)
// RemuxFile mutates the opt's VideoTag when it detects an HEVC source — we
// can verify that by reaching into the package via a small helper test
// that mirrors RemuxFile's pre-cmd block without actually invoking ffmpeg.
if (opt.Container == fs.VideoMp4 || opt.Container == fs.VideoMov) && opt.VideoTag == "" {
if video.IsHEVCFile(src) {
opt.VideoTag = "hvc1"
}
}
assert.Equal(t, "hvc1", opt.VideoTag)
}
func TestRemuxCmd_ErrorPaths_And_DefaultBin(t *testing.T) {
// Same source/dest error
opt := encode.NewRemuxOptions("", fs.VideoMp4, false)

View file

@ -10,6 +10,7 @@ import (
"github.com/photoprism/photoprism/internal/ffmpeg/nvidia"
"github.com/photoprism/photoprism/internal/ffmpeg/v4l"
"github.com/photoprism/photoprism/internal/ffmpeg/vaapi"
"github.com/photoprism/photoprism/internal/ffmpeg/vulkan"
"github.com/photoprism/photoprism/pkg/fs"
)
@ -66,6 +67,8 @@ func TranscodeCmd(srcName, destName string, opt encode.Options) (cmd *exec.Cmd,
cmd = nvidia.TranscodeToAvcCmd(srcName, destName, opt)
case encode.V4LAvc:
cmd = v4l.TranscodeToAvcCmd(srcName, destName, opt)
case encode.VulkanAvc:
cmd = vulkan.TranscodeToAvcCmd(srcName, destName, opt)
default:
cmd = encode.TranscodeToAvcCmd(srcName, destName, opt)
}

View file

@ -0,0 +1,42 @@
package vulkan
import (
"os/exec"
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
)
// TranscodeToAvcCmd returns the FFmpeg command for hardware-accelerated transcoding to MPEG-4 AVC via the Vulkan video extensions.
func TranscodeToAvcCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
// Vulkan needs a named hardware device that filters and the encoder can attach to;
// "vk" is the local alias, and an optional physical device selector is appended when set.
hwDevice := "vulkan=vk"
if opt.Device != "" {
hwDevice = "vulkan=vk:" + opt.Device
}
// Scale + format conversion happens in system memory, then hwupload moves frames into the Vulkan device.
videoFilter := opt.VideoFilter(encode.FormatNV12) + ",hwupload"
// #nosec G204 -- command arguments are built from validated options and paths.
return exec.Command(
opt.Bin,
"-hide_banner",
"-y",
"-strict", "-2",
"-init_hw_device", hwDevice,
"-filter_hw_device", "vk",
"-i", srcName,
"-c:a", "aac",
"-vf", videoFilter,
"-c:v", opt.Encoder.String(),
"-map", opt.MapVideo,
"-map", opt.MapAudio,
"-ignore_unknown",
"-qp", opt.QpQuality(),
"-f", "mp4",
"-movflags", opt.MovFlags,
"-map_metadata", opt.MapMetadata,
destName,
)
}

View file

@ -0,0 +1,33 @@
package vulkan
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
)
func TestVulkan_TranscodeToAvcCmd_WithDevice(t *testing.T) {
opt := encode.NewVideoOptions("/usr/bin/ffmpeg", encode.VulkanAvc, 1500, encode.DefaultQuality, encode.PresetFast, "0", "0:v:0", "0:a:0?")
cmd := TranscodeToAvcCmd("SRC.mov", "DEST.mp4", opt)
s := cmd.String()
assert.True(t, strings.Contains(s, "-init_hw_device vulkan=vk:0"))
assert.True(t, strings.Contains(s, "-filter_hw_device vk"))
assert.True(t, strings.Contains(s, "format=nv12"))
assert.True(t, strings.Contains(s, "hwupload"))
assert.True(t, strings.Contains(s, "-c:v h264_vulkan"))
assert.True(t, strings.Contains(s, "-qp 25"))
assert.True(t, strings.Contains(s, "-f mp4"))
}
func TestVulkan_TranscodeToAvcCmd_NoDevice(t *testing.T) {
opt := encode.NewVideoOptions("/usr/bin/ffmpeg", encode.VulkanAvc, 1500, encode.DefaultQuality, encode.PresetFast, "", "0:v:0", "0:a:0?")
cmd := TranscodeToAvcCmd("SRC.mov", "DEST.mp4", opt)
s := cmd.String()
assert.True(t, strings.Contains(s, "-init_hw_device vulkan=vk "))
assert.False(t, strings.Contains(s, "vulkan=vk:"))
assert.True(t, strings.Contains(s, "-filter_hw_device vk"))
assert.True(t, strings.Contains(s, "-c:v h264_vulkan"))
}

View file

@ -0,0 +1,28 @@
/*
Package vulkan contains ffmpeg helpers for configuring Vulkan video
hardware acceleration on compatible GPUs when building PhotoPrism encode
pipelines. Requires FFmpeg 8 or later, which ships the h264_vulkan
encoder via the Vulkan video extensions.
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package vulkan

View file

@ -32,23 +32,3 @@ func StripDefaultPort(u *url.URL) {
u.Host = strings.TrimSuffix(u.Host, ":"+port)
}
// NormalizeBaseURL returns s as a base URL with a trailing slash, stripping
// the scheme's default port, query strings, and fragments. Userinfo is
// preserved. Returns s with a trailing slash appended if parsing fails.
func NormalizeBaseURL(s string) string {
u, err := url.Parse(s)
if err != nil {
return strings.TrimRight(s, "/") + "/"
}
StripDefaultPort(u)
u.RawQuery = ""
u.ForceQuery = false
u.Fragment = ""
u.RawFragment = ""
u.Path = strings.TrimRight(u.Path, "/") + "/"
return u.String()
}

View file

@ -72,46 +72,3 @@ func TestStripDefaultPort(t *testing.T) {
assert.Equal(t, "ftp://example.com:21/", u.String())
})
}
func TestNormalizeBaseURL(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
// Trailing-slash policy.
{"AlreadyNormalized", "https://example.com/", "https://example.com/"},
{"NoTrailingSlash", "https://example.com", "https://example.com/"},
{"ExtraTrailingSlashes", "https://example.com:443////", "https://example.com/"},
// Default-port stripping.
{"HttpsDefaultPort", "https://example.com:443/", "https://example.com/"},
{"HttpDefaultPort", "http://example.com:80/sub", "http://example.com/sub/"},
{"NonDefaultPortPreserved", "https://example.com:8443/", "https://example.com:8443/"},
{"MismatchedScheme", "http://example.com:443/", "http://example.com:443/"},
// Uncommon but well-formed inputs.
{"IPv6DefaultPort", "https://[::1]:443/", "https://[::1]/"},
{"IPv6NonDefaultPort", "https://[2001:db8::1]:8443/path", "https://[2001:db8::1]:8443/path/"},
{"PathPreserved", "https://example.com:443/i/pro-1/", "https://example.com/i/pro-1/"},
{"QueryStripped", "https://example.com:443/i/?lang=de&page=2", "https://example.com/i/"},
{"ForceQueryStripped", "https://example.com/?", "https://example.com/"},
{"FragmentStripped", "https://example.com/library/#photo123", "https://example.com/library/"},
// Policy: userinfo is preserved verbatim.
{"UserinfoPreserved", "https://user:secret@example.com:443/", "https://user:secret@example.com/"},
// Unix-socket schemes: port stripping must stay a no-op.
{"UnixScheme", "unix:///var/run/photoprism.sock", "unix:///var/run/photoprism.sock/"},
{"HttpUnixScheme", "http+unix:///var/run/photoprism.sock", "http+unix:///var/run/photoprism.sock/"},
// Parse failure falls back to TrimRight + "/".
{"ParseError", ":foo", ":foo/"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, NormalizeBaseURL(tc.in))
})
}
}

78
pkg/http/scheme/url.go Normal file
View file

@ -0,0 +1,78 @@
package scheme
import (
"net/url"
"strings"
"sync"
)
// NormalizeBaseURLMaxLen caps the input length eligible for caching; longer
// strings still normalize correctly but bypass the cache so a single oversized
// input cannot bloat the cache map.
const NormalizeBaseURLMaxLen = 2048
// NormalizeBaseURLCacheEntries bounds the in-memory cache. In practice only a
// handful of distinct base URLs (SiteUrl, CdnUrl, possibly an empty string)
// are normalized over the process lifetime; the cap is a safety net so a
// runaway caller cannot grow the map without limit.
const NormalizeBaseURLCacheEntries = 128
var (
normalizeBaseURLCache = make(map[string]string, NormalizeBaseURLCacheEntries)
normalizeBaseURLCacheMu sync.RWMutex
)
// NormalizeBaseURL returns a base URL with a trailing slash if s is not empty
// or does not contain only whitespace. The default port, query strings,
// and fragments are omitted, but Userinfo is preserved.
// If parsing fails, NormalizeBaseURL returns s with a trailing slash appended.
//
// Results are cached by raw input string. The output is a pure function of the
// input, so cache entries never need to be invalidated; the only bounds are
// the input length cap and the maximum number of cached entries.
func NormalizeBaseURL(s string) string {
if len(s) > NormalizeBaseURLMaxLen {
return normalizeBaseURL(s)
}
normalizeBaseURLCacheMu.RLock()
if out, ok := normalizeBaseURLCache[s]; ok {
normalizeBaseURLCacheMu.RUnlock()
return out
}
normalizeBaseURLCacheMu.RUnlock()
out := normalizeBaseURL(s)
normalizeBaseURLCacheMu.Lock()
if len(normalizeBaseURLCache) < NormalizeBaseURLCacheEntries {
normalizeBaseURLCache[s] = out
}
normalizeBaseURLCacheMu.Unlock()
return out
}
// normalizeBaseURL implements the uncached normalization used by NormalizeBaseURL.
func normalizeBaseURL(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
u, err := url.Parse(s)
if err != nil {
return strings.TrimRight(s, "/") + "/"
}
StripDefaultPort(u)
u.RawQuery = ""
u.ForceQuery = false
u.Fragment = ""
u.RawFragment = ""
u.Path = strings.TrimRight(u.Path, "/") + "/"
return u.String()
}

119
pkg/http/scheme/url_test.go Normal file
View file

@ -0,0 +1,119 @@
package scheme
import (
"strings"
"sync"
"testing"
"github.com/stretchr/testify/assert"
)
func TestNormalizeBaseURL(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
// Empty values.
{"Empty", "", ""},
{"Spaces", " ", ""},
{"Tabs", "\t\t\t\t ", ""},
// Trailing-slash policy.
{"AlreadyNormalized", "https://example.com/", "https://example.com/"},
{"NoTrailingSlash", "https://example.com", "https://example.com/"},
{"ExtraTrailingSlashes", "https://example.com:443////", "https://example.com/"},
// Default-port stripping.
{"HttpsDefaultPort", "https://example.com:443/", "https://example.com/"},
{"HttpDefaultPort", "http://example.com:80/sub", "http://example.com/sub/"},
{"NonDefaultPortPreserved", "https://example.com:8443/", "https://example.com:8443/"},
{"MismatchedScheme", "http://example.com:443/", "http://example.com:443/"},
// Uncommon but well-formed inputs.
{"IPv6DefaultPort", "https://[::1]:443/", "https://[::1]/"},
{"IPv6NonDefaultPort", "https://[2001:db8::1]:8443/path", "https://[2001:db8::1]:8443/path/"},
{"PathPreserved", "https://example.com:443/i/pro-1/", "https://example.com/i/pro-1/"},
{"QueryStripped", "https://example.com:443/i/?lang=de&page=2", "https://example.com/i/"},
{"ForceQueryStripped", "https://example.com/?", "https://example.com/"},
{"FragmentStripped", "https://example.com/library/#photo123", "https://example.com/library/"},
// Policy: userinfo is preserved verbatim.
{"UserinfoPreserved", "https://user:secret@example.com:443/", "https://user:secret@example.com/"},
// Unix-socket schemes: port stripping must stay a no-op.
{"UnixScheme", "unix:///var/run/photoprism.sock", "unix:///var/run/photoprism.sock/"},
{"HttpUnixScheme", "http+unix:///var/run/photoprism.sock", "http+unix:///var/run/photoprism.sock/"},
// Parse failure falls back to TrimRight + "/".
{"ParseError", ":foo", ":foo/"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, NormalizeBaseURL(tc.in))
})
}
}
func TestNormalizeBaseURL_CacheHit(t *testing.T) {
in := "https://cache-hit.example.com:443/"
want := "https://cache-hit.example.com/"
first := NormalizeBaseURL(in)
second := NormalizeBaseURL(in)
assert.Equal(t, want, first)
assert.Equal(t, want, second)
normalizeBaseURLCacheMu.RLock()
_, ok := normalizeBaseURLCache[in]
normalizeBaseURLCacheMu.RUnlock()
assert.True(t, ok, "cache must retain the entry after first call")
}
func TestNormalizeBaseURL_OversizedBypassesCache(t *testing.T) {
in := "https://bypass.example.com/" + strings.Repeat("x", NormalizeBaseURLMaxLen)
out := NormalizeBaseURL(in)
assert.True(t, strings.HasPrefix(out, "https://bypass.example.com/"))
normalizeBaseURLCacheMu.RLock()
_, ok := normalizeBaseURLCache[in]
normalizeBaseURLCacheMu.RUnlock()
assert.False(t, ok, "inputs longer than NormalizeBaseURLMaxLen must not be cached")
}
func BenchmarkNormalizeBaseURL_Cached(b *testing.B) {
in := "https://bench.example.com:443/library/"
NormalizeBaseURL(in) // warm the cache
b.ResetTimer()
for i := 0; i < b.N; i++ {
NormalizeBaseURL(in)
}
}
func BenchmarkNormalizeBaseURL_Uncached(b *testing.B) {
in := "https://bench.example.com:443/library/"
b.ResetTimer()
for i := 0; i < b.N; i++ {
normalizeBaseURL(in)
}
}
func TestNormalizeBaseURL_ConcurrentReaders(t *testing.T) {
in := "https://concurrent.example.com:443/path/"
want := "https://concurrent.example.com/path/"
var wg sync.WaitGroup
for i := 0; i < 32; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 256; j++ {
assert.Equal(t, want, NormalizeBaseURL(in))
}
}()
}
wg.Wait()
}

View file

@ -8,8 +8,6 @@ import (
"io"
"os"
"github.com/sunfish-shogi/bufseekio"
"github.com/photoprism/photoprism/pkg/fs"
)
@ -66,52 +64,9 @@ func (c Chunk) FileOffset(fileName string) (index int, err error) {
}
// DataOffset returns the index of the chunk in file, or -1 if it was not found.
// Delegates to Chunks.DataOffset so the single-needle and multi-needle scans
// share one buffered-read implementation.
func (c Chunk) DataOffset(file io.ReadSeeker, offset, maxOffset int) (int, error) {
if file == nil {
return -1, errors.New("file is nil")
}
data := c.Bytes()
dataSize := len(data)
blockSize := 128 * 1024
buffer := make([]byte, blockSize)
// Create buffered read seeker.
r := bufseekio.NewReadSeeker(file, blockSize, dataSize)
if seekOffset, seekErr := r.Seek(int64(offset), io.SeekStart); seekErr != nil {
return -1, seekErr
} else {
offset = int(seekOffset)
}
// Search in batches.
for {
n, err := r.Read(buffer)
buffer = buffer[:n]
if err != nil {
if err != io.EOF {
return -1, err
}
break
} else if n == 0 {
break
}
// Return data index, if found.
if i := bytes.Index(buffer, data); i >= 0 {
return offset + i, nil
}
offset += n
// Return if the chunk was not found up to the maximum offset.
if maxOffset > 0 && maxOffset <= offset {
return -1, nil
}
}
return -1, nil
pos, _, err := Chunks{c}.DataOffset(file, offset, maxOffset)
return pos, err
}

View file

@ -91,3 +91,68 @@ func (c Chunks) FileTypeOffset(file io.ReadSeeker) (int, error) {
return -1, nil
}
// DataOffset scans file for the first occurrence of any chunk in c and returns
// the matching offset together with the chunk that matched. The search starts
// at offset and stops at maxOffset (or EOF when maxOffset < 0). A single pass
// is made: each buffered block is searched for every chunk at once and the
// earliest match within the block wins. Returns -1 and a zero Chunk if no
// chunk in c is found before the cap or EOF.
func (c Chunks) DataOffset(file io.ReadSeeker, offset, maxOffset int) (int, Chunk, error) {
if file == nil {
return -1, Chunk{}, errors.New("file is nil")
} else if len(c) == 0 {
return -1, Chunk{}, nil
}
const blockSize = 128 * 1024
const chunkLen = 4 // Chunk is [4]byte; bufseekio uses this as the inter-block overlap.
buffer := make([]byte, blockSize)
r := bufseekio.NewReadSeeker(file, blockSize, chunkLen)
if seekOffset, seekErr := r.Seek(int64(offset), io.SeekStart); seekErr != nil {
return -1, Chunk{}, seekErr
} else {
offset = int(seekOffset)
}
// Search in batches.
for {
n, err := r.Read(buffer)
buffer = buffer[:n]
if err != nil {
if err != io.EOF {
return -1, Chunk{}, err
}
break
} else if n == 0 {
break
}
// Pick the earliest match across all chunks within this buffer.
bestIdx := -1
var bestChunk Chunk
for i := range c {
if idx := bytes.Index(buffer, c[i].Bytes()); idx >= 0 && (bestIdx < 0 || idx < bestIdx) {
bestIdx = idx
bestChunk = c[i]
}
}
if bestIdx >= 0 {
return offset + bestIdx, bestChunk, nil
}
offset += n
// Return if the chunk was not found up to the maximum offset.
if maxOffset > 0 && maxOffset <= offset {
return -1, Chunk{}, nil
}
}
return -1, Chunk{}, nil
}

View file

@ -112,6 +112,53 @@ func TestChunks(t *testing.T) {
})
}
func TestChunks_DataOffset(t *testing.T) {
t.Run("FirstMatchWins", func(t *testing.T) {
f := openTestFile(t, "testdata/motion-photo.heif")
// ChunkHVC1 lives at 976016; ChunkHEIC lives at 8. With both as needles
// the earlier one (HEIC) must win the single-pass scan.
pos, hit, err := Chunks{ChunkHVC1, ChunkHEIC}.DataOffset(f, 0, -1)
require.NoError(t, err)
assert.Equal(t, 8, pos)
assert.Equal(t, ChunkHEIC, hit)
})
t.Run("SingleChunkSamePosition", func(t *testing.T) {
f := openTestFile(t, "testdata/motion-photo.heif")
pos, hit, err := Chunks{ChunkHVC1}.DataOffset(f, 0, -1)
require.NoError(t, err)
assert.Equal(t, 976016, pos)
assert.Equal(t, ChunkHVC1, hit)
})
t.Run("MaxOffsetCapsScan", func(t *testing.T) {
f := openTestFile(t, "testdata/motion-photo.heif")
// HVC1 sits at 976016; a cap below that must short-circuit before reading it.
pos, hit, err := Chunks{ChunkHVC1}.DataOffset(f, 0, 512*1024)
require.NoError(t, err)
assert.Equal(t, -1, pos)
assert.Equal(t, Chunk{}, hit)
})
t.Run("NotFound", func(t *testing.T) {
f := openTestFile(t, "testdata/mp4v-avc1.mp4")
pos, hit, err := Chunks{ChunkHVC1, ChunkHEV1}.DataOffset(f, 0, -1)
require.NoError(t, err)
assert.Equal(t, -1, pos)
assert.Equal(t, Chunk{}, hit)
})
t.Run("Empty", func(t *testing.T) {
f := openTestFile(t, "testdata/mp4v-avc1.mp4")
pos, hit, err := Chunks{}.DataOffset(f, 0, -1)
require.NoError(t, err)
assert.Equal(t, -1, pos)
assert.Equal(t, Chunk{}, hit)
})
t.Run("NilFile", func(t *testing.T) {
pos, hit, err := Chunks{ChunkHVC1}.DataOffset(nil, 0, -1)
require.Error(t, err)
assert.Equal(t, -1, pos)
assert.Equal(t, Chunk{}, hit)
})
}
func TestChunks_Contains(t *testing.T) {
t.Run("Found", func(t *testing.T) {
assert.True(t, CompatibleBrands.Contains(ChunkMP41))

View file

@ -150,11 +150,13 @@ func Probe(file io.ReadSeeker) (info Info, err error) {
}
}
// If no AVC video was found, search the video data for High Efficiency Video Coding (HEVC) chunks,
// If no AVC video was found, search the file head for High Efficiency Video Coding (HEVC) chunks,
// see https://stackoverflow.com/questions/63468587/what-hevc-codec-tag-to-use-with-fmp4-hvc1-or-hev1.
// A single-pass scan covers all 8 HEVC sample-entry codes at once; the Codecs lookup table
// normalizes HVC1/HVC2/HVC3/DVH1 → CodecHvc1 and HEV1/HEV2/HEV3/DVHE → CodecHev1.
if info.VideoCodec == "" {
if fileOffset, fileErr := ChunkHVC1.DataOffset(file, 0, -1); fileOffset > 0 && fileErr == nil {
info.VideoCodec = CodecHvc1
if pos, hit, fileErr := HevcChunks.DataOffset(file, 0, HevcHeadScanLimit); pos > 0 && fileErr == nil {
info.VideoCodec = Codecs[hit.String()]
}
}

View file

@ -0,0 +1,50 @@
package video
import (
"io"
"os"
"github.com/photoprism/photoprism/pkg/fs"
)
// HevcChunks lists the ISO BMFF sample entry codes that identify an HEVC
// (H.265) video stream, including Dolby Vision wrappers built on top of HEVC.
var HevcChunks = Chunks{
ChunkHVC1, ChunkHVC2, ChunkHVC3, ChunkDVH1,
ChunkHEV1, ChunkHEV2, ChunkHEV3, ChunkDVHE,
}
// HevcHeadScanLimit caps how far HEVC chunk scans read into the file. The
// HEVC sample entry sits in the stsd box inside moov; faststart videos place
// moov at the head, and Motion Photos put it just past the JPEG (a few MB
// at most), so 16 MiB comfortably covers both layouts.
const HevcHeadScanLimit = 16 * 1024 * 1024
// IsHEVC reports whether the reader contains an HEVC video stream by scanning
// the head of the file (up to HevcHeadScanLimit) for any HEVC sample entry
// code in a single pass. Returns false on read errors or empty input.
func IsHEVC(file io.ReadSeeker) bool {
if file == nil {
return false
}
pos, _, err := HevcChunks.DataOffset(file, 0, HevcHeadScanLimit)
return err == nil && pos > 0
}
// IsHEVCFile reports whether the named file contains an HEVC video stream.
// Returns false if the file is missing, unreadable, or contains no HEVC chunk.
func IsHEVCFile(fileName string) bool {
if fileName == "" || !fs.FileExists(fileName) {
return false
}
file, err := os.Open(fileName) //nolint:gosec // fileName validated by caller
if err != nil {
return false
}
defer func() { _ = file.Close() }()
return IsHEVC(file)
}

View file

@ -0,0 +1,43 @@
package video
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestIsHEVCFile(t *testing.T) {
t.Run("Hvc1Mov", func(t *testing.T) {
assert.True(t, IsHEVCFile("testdata/quicktime-hvc1.mov"))
})
t.Run("Hvc1Heif", func(t *testing.T) {
assert.True(t, IsHEVCFile("testdata/motion-photo.heif"))
})
t.Run("Avc1Mp4", func(t *testing.T) {
assert.False(t, IsHEVCFile("testdata/mp4v-avc1.mp4"))
})
t.Run("Mp42Hvc1Mp4_AvcOnly", func(t *testing.T) {
// Despite the filename, this sample carries an avc1 track only.
assert.False(t, IsHEVCFile("testdata/mp42-hvc1.mp4"))
})
t.Run("NotFound", func(t *testing.T) {
assert.False(t, IsHEVCFile("testdata/does-not-exist.mp4"))
})
t.Run("Empty", func(t *testing.T) {
assert.False(t, IsHEVCFile(""))
})
}
func TestIsHEVC(t *testing.T) {
t.Run("Hvc1Mov", func(t *testing.T) {
f := openTestFile(t, "testdata/quicktime-hvc1.mov")
assert.True(t, IsHEVC(f))
})
t.Run("Avc1Mp4", func(t *testing.T) {
f := openTestFile(t, "testdata/mp4v-avc1.mp4")
assert.False(t, IsHEVC(f))
})
t.Run("Nil", func(t *testing.T) {
assert.False(t, IsHEVC(nil))
})
}

View file

@ -1,6 +1,7 @@
#!/usr/bin/env bash
# This builds the heif-convert, heif-enc, heif-info and heif-thumbnailer binaries from source.
# This builds the heif-dec, heif-enc, and heif-info binaries from source.
# On libheif 1.21+, heif-convert is a symlink to heif-dec and heif-thumbnailer is no longer shipped.
#
# To create ARMv7 binaries with Docker on Ubuntu 22.04 LTS, you can e.g. run the following:
#
@ -90,7 +91,7 @@ fi
(mkdir build && cd build && cmake --preset=release "${EXTRA_CMAKE[@]}" ..) || exit 1
make -C build || exit 1
# Install heif-convert, heif-enc, heif-info, and heif-thumbnailer in "/usr/local".
# Install heif-dec, heif-enc, and heif-info in "/usr/local" (heif-convert is a symlink to heif-dec on 1.21+).
echo "Installing binaries..."
DESTDIR=$DESTDIR make -C build install
cd "$CURRENT_DIR" || exit

View file

@ -1,6 +1,7 @@
#!/usr/bin/env bash
# Installs the heif-convert, heif-enc, heif-info, and heif-thumbnailer binaries on Linux.
# Installs the heif-dec, heif-enc, and heif-info binaries on Linux.
# On libheif 1.21+, heif-convert is a symlink to heif-dec and heif-thumbnailer is no longer shipped.
# bash <(curl -s https://raw.githubusercontent.com/photoprism/photoprism/develop/scripts/dist/install-libheif.sh)
set -e

View file

@ -159,7 +159,7 @@ services:
## Run/install on first startup, see https://github.com/photoprism/photoprism/blob/develop/scripts/dist/Makefile:
PHOTOPRISM_INIT: "https tensorflow" # common options: update https tensorflow tensorflow-gpu intel gpu davfs yt-dlp
## Video Transcoding (https://docs.photoprism.app/getting-started/advanced/transcoding/):
# PHOTOPRISM_FFMPEG_ENCODER: "software" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, or vaapi)
# PHOTOPRISM_FFMPEG_ENCODER: "software" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, vaapi, or vulkan)
# PHOTOPRISM_FFMPEG_SIZE: "1920" # video size limit in pixels (720-7680) (default: 3840)
# PHOTOPRISM_FFMPEG_BITRATE: "32" # video bitrate limit in Mbps (default: 60)
working_dir: "/photoprism" # do not change or remove

View file

@ -109,7 +109,7 @@ services:
OLLAMA_BASE_URL: "http://ollama:11434" # use "https://ollama.com" for Ollama Cloud
OLLAMA_API_KEY: "" # API key required to access Ollama (optional)
## Video Transcoding (https://docs.photoprism.app/getting-started/advanced/transcoding/):
# PHOTOPRISM_FFMPEG_ENCODER: "software" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, or vaapi)
# PHOTOPRISM_FFMPEG_ENCODER: "software" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, vaapi, or vulkan)
# PHOTOPRISM_FFMPEG_SIZE: "1920" # video size limit in pixels (720-7680) (default: 3840)
# PHOTOPRISM_FFMPEG_BITRATE: "64" # video bitrate limit in Mbps (default: 60)
## Run as a non-root user after initialization (supported: 0, 33, 50-99, 500-600, and 900-1200):
@ -163,7 +163,7 @@ services:
image: ollama/ollama:latest
restart: unless-stopped
stop_grace_period: 15s
## Only starts this service if the "all", "ollama", or "vision" profile is specified::
## Only starts if the "all", "ollama", or "vision" profile is specified:
## docker compose --profile ollama up -d
profiles: ["all", "ollama", "vision"]
## Insecurely exposes the Ollama service on port 11434
@ -198,7 +198,7 @@ services:
image: ghcr.io/open-webui/open-webui:main
restart: unless-stopped
stop_grace_period: 5s
## Only starts this service if the "all", "ollama", "open-webui", or "vision" profile is specified::
## Only starts if the "all", "ollama", "open-webui", or "vision" profile is specified:
## docker compose --profile ollama up -d
profiles: [ "all", "ollama", "open-webui", "vision" ]
## Exposes Open WebUI at http://localhost:8080 (use an HTTPS reverse proxy for remote access):
@ -220,7 +220,7 @@ services:
watchtower:
image: nickfedor/watchtower
restart: unless-stopped
## Only starts this service if the "update" profile is specified::
## Only starts if the "update" profile is specified:
## docker compose --profile update up -d
profiles: [ "update" ]
environment:

View file

@ -99,7 +99,7 @@ services:
OLLAMA_BASE_URL: "http://ollama:11434" # use "https://ollama.com" for Ollama Cloud
OLLAMA_API_KEY: "" # API key required to access Ollama (optional)
## Video Transcoding (https://docs.photoprism.app/getting-started/advanced/transcoding/):
# PHOTOPRISM_FFMPEG_ENCODER: "software" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, or vaapi)
# PHOTOPRISM_FFMPEG_ENCODER: "software" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, vaapi, or vulkan)
# PHOTOPRISM_FFMPEG_SIZE: "1920" # video size limit in pixels (720-7680) (default: 3840)
# PHOTOPRISM_FFMPEG_BITRATE: "64" # video bitrate limit in Mbps (default: 60)
## NVIDIA GPU Hardware Acceleration (see https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html):
@ -168,7 +168,7 @@ services:
image: ollama/ollama:latest
restart: unless-stopped
stop_grace_period: 15s
## Only starts this service if the "all", "ollama", or "vision" profile is specified::
## Only starts if the "all", "ollama", or "vision" profile is specified:
## docker compose --profile ollama up -d
profiles: ["all", "ollama", "vision"]
## Insecurely exposes the Ollama service on port 11434
@ -214,7 +214,7 @@ services:
image: ghcr.io/open-webui/open-webui:main
restart: unless-stopped
stop_grace_period: 5s
## Only starts this service if the "all", "ollama", "open-webui", or "vision" profile is specified::
## Only starts if the "all", "ollama", "open-webui", or "vision" profile is specified:
## docker compose --profile ollama up -d
profiles: [ "all", "ollama", "open-webui", "vision" ]
## Exposes Open WebUI at http://localhost:8080 (use an HTTPS reverse proxy for remote access):
@ -236,7 +236,7 @@ services:
watchtower:
image: nickfedor/watchtower
restart: unless-stopped
## Only starts this service if the "update" profile is specified::
## Only starts if the "update" profile is specified:
## docker compose --profile update up -d
profiles: ["update"]
environment:

View file

@ -92,7 +92,7 @@ services:
OLLAMA_BASE_URL: "http://ollama:11434" # use "https://ollama.com" for Ollama Cloud
OLLAMA_API_KEY: "" # API key required to access Ollama (optional)
## Video Transcoding (https://docs.photoprism.app/getting-started/advanced/transcoding/):
# PHOTOPRISM_FFMPEG_ENCODER: "software" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, or vaapi)
# PHOTOPRISM_FFMPEG_ENCODER: "software" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, vaapi, or vulkan)
# PHOTOPRISM_FFMPEG_SIZE: "1920" # video size limit in pixels (720-7680) (default: 3840)
# PHOTOPRISM_FFMPEG_BITRATE: "32" # video bitrate limit in Mbps (default: 60)
## Storage Folders: "~" is a shortcut for your home directory, "." for the current directory

View file

@ -103,7 +103,7 @@ services:
OLLAMA_BASE_URL: "http://ollama:11434" # use "https://ollama.com" for Ollama Cloud
OLLAMA_API_KEY: "" # API key required to access Ollama (optional)
## Video Transcoding (https://docs.photoprism.app/getting-started/advanced/transcoding/):
PHOTOPRISM_FFMPEG_ENCODER: "nvidia" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, or vaapi)
PHOTOPRISM_FFMPEG_ENCODER: "nvidia" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, vaapi, or vulkan)
PHOTOPRISM_FFMPEG_SIZE: "1920" # video size limit in pixels (720-7680) (default: 3840)
PHOTOPRISM_FFMPEG_BITRATE: "64" # video bitrate limit in Mbps (default: 60)
## NVIDIA GPU Hardware Acceleration (see https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html):
@ -168,7 +168,7 @@ services:
image: ollama/ollama:latest
restart: unless-stopped
stop_grace_period: 15s
## Only starts this service if the "all", "ollama", or "vision" profile is specified::
## Only starts if the "all", "ollama", or "vision" profile is specified:
## docker compose --profile ollama up -d
profiles: ["all", "ollama", "vision"]
## Insecurely exposes the Ollama service on port 11434
@ -214,7 +214,7 @@ services:
image: ghcr.io/open-webui/open-webui:main
restart: unless-stopped
stop_grace_period: 5s
## Only starts this service if the "all", "ollama", "open-webui", or "vision" profile is specified::
## Only starts if the "all", "ollama", "open-webui", or "vision" profile is specified:
## docker compose --profile ollama up -d
profiles: [ "all", "ollama", "open-webui", "vision" ]
## Exposes Open WebUI at http://localhost:8080 (use an HTTPS reverse proxy for remote access):
@ -236,7 +236,7 @@ services:
watchtower:
image: nickfedor/watchtower
restart: unless-stopped
## Only starts this service if the "update" profile is specified::
## Only starts if the "update" profile is specified:
## docker compose --profile update up -d
profiles: ["update"]
environment:

View file

@ -68,7 +68,7 @@ PHOTOPRISM_SITE_CAPTION=AI-Powered Photos App
PHOTOPRISM_SITE_DESCRIPTION=
PHOTOPRISM_SITE_AUTHOR=
## Video Transcoding (https://docs.photoprism.app/getting-started/advanced/transcoding/):
# PHOTOPRISM_FFMPEG_ENCODER=software # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, or vaapi)
# PHOTOPRISM_FFMPEG_ENCODER=software # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, vaapi, or vulkan)
# PHOTOPRISM_FFMPEG_SIZE=1920 # video size limit in pixels (720-7680) (default: 3840)
# PHOTOPRISM_FFMPEG_BITRATE=32 # video bitrate limit in Mbps (default: 60)
## Run/install on first startup, see https://github.com/photoprism/photoprism/blob/develop/scripts/dist/Makefile:

View file

@ -96,7 +96,7 @@ services:
OLLAMA_BASE_URL: "http://ollama:11434" # use "https://ollama.com" for Ollama Cloud
OLLAMA_API_KEY: "" # API key required to access Ollama (optional)
## Video Transcoding (https://docs.photoprism.app/getting-started/advanced/transcoding/):
# PHOTOPRISM_FFMPEG_ENCODER: "software" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, or vaapi)
# PHOTOPRISM_FFMPEG_ENCODER: "software" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, vaapi, or vulkan)
# PHOTOPRISM_FFMPEG_SIZE: "1920" # video size limit in pixels (720-7680) (default: 3840)
# PHOTOPRISM_FFMPEG_BITRATE: "32" # video bitrate limit in Mbps (default: 60)
## Run as a non-root user after initialization (supported: 0, 33, 50-99, 500-600, and 900-1200):

View file

@ -89,7 +89,7 @@ services:
OLLAMA_BASE_URL: "http://ollama:11434" # use "https://ollama.com" for Ollama Cloud
OLLAMA_API_KEY: "" # API key required to access Ollama (optional)
## Video Transcoding (https://docs.photoprism.app/getting-started/advanced/transcoding/):
# PHOTOPRISM_FFMPEG_ENCODER: "software" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, or vaapi)
# PHOTOPRISM_FFMPEG_ENCODER: "software" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, vaapi, or vulkan)
# PHOTOPRISM_FFMPEG_SIZE: "1920" # video size limit in pixels (720-7680) (default: 3840)
# PHOTOPRISM_FFMPEG_BITRATE: "32" # video bitrate limit in Mbps (default: 60)
## Run as a non-root user after initialization (supported: 0, 33, 50-99, 500-600, and 900-1200):

View file

@ -97,7 +97,7 @@ services:
OLLAMA_BASE_URL: "http://ollama:11434" # use "https://ollama.com" for Ollama Cloud
OLLAMA_API_KEY: "" # API key required to access Ollama (optional)
## Video Transcoding (https://docs.photoprism.app/getting-started/advanced/transcoding/):
# PHOTOPRISM_FFMPEG_ENCODER: "software" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, or vaapi)
# PHOTOPRISM_FFMPEG_ENCODER: "software" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, vaapi, or vulkan)
# PHOTOPRISM_FFMPEG_SIZE: "1920" # video size limit in pixels (720-7680) (default: 3840)
# PHOTOPRISM_FFMPEG_BITRATE: "32" # video bitrate limit in Mbps (default: 60)
working_dir: "/photoprism" # do not change or remove

View file

@ -100,7 +100,7 @@ services:
## Run/install on first startup, see https://github.com/photoprism/photoprism/blob/develop/scripts/dist/Makefile:
PHOTOPRISM_INIT: "https tensorflow" # common options: update https tensorflow tensorflow-gpu intel gpu davfs yt-dlp
## Video Transcoding (https://docs.photoprism.app/getting-started/advanced/transcoding/):
# PHOTOPRISM_FFMPEG_ENCODER: "software" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, or vaapi)
# PHOTOPRISM_FFMPEG_ENCODER: "software" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, vaapi, or vulkan)
# PHOTOPRISM_FFMPEG_SIZE: "1920" # video size limit in pixels (720-7680) (default: 3840)
# PHOTOPRISM_FFMPEG_BITRATE: "32" # video bitrate limit in Mbps (default: 60)
## Run as a non-root user after initialization (supported: 0, 33, 50-99, 500-600, and 900-1200):