From dccc4504dbd204d980d84ba32537c2818b8a98e2 Mon Sep 17 00:00:00 2001 From: Andy Parsons Date: Wed, 13 May 2026 23:41:04 +0800 Subject: [PATCH] Show ingredient thumbnails for provenance-free stub nodes in the tree view Ingredient assertions (c2pa.ingredient*) embed a thumbnail reference even when the ingredient has no Content Credentials. This change resolves those thumbnails and displays them on stub nodes in the provenance tree. - ingredientThumbnailSrc(): handles both v2 (inline data) and v1 (JUMBF url pointing to a c2pa.thumbnail.* assertion in the parent manifest) - enrichThumbnails(): extended to also resolve identifier URIs inside ingredient thumbnail objects (v2 embedded case) - enrichThumbnailsViaPackagedSdk(): quick-check now also detects unresolved ingredient-embedded thumbnail identifiers Co-Authored-By: Claude Sonnet 4.6 --- src/lib/OverviewPanel.svelte | 38 +++++++++++++++++++++++++--- src/lib/c2pa.ts | 49 +++++++++++++++++++++++++----------- 2 files changed, 68 insertions(+), 19 deletions(-) diff --git a/src/lib/OverviewPanel.svelte b/src/lib/OverviewPanel.svelte index 9d73c6e..7cead2c 100644 --- a/src/lib/OverviewPanel.svelte +++ b/src/lib/OverviewPanel.svelte @@ -270,7 +270,36 @@ return undefined } - type Edge = { childIdx: number | null; relationship?: string; stubTitle?: string; stubFormat?: string } + type Edge = { childIdx: number | null; relationship?: string; stubTitle?: string; stubFormat?: string; stubThumbnailSrc?: string } + + function ingredientThumbnailSrc(v: Record, m: CrJsonManifestEntry): string | undefined { + const thumb = v.thumbnail + if (!thumb || typeof thumb !== 'object') return undefined + const t = thumb as Record + + // v2: inline data on the thumbnail object itself + if (typeof t.data === 'string' && t.data) { + const fmt = typeof t.format === 'string' ? t.format : 'image/jpeg' + const raw = t.data + const b64 = raw.startsWith("b64'") && raw.endsWith("'") ? raw.slice(4, -1) : raw + return `data:${fmt};base64,${b64}` + } + + // v1: thumbnail.url is a JUMBF reference to a c2pa.thumbnail.* assertion in this manifest + // e.g. "self#jumbf=c2pa.assertions/c2pa.thumbnail.ingredient.jpeg" + const url = typeof t.url === 'string' ? t.url : undefined + if (!url) return undefined + const prefix = 'c2pa.assertions/' + const prefixIdx = url.indexOf(prefix) + if (prefixIdx < 0) return undefined + const assertionLabel = url.slice(prefixIdx + prefix.length) + const assertion = (m.assertions ?? {})[assertionLabel] as Record | undefined + if (!assertion || typeof assertion.data !== 'string' || !assertion.data) return undefined + const fmt = typeof assertion.format === 'string' ? assertion.format : 'image/jpeg' + const raw = assertion.data + const b64 = raw.startsWith("b64'") && raw.endsWith("'") ? raw.slice(4, -1) : raw + return `data:${fmt};base64,${b64}` + } function crJsonEdges( m: CrJsonManifestEntry, @@ -283,6 +312,7 @@ const relationship = v.relationship as string | undefined const stubTitle = (v.title ?? v.dc_title) as string | undefined const stubFormat = (v.format ?? v.dc_format) as string | undefined + const stubThumbnailSrc = ingredientThumbnailSrc(v, m) // v1: c2pa_manifest is an object { url, alg, hash } // v2: active_manifest is a direct string (manifest label) const manifestRef = (v.c2pa_manifest ?? v.activeManifest) as Record | undefined @@ -290,14 +320,14 @@ const url = (manifestRef?.url as string | undefined) ?? (typeof activeManifestStr === 'string' ? activeManifestStr : undefined) if (!url) { // No manifest reference — ingredient has no Content Credentials - out.push({ childIdx: null, relationship, stubTitle, stubFormat }) + out.push({ childIdx: null, relationship, stubTitle, stubFormat, stubThumbnailSrc }) } else { const childIdx = idx.get(parseManifestLabel(url)) if (childIdx != null) { out.push({ childIdx, relationship }) } else { // Manifest referenced but not present in this report - out.push({ childIdx: null, relationship, stubTitle, stubFormat }) + out.push({ childIdx: null, relationship, stubTitle, stubFormat, stubThumbnailSrc }) } } } @@ -309,7 +339,7 @@ manifestIdx: -1, claimGenerator: edge.stubTitle, mimeType: edge.stubFormat ?? null, - thumbnailSrc: undefined, + thumbnailSrc: edge.stubThumbnailSrc, date: undefined, ingredientCount: 0, inceptions: [], diff --git a/src/lib/c2pa.ts b/src/lib/c2pa.ts index 37e9ba6..14a7787 100644 --- a/src/lib/c2pa.ts +++ b/src/lib/c2pa.ts @@ -492,7 +492,7 @@ async function runTrustValidationFlow( * The packaged SDK instance is cached so only one Web Worker is created. */ async function enrichThumbnailsViaPackagedSdk(crJson: CrJson, file: Blob, mimeType: string): Promise { - // Quick check: any unresolved thumbnail identifiers? + // Quick check: any unresolved thumbnail identifiers? (manifest-level or ingredient-embedded) let hasUnresolved = false outer: for (const manifest of (crJson.manifests ?? [])) { const assertions = (manifest.assertions ?? {}) as Record> @@ -501,6 +501,13 @@ async function enrichThumbnailsViaPackagedSdk(crJson: CrJson, file: Blob, mimeTy hasUnresolved = true break outer } + if (key.startsWith('c2pa.ingredient') && assertion) { + const thumb = assertion.thumbnail as Record | undefined + if (thumb && !thumb.data && typeof thumb.identifier === 'string') { + hasUnresolved = true + break outer + } + } } } if (!hasUnresolved) return @@ -521,27 +528,39 @@ async function enrichThumbnailsViaPackagedSdk(crJson: CrJson, file: Blob, mimeTy /** * Resolve JUMBF `identifier` URIs in thumbnail assertions to inline base64 `data` fields. + * Handles both manifest-level (c2pa.thumbnail*) and ingredient-embedded (c2pa.ingredient*.thumbnail) thumbnails. * Only runs when the reader exposes `resourceToBytes`; silently skips failures. */ async function enrichThumbnails(crJson: CrJson, resourceToBytes: (uri: string) => Promise): Promise { for (const manifest of (crJson.manifests ?? [])) { const assertions = (manifest.assertions ?? {}) as Record> for (const [key, assertion] of Object.entries(assertions)) { - if (!key.startsWith('c2pa.thumbnail') || !assertion || typeof assertion !== 'object') continue - if (assertion.data) continue // already inlined - const identifier = assertion.identifier - if (typeof identifier !== 'string') continue - try { - const bytes = await resourceToBytes(identifier) - // Convert to base64 in chunks to avoid call-stack limits on large thumbnails - const chunkSize = 8192 - let binary = '' - for (let i = 0; i < bytes.length; i += chunkSize) { - binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)) + if (!assertion || typeof assertion !== 'object') continue + + const targets: Array> = [] + if (key.startsWith('c2pa.thumbnail')) { + targets.push(assertion) + } else if (key.startsWith('c2pa.ingredient')) { + const thumb = assertion.thumbnail as Record | undefined + if (thumb && typeof thumb === 'object') targets.push(thumb) + } + + for (const target of targets) { + if (target.data) continue // already inlined + const identifier = target.identifier + if (typeof identifier !== 'string') continue + try { + const bytes = await resourceToBytes(identifier) + // Convert to base64 in chunks to avoid call-stack limits on large thumbnails + const chunkSize = 8192 + let binary = '' + for (let i = 0; i < bytes.length; i += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)) + } + target.data = `b64'${btoa(binary)}'` + } catch { + // Non-fatal: skip thumbnails we can't resolve } - assertion.data = `b64'${btoa(binary)}'` - } catch { - // Non-fatal: skip thumbnails we can't resolve } } }