mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
Merge remote-tracking branch 'origin/develop' into PostgreSQL
This commit is contained in:
commit
ea3ba601dd
53 changed files with 1009 additions and 182 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
33
compose.yaml
33
compose.yaml
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ var FacesCommands = &cli.Command{
|
|||
},
|
||||
Action: facesOptimizeAction,
|
||||
},
|
||||
FacesConfigCommand,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
76
internal/commands/faces_config.go
Normal file
76
internal/commands/faces_config.go
Normal 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
|
||||
}
|
||||
62
internal/commands/faces_config_test.go
Normal file
62
internal/commands/faces_config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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, "/")
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
42
internal/ffmpeg/vulkan/avc.go
Normal file
42
internal/ffmpeg/vulkan/avc.go
Normal 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,
|
||||
)
|
||||
}
|
||||
33
internal/ffmpeg/vulkan/avc_test.go
Normal file
33
internal/ffmpeg/vulkan/avc_test.go
Normal 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"))
|
||||
}
|
||||
28
internal/ffmpeg/vulkan/vulkan.go
Normal file
28
internal/ffmpeg/vulkan/vulkan.go
Normal 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
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
78
pkg/http/scheme/url.go
Normal 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
119
pkg/http/scheme/url_test.go
Normal 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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
50
pkg/media/video/probe_hevc.go
Normal file
50
pkg/media/video/probe_hevc.go
Normal 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)
|
||||
}
|
||||
43
pkg/media/video/probe_hevc_test.go
Normal file
43
pkg/media/video/probe_hevc_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
5
scripts/dist/build-libheif.sh
vendored
5
scripts/dist/build-libheif.sh
vendored
|
|
@ -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
|
||||
|
|
|
|||
3
scripts/dist/install-libheif.sh
vendored
3
scripts/dist/install-libheif.sh
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue