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}
|
||||
Reference in New Issue
Block a user