feat(metadata,people): richer metadata editing + face-naming flow
Metadata (sidebar):
- New editable fields: Title, Credits section (Artist/Copyright/
License via Details), GPS Altitude.
- Video facts in the File section: Duration, FPS, Codec — required
fixing videoFile(): PhotoPrism serializes MediaType as the bare word
"video", so the old startsWith('video/') check never matched and the
helper always fell back to the JPEG poster.
- Timezone correctness: buildTakenAtPatch no longer forces
TakenAtLocal=UTC; it preserves the photo's existing UTC↔local offset
(per-photo in bulk edits) so PhotoPrism can't clobber manual date
edits when recomputing from TimeZone, and Year/Month/Day now derive
from local wall-clock time.
People (was "disabled" — really: zero subjects because naming is what
creates a person, and the UI had no naming flow; prod has 40k face
markers in 790 unnamed clusters):
- Sidecar GET /api/sidecar/subjects — scoped people list via one
markers→files→photos SQL pass (labels pattern), replacing the
client-side probe-per-subject N+1 filter.
- Sidecar GET /api/sidecar/faces/unnamed — the caller's unnamed face
clusters with count, crop thumb, and a representative marker UID.
- "Name new faces" panel on /tags/people: face-crop cards with inline
name input; naming uses PhotoPrism's own flow (PUT /markers/:uid
{Name, SubjSrc:manual}, verified against PP source) which creates
the Subject and propagates across the cluster.
- Scoped proxy: marker PUT / subject-clear DELETE now allowed with
per-marker ownership checks (was blanket-forbidden, which would have
blocked naming for scoped users).
- Per-photo People chips in the sidebar from named Files[].Markers,
linking to the person's page.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
112
web/src/lib/components/people/NewFacesPanel.svelte
Normal file
112
web/src/lib/components/people/NewFacesPanel.svelte
Normal file
@@ -0,0 +1,112 @@
|
||||
<!--
|
||||
"Name new faces" — the missing half of the People feature. PhotoPrism
|
||||
only creates a person once someone names a detected face cluster, so a
|
||||
library can have tens of thousands of face markers and still show an
|
||||
empty People list. This panel surfaces the caller's unnamed clusters
|
||||
(scoped server-side to their BasePath) as face-crop cards with an
|
||||
inline name input; naming goes through PhotoPrism's own flow (PUT on
|
||||
the cluster's representative marker), which creates the Subject and
|
||||
propagates it across the cluster.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
listUnnamedFaces,
|
||||
nameFaceCluster,
|
||||
type UnnamedFaceCluster
|
||||
} from '$lib/services/photoprism';
|
||||
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
||||
import { InlineLoader } from '$lib/components/feedback';
|
||||
import { UserPlus } from 'lucide-svelte';
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
const facesQuery = createQuery<UnnamedFaceCluster[]>(() => ({
|
||||
queryKey: ['faces', 'unnamed'],
|
||||
queryFn: listUnnamedFaces,
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 60_000
|
||||
}));
|
||||
|
||||
let drafts = $state<Record<string, string>>({});
|
||||
let busy = $state<Record<string, boolean>>({});
|
||||
|
||||
async function submit(cluster: UnnamedFaceCluster) {
|
||||
const name = (drafts[cluster.faceId] ?? '').trim();
|
||||
if (!name || busy[cluster.faceId]) return;
|
||||
busy[cluster.faceId] = true;
|
||||
try {
|
||||
await nameFaceCluster(cluster.markerUid, name);
|
||||
toast.success(`Named ${name}`, {
|
||||
description:
|
||||
'PhotoPrism links the whole cluster in the background — the photo count may keep growing.'
|
||||
});
|
||||
drafts[cluster.faceId] = '';
|
||||
void qc.invalidateQueries({ queryKey: ['faces', 'unnamed'] });
|
||||
void qc.invalidateQueries({ queryKey: ['subjects'] });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Naming failed');
|
||||
} finally {
|
||||
busy[cluster.faceId] = false;
|
||||
}
|
||||
}
|
||||
|
||||
const clusters = $derived(facesQuery.data ?? []);
|
||||
</script>
|
||||
|
||||
{#if facesQuery.isPending}
|
||||
<InlineLoader label="Looking for unnamed faces…" />
|
||||
{:else if clusters.length > 0}
|
||||
<section class="space-y-3">
|
||||
<header class="space-y-0.5">
|
||||
<h2 class="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<UserPlus class="h-4 w-4" /> Name new faces
|
||||
</h2>
|
||||
<p class="text-[11px] text-muted-foreground">
|
||||
Faces PhotoPrism detected but nobody has named yet. Naming one creates a person and tags
|
||||
every matching photo.
|
||||
</p>
|
||||
</header>
|
||||
<div
|
||||
class="grid gap-3"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));"
|
||||
>
|
||||
{#each clusters as cluster (cluster.faceId)}
|
||||
<div
|
||||
class="flex flex-col items-center gap-2 rounded-md border border-border bg-card/30 p-3"
|
||||
>
|
||||
<div class="relative">
|
||||
<img
|
||||
src={thumbUrl(cluster.thumb, 'tile_224')}
|
||||
alt="Unnamed face"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-20 w-20 rounded-full border border-border object-cover"
|
||||
/>
|
||||
<span
|
||||
class="absolute -bottom-1 -right-1 rounded-full bg-secondary px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground"
|
||||
title={`${cluster.count} of your photos carry this face`}
|
||||
>
|
||||
{cluster.count}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name…"
|
||||
disabled={busy[cluster.faceId]}
|
||||
class="w-full rounded border border-input bg-background px-1.5 py-1 text-center text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
|
||||
bind:value={drafts[cluster.faceId]}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void submit(cluster);
|
||||
}
|
||||
}}
|
||||
onblur={() => void submit(cluster)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
@@ -82,11 +82,13 @@
|
||||
() =>
|
||||
patchTargets(
|
||||
ids,
|
||||
buildTakenAtPatch(iso),
|
||||
// Per-photo patch so each photo keeps its own UTC↔local
|
||||
// offset when the date is stamped across a selection.
|
||||
(p) => buildTakenAtPatch(iso, p),
|
||||
label,
|
||||
(p) =>
|
||||
p.TakenAt
|
||||
? buildTakenAtPatch(p.TakenAt)
|
||||
? buildTakenAtPatch(p.TakenAt, p)
|
||||
: ({ TakenSrc: '' } as UpdatePhotoBody)
|
||||
),
|
||||
label
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
Star,
|
||||
Tag,
|
||||
Timer,
|
||||
User,
|
||||
X
|
||||
} from 'lucide-svelte';
|
||||
import {
|
||||
@@ -44,7 +45,13 @@
|
||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
|
||||
import { photoNameAndDir, primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
import {
|
||||
isVideo,
|
||||
photoNameAndDir,
|
||||
primaryFile,
|
||||
videoFile,
|
||||
type PpPhoto
|
||||
} from '$lib/types/photoprism';
|
||||
import { navigateToFolder, navigateToTag, setSearch, setSection } from '$lib/stores/filters.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
|
||||
@@ -59,14 +66,19 @@
|
||||
const qc = useQueryClient();
|
||||
|
||||
let basename = $state('');
|
||||
let title = $state('');
|
||||
let caption = $state('');
|
||||
let takenAt = $state('');
|
||||
let lat = $state('');
|
||||
let lng = $state('');
|
||||
let altitude = $state('');
|
||||
let country = $state('');
|
||||
let keywords = $state<string[]>([]);
|
||||
let keywordDraft = $state('');
|
||||
let renaming = $state(false);
|
||||
let artist = $state('');
|
||||
let copyright = $state('');
|
||||
let license = $state('');
|
||||
|
||||
/** Split `pf.Name` (a relative path like `foo/bar/IMG.jpg`) into directory
|
||||
* prefix and basename. Sidecar's rename endpoint only accepts a bare
|
||||
@@ -80,16 +92,21 @@
|
||||
$effect(() => {
|
||||
const pf = primaryFile(photo);
|
||||
basename = splitName(pf.Name ?? '').base;
|
||||
title = photo.Title ?? '';
|
||||
caption = photo.Caption ?? '';
|
||||
takenAt = (photo.TakenAt ?? '').slice(0, 10);
|
||||
lat = photo.Lat ? String(photo.Lat) : '';
|
||||
lng = photo.Lng ? String(photo.Lng) : '';
|
||||
altitude = photo.Altitude ? String(photo.Altitude) : '';
|
||||
country = photo.Country && photo.Country !== 'zz' ? photo.Country : '';
|
||||
const det = photo.Details ?? {};
|
||||
keywords = (det.Keywords ?? '')
|
||||
.split(',')
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean);
|
||||
artist = det.Artist ?? '';
|
||||
copyright = det.Copyright ?? '';
|
||||
license = det.License ?? '';
|
||||
});
|
||||
|
||||
const patchMutation = createMutation(() => ({
|
||||
@@ -145,6 +162,11 @@
|
||||
if (caption === (photo.Caption ?? '')) return;
|
||||
commit({ Caption: caption, CaptionSrc: 'manual' });
|
||||
}
|
||||
function commitTitle() {
|
||||
const next = title.trim();
|
||||
if (next === (photo.Title ?? '')) return;
|
||||
commit({ Title: next, TitleSrc: 'manual' });
|
||||
}
|
||||
const takenAtValid = $derived(takenAt === '' || isValidISODate(takenAt));
|
||||
// Path-based date guess. Scoped to the EXIF Stripped review tab: those
|
||||
// are the photos with definitionally-untrusted dates, and showing the
|
||||
@@ -185,14 +207,16 @@
|
||||
const tail = (photo.TakenAt ?? '').slice(10) || 'T00:00:00Z';
|
||||
const iso = `${takenAt}${tail}`;
|
||||
if (iso === photo.TakenAt) return;
|
||||
commit(buildTakenAtPatch(iso));
|
||||
commit(buildTakenAtPatch(iso, photo));
|
||||
}
|
||||
function commitGps() {
|
||||
const nlat = parseFloat(lat);
|
||||
const nlng = parseFloat(lng);
|
||||
const nalt = parseFloat(altitude);
|
||||
const patch: UpdatePhotoBody = {};
|
||||
if (!Number.isNaN(nlat) && nlat !== photo.Lat) patch.Lat = nlat;
|
||||
if (!Number.isNaN(nlng) && nlng !== photo.Lng) patch.Lng = nlng;
|
||||
if (!Number.isNaN(nalt) && nalt !== photo.Altitude) patch.Altitude = nalt;
|
||||
if (Object.keys(patch).length) commit(patch);
|
||||
}
|
||||
function commitCountry() {
|
||||
@@ -202,7 +226,7 @@
|
||||
commit({ Country: next || 'zz', CountrySrc: 'manual' });
|
||||
}
|
||||
|
||||
type DetailsKey = 'Keywords';
|
||||
type DetailsKey = 'Keywords' | 'Artist' | 'Copyright' | 'License';
|
||||
function commitDetails(field: DetailsKey, value: string) {
|
||||
const prev = (photo.Details ?? {})[field] ?? '';
|
||||
if (value === prev) return;
|
||||
@@ -283,7 +307,42 @@
|
||||
const currentRating = $derived(photoMark.rating ?? 0);
|
||||
const currentColor = $derived(photoMark.color ?? '');
|
||||
|
||||
// Named face markers across all file variants, deduped by subject.
|
||||
// Slug mirrors PhotoPrism's slugify (lowercase, diacritics stripped,
|
||||
// non-alphanumerics collapsed to '-') so the person link resolves the
|
||||
// same drill URL the sidebar list uses.
|
||||
function personSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
const peopleChips = $derived.by(() => {
|
||||
const seen = new Map<string, { subjUid: string; name: string; slug: string }>();
|
||||
for (const f of photo.Files ?? []) {
|
||||
for (const m of f.Markers ?? []) {
|
||||
if (m.Invalid || !m.Name || !m.SubjUID || seen.has(m.SubjUID)) continue;
|
||||
seen.set(m.SubjUID, { subjUid: m.SubjUID, name: m.Name, slug: personSlug(m.Name) });
|
||||
}
|
||||
}
|
||||
return [...seen.values()];
|
||||
});
|
||||
|
||||
const pf = $derived(primaryFile(photo));
|
||||
// Video facts come from the video variant (primary is often the JPEG
|
||||
// poster for Live Photos / transcoded clips).
|
||||
const vf = $derived(isVideo(photo) ? videoFile(photo) : null);
|
||||
const durationStr = $derived.by(() => {
|
||||
// PpFile.Duration is Go time.Duration → nanoseconds.
|
||||
const ns = vf?.Duration ?? 0;
|
||||
if (ns <= 0) return '';
|
||||
const totalSec = Math.round(ns / 1_000_000_000);
|
||||
const m = Math.floor(totalSec / 60);
|
||||
const s = totalSec % 60;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
});
|
||||
const dirPath = $derived(splitName(pf.Name ?? '').dir);
|
||||
const folderLabel = $derived(dirPath ? `${dirPath}/` : '/');
|
||||
const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—');
|
||||
@@ -508,6 +567,17 @@
|
||||
</span>
|
||||
</summary>
|
||||
<div class="space-y-2 p-2 pt-1">
|
||||
<div class="space-y-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Title</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add a title…"
|
||||
class="w-full rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
bind:value={title}
|
||||
onblur={commitTitle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Note</div>
|
||||
<textarea
|
||||
@@ -604,6 +674,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recognized people — named face markers on this photo's files.
|
||||
Read-only chips linking to the person's page. -->
|
||||
{#if peopleChips.length > 0}
|
||||
<div class="space-y-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">People</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each peopleChips as person (person.subjUid)}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-full border border-border bg-secondary px-1.5 py-0.5 text-[10px] hover:bg-accent"
|
||||
onclick={() => void navigateToTag('people', person.slug)}
|
||||
title={`View photos of ${person.name}`}
|
||||
>
|
||||
<User class="h-2.5 w-2.5 text-muted-foreground" />
|
||||
{person.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Auto-labels (PhotoPrism's TensorFlow classifier output). Read-
|
||||
only: editing labels requires re-indexing on PhotoPrism's
|
||||
side. The dashed border + lower contrast distinguishes them
|
||||
@@ -678,6 +769,62 @@
|
||||
onblur={commitCountry}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-0.5">
|
||||
<span class="text-[9px] text-muted-foreground">Altitude (m)</span>
|
||||
<input
|
||||
type="number"
|
||||
step="1"
|
||||
class="rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
bind:value={altitude}
|
||||
onblur={commitGps}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Credits — IPTC provenance fields (Artist / Copyright / License).
|
||||
Closed by default; persists once opened. -->
|
||||
<details
|
||||
class="rounded border border-border"
|
||||
open={getMetadataSectionOpen('credits', false)}
|
||||
ontoggle={(e) => setMetadataSection('credits', e.currentTarget.open)}
|
||||
>
|
||||
<summary
|
||||
class="cursor-pointer px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Credits
|
||||
</summary>
|
||||
<div class="space-y-1.5 p-2 pt-1">
|
||||
<label class="flex flex-col gap-0.5">
|
||||
<span class="text-[9px] text-muted-foreground">Artist</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Photographer…"
|
||||
class="rounded border border-input bg-background px-1.5 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
bind:value={artist}
|
||||
onblur={() => commitDetails('Artist', artist.trim())}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-0.5">
|
||||
<span class="text-[9px] text-muted-foreground">Copyright</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="© …"
|
||||
class="rounded border border-input bg-background px-1.5 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
bind:value={copyright}
|
||||
onblur={() => commitDetails('Copyright', copyright.trim())}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-0.5">
|
||||
<span class="text-[9px] text-muted-foreground">License</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. CC BY-NC 4.0"
|
||||
class="rounded border border-input bg-background px-1.5 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
bind:value={license}
|
||||
onblur={() => commitDetails('License', license.trim())}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@@ -753,6 +900,18 @@
|
||||
{/if}
|
||||
<dt class="text-muted-foreground">Type</dt>
|
||||
<dd class="text-foreground/80">{pf.FileType ?? photo.Type ?? '—'}</dd>
|
||||
{#if durationStr}
|
||||
<dt class="text-muted-foreground">Duration</dt>
|
||||
<dd class="text-foreground/80">{durationStr}</dd>
|
||||
{/if}
|
||||
{#if vf?.FPS}
|
||||
<dt class="text-muted-foreground">FPS</dt>
|
||||
<dd class="text-foreground/80">{Math.round(vf.FPS * 10) / 10}</dd>
|
||||
{/if}
|
||||
{#if vf?.Codec}
|
||||
<dt class="text-muted-foreground">Codec</dt>
|
||||
<dd class="text-foreground/80">{vf.Codec}</dd>
|
||||
{/if}
|
||||
<dt class="text-muted-foreground">Hash</dt>
|
||||
<dd class="break-all font-mono text-foreground/70">{pf.Hash?.slice(0, 16) ?? '—'}…</dd>
|
||||
<dt class="text-muted-foreground">Indexed</dt>
|
||||
|
||||
@@ -427,7 +427,7 @@
|
||||
title={filterText ? 'No people match the filter' : 'No people yet'}
|
||||
description={filterText
|
||||
? undefined
|
||||
: 'PhotoPrism creates a person whenever it clusters detected faces. Make sure face recognition is enabled and indexed.'}
|
||||
: 'A person appears here once you name a detected face — use the "Name new faces" cards on the right.'}
|
||||
/>
|
||||
{:else}
|
||||
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
|
||||
|
||||
@@ -104,7 +104,7 @@ export async function acceptDateAndKeep(uids: string[]): Promise<void> {
|
||||
originalName: p.OriginalName,
|
||||
path
|
||||
});
|
||||
if (guess) await updatePhoto(p, buildTakenAtPatch(`${guess.iso}T00:00:00Z`));
|
||||
if (guess) await updatePhoto(p, buildTakenAtPatch(`${guess.iso}T00:00:00Z`, p));
|
||||
}
|
||||
await approvePhoto(id);
|
||||
return id;
|
||||
|
||||
@@ -343,6 +343,8 @@ export async function getPhoto(uid: string): Promise<PpPhoto> {
|
||||
*/
|
||||
export interface UpdatePhotoBody {
|
||||
OriginalName?: string;
|
||||
Title?: string;
|
||||
TitleSrc?: 'manual' | '';
|
||||
Caption?: string;
|
||||
CaptionSrc?: 'manual' | '';
|
||||
Archived?: boolean;
|
||||
@@ -373,17 +375,43 @@ export function isValidISODate(s: string): boolean {
|
||||
return d.toISOString().slice(0, 10) === s;
|
||||
}
|
||||
|
||||
export function buildTakenAtPatch(iso: string): UpdatePhotoBody {
|
||||
/** PhotoPrism serializes TakenAtLocal with a `Z` suffix even though it's
|
||||
* semantically wall-clock time in the photo's TimeZone. Force-parse as
|
||||
* UTC so offset math never picks up the *browser's* timezone. */
|
||||
function parseAsUtc(s: string): number {
|
||||
return Date.parse(/(Z|[+-]\d{2}:?\d{2})$/.test(s) ? s : s + 'Z');
|
||||
}
|
||||
|
||||
/**
|
||||
* `photo` supplies the existing TakenAt/TakenAtLocal pair so the photo's
|
||||
* UTC↔local offset survives the edit. Without it (or without a prior
|
||||
* pair) local falls back to UTC — correct for TimeZone-less photos.
|
||||
* Previously this forced `TakenAtLocal = UTC`, which both let PhotoPrism
|
||||
* clobber manual edits when recomputing local time from TimeZone and
|
||||
* shifted Year/Month/Day for photos taken far from UTC.
|
||||
*/
|
||||
export function buildTakenAtPatch(
|
||||
iso: string,
|
||||
photo?: { TakenAt?: string; TakenAtLocal?: string }
|
||||
): UpdatePhotoBody {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return {};
|
||||
const utc = d.toISOString().replace(/\.\d+Z$/, 'Z');
|
||||
let offsetMs = 0;
|
||||
if (photo?.TakenAt && photo?.TakenAtLocal) {
|
||||
const a = parseAsUtc(photo.TakenAt);
|
||||
const b = parseAsUtc(photo.TakenAtLocal);
|
||||
if (!Number.isNaN(a) && !Number.isNaN(b)) offsetMs = b - a;
|
||||
}
|
||||
const local = new Date(d.getTime() + offsetMs);
|
||||
return {
|
||||
TakenAt: utc,
|
||||
TakenAtLocal: utc,
|
||||
TakenAtLocal: local.toISOString().replace(/\.\d+Z$/, 'Z'),
|
||||
TakenSrc: 'manual',
|
||||
Year: d.getUTCFullYear(),
|
||||
Month: d.getUTCMonth() + 1,
|
||||
Day: d.getUTCDate()
|
||||
// PhotoPrism derives Year/Month/Day from local wall-clock time.
|
||||
Year: local.getUTCFullYear(),
|
||||
Month: local.getUTCMonth() + 1,
|
||||
Day: local.getUTCDate()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -694,35 +722,6 @@ export async function aggregateKeywords(): Promise<AggregatedKeyword[]> {
|
||||
return Array.from(buckets.values()).sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
async function hasPhotosMatching(q: string): Promise<boolean> {
|
||||
const resp = await sidecar.get<PpPhoto[]>('/api/sidecar/timeline', {
|
||||
params: { count: 1, offset: 0, q }
|
||||
});
|
||||
return Array.isArray(resp.data) && resp.data.length > 0;
|
||||
}
|
||||
|
||||
async function filterByUserPhotos<T>(
|
||||
items: T[],
|
||||
queryFor: (item: T) => string
|
||||
): Promise<T[]> {
|
||||
if (userBasePath() === '') return items;
|
||||
const CONCURRENCY = 8;
|
||||
const out: T[] = [];
|
||||
for (let i = 0; i < items.length; i += CONCURRENCY) {
|
||||
const batch = items.slice(i, i + CONCURRENCY);
|
||||
const checks = await Promise.all(
|
||||
batch.map(async (item) => ({
|
||||
item,
|
||||
has: await hasPhotosMatching(queryFor(item))
|
||||
}))
|
||||
);
|
||||
for (const { item, has } of checks) {
|
||||
if (has) out.push(item);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function listLabels(): Promise<PpLabel[]> {
|
||||
// `all=true` includes labels PhotoPrism has soft-deleted (auto-hidden
|
||||
// low-confidence classifier hits, manually-removed labels). They're
|
||||
@@ -762,10 +761,42 @@ export interface PpSubject {
|
||||
}
|
||||
|
||||
export async function listSubjects(): Promise<PpSubject[]> {
|
||||
const { data } = await http.get<PpSubject[]>('/subjects', {
|
||||
// Sidecar proxy scopes PhotoCount (and drops out-of-scope people) with
|
||||
// one SQL pass, replacing the old client-side probe-per-subject filter.
|
||||
const { data } = await sidecar.get<PpSubject[]>('/api/sidecar/subjects', {
|
||||
params: { count: 1000, order: 'count' }
|
||||
});
|
||||
return filterByUserPhotos(data ?? [], (s) => `person:${s.Slug}`);
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
// ── Face clusters (unnamed people) ──────────────────────────────────────────
|
||||
//
|
||||
// PhotoPrism only creates a Subject once someone names a detected face
|
||||
// cluster. The sidecar lists clusters awaiting a name (scoped to the
|
||||
// caller's BasePath); naming goes through PhotoPrism's own flow — a PUT
|
||||
// on the cluster's representative marker — which creates the Subject and
|
||||
// propagates it across the whole cluster.
|
||||
|
||||
export interface UnnamedFaceCluster {
|
||||
faceId: string;
|
||||
count: number;
|
||||
/** Marker crop hash — renders via the standard thumb endpoint. */
|
||||
thumb: string;
|
||||
markerUid: string;
|
||||
}
|
||||
|
||||
export async function listUnnamedFaces(): Promise<UnnamedFaceCluster[]> {
|
||||
const { data } = await sidecar.get<{ clusters: UnnamedFaceCluster[] }>(
|
||||
'/api/sidecar/faces/unnamed'
|
||||
);
|
||||
return data?.clusters ?? [];
|
||||
}
|
||||
|
||||
export async function nameFaceCluster(markerUid: string, name: string): Promise<void> {
|
||||
await http.put(`/markers/${encodeURIComponent(markerUid)}`, {
|
||||
Name: name,
|
||||
SubjSrc: 'manual'
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSubject(uid: string, patch: Partial<PpSubject>): Promise<PpSubject> {
|
||||
|
||||
@@ -257,10 +257,17 @@ export function isVideo(p: PpPhoto): boolean {
|
||||
|
||||
/** Return the Files[] entry that carries the actual video stream. Falls back
|
||||
* to primaryFile() if no video MediaType is present (shouldn't happen for
|
||||
* Type === 'video' but keeps the call site total). */
|
||||
* Type === 'video' but keeps the call site total).
|
||||
*
|
||||
* PhotoPrism serializes MediaType as the bare word "video" (verified
|
||||
* against prod), not a MIME type — the old `startsWith('video/')` check
|
||||
* never matched, so this always fell back to the JPEG poster and video
|
||||
* facts (duration/codec/fps) were unreachable. */
|
||||
export function videoFile(p: PpPhoto): PpFile {
|
||||
const files = p.Files ?? [];
|
||||
const v = files.find((f) => f.MediaType?.startsWith('video/'));
|
||||
const v = files.find(
|
||||
(f) => f.MediaType === 'video' || f.MediaType?.startsWith('video/')
|
||||
);
|
||||
return v ?? primaryFile(p);
|
||||
}
|
||||
|
||||
@@ -276,6 +283,34 @@ export interface PpFile {
|
||||
Size?: number;
|
||||
FileType?: string;
|
||||
MediaType?: string;
|
||||
Codec?: string;
|
||||
/** Video duration in nanoseconds (Go time.Duration serialization). */
|
||||
Duration?: number;
|
||||
FPS?: number;
|
||||
Frames?: number;
|
||||
/** EXIF orientation 1–8. */
|
||||
Orientation?: number;
|
||||
HDR?: boolean;
|
||||
Projection?: string;
|
||||
/** Face/subject markers detected in this file (present on
|
||||
* GET /photos/:uid responses). */
|
||||
Markers?: PpMarker[];
|
||||
}
|
||||
|
||||
/** A detected region in a file — for our purposes always a face. Named
|
||||
* markers carry the subject they were matched to. */
|
||||
export interface PpMarker {
|
||||
UID: string;
|
||||
Type?: string;
|
||||
Src?: string;
|
||||
Name?: string;
|
||||
SubjUID?: string;
|
||||
SubjSrc?: string;
|
||||
FaceID?: string;
|
||||
Invalid?: boolean;
|
||||
Score?: number;
|
||||
/** Crop hash renderable via the standard thumb endpoint. */
|
||||
Thumb?: string;
|
||||
}
|
||||
|
||||
export type PpThumbSize =
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
import { countryName } from '$lib/utils/countries';
|
||||
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
|
||||
import NewFacesPanel from '$lib/components/people/NewFacesPanel.svelte';
|
||||
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
||||
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
||||
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
|
||||
@@ -216,13 +217,28 @@
|
||||
</Toolbar>
|
||||
|
||||
{#if !selectedValue}
|
||||
<main class="flex min-h-0 flex-1 items-center justify-center p-8">
|
||||
<EmptyState
|
||||
icon={Tag}
|
||||
title={`Pick a ${category ?? 'tag'} from the sidebar`}
|
||||
description="Click a row in the panel on the left to filter the photo grid by that tag."
|
||||
/>
|
||||
</main>
|
||||
{#if category === 'people'}
|
||||
<!-- No person selected: surface the naming workflow instead of a
|
||||
bare prompt — naming is what creates people in the first place. -->
|
||||
<main class="min-h-0 flex-1 overflow-y-auto p-6">
|
||||
<NewFacesPanel />
|
||||
<div class="mt-8 flex items-center justify-center">
|
||||
<EmptyState
|
||||
icon={Tag}
|
||||
title="Pick a person from the sidebar"
|
||||
description="Click a row in the panel on the left to see that person's photos."
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
{:else}
|
||||
<main class="flex min-h-0 flex-1 items-center justify-center p-8">
|
||||
<EmptyState
|
||||
icon={Tag}
|
||||
title={`Pick a ${category ?? 'tag'} from the sidebar`}
|
||||
description="Click a row in the panel on the left to filter the photo grid by that tag."
|
||||
/>
|
||||
</main>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
|
||||
Reference in New Issue
Block a user