Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 59 additions & 16 deletions src/lib/asset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,12 @@ import {
} from './selectors/validationResult';
import { selectWeb3 } from './selectors/web3Info';
import { selectWebsite } from './selectors/website';
import { loadThumbnail, type ThumbnailInfo } from './thumbnail';
import { toAbsoluteIdentifier } from './resolveThumbnails';
import {
loadThumbnail,
type ThumbnailInfo,
type ThumbnailResult,
} from './thumbnail';
import type { Disposable } from './types';

const MANIFEST_STORE_MIME_TYPE = 'application/x-c2pa-manifest-store';
Expand Down Expand Up @@ -132,9 +137,11 @@ export function getIngredientDataType(
export async function resultToAssetMap({
manifestStore,
source,
thumbnails,
}: {
manifestStore: ManifestStore;
source: Blob | File;
thumbnails: Map<string, Blob>;
}): Promise<DisposableAssetDataMap> {
const assetMap: AssetDataMap = {};
const disposers: (() => void)[] = [];
Expand Down Expand Up @@ -177,6 +184,26 @@ export async function resultToAssetMap({
}
}

async function lookupThumbnail(
ref: Thumbnail | null | undefined,
containingManifestLabel: string,
): Promise<ThumbnailResult> {
const blob = ref?.identifier
? thumbnails.get(toAbsoluteIdentifier(ref.identifier, containingManifestLabel))
: undefined;

if (!blob) {
return loadThumbnail(ref?.format, undefined);
}

const url = URL.createObjectURL(blob);

return loadThumbnail(ref?.format, {
url,
dispose: () => URL.revokeObjectURL(url),
});
}

if (!isManifest && (!manifestStore || hasError || hasOtgp)) {
// Fallback for raw files: render the original source file directly
const url = URL.createObjectURL(source);
Expand Down Expand Up @@ -231,14 +258,11 @@ export async function resultToAssetMap({
runtimeValidationStatuses: ManifestLabelValidationStatusMap,
id: string,
): Promise<AssetData> {
const manifest = manifestStore.manifests?.[manifestStore.active_manifest || ''];
const activeManifestLabel = manifestStore.active_manifest || '';
const manifest = manifestStore.manifests?.[activeManifestLabel];
if (!manifest) throw new Error('Active manifest not found');

// 0.17.x SDK dropped internal thumbnail generation. Pass undefined to skip WASM fetch.
let thumbnail = await loadThumbnail(
manifest.thumbnail?.format,
undefined
);
let thumbnail = await lookupThumbnail(manifest.thumbnail, activeManifestLabel);

if (
!thumbnail.info &&
Expand All @@ -248,7 +272,7 @@ export async function resultToAssetMap({
// Fallback for active manifest: render the original source file directly
const url = URL.createObjectURL(source);
thumbnail = await loadThumbnail(
source.type,
source.type,
{ url, dispose: () => URL.revokeObjectURL(url) }
);
}
Expand All @@ -263,6 +287,7 @@ export async function resultToAssetMap({
manifestStore,
runtimeValidationStatuses,
id,
activeManifestLabel,
),
manifestData: await getManifestData(manifest, rootValidationResult),
dataType: null,
Expand All @@ -284,17 +309,32 @@ export async function resultToAssetMap({
manifestStore: ManifestStore,
runtimeValidationStatuses: ManifestLabelValidationStatusMap,
id: string,
containingManifestLabel: string,
): Promise<AssetData> {
const ingredientManifestLabel = ingredient.active_manifest || (ingredient as ExtendedIngredient).activeManifest;
const ingredientManifest = ingredientManifestLabel ? manifestStore.manifests?.[ingredientManifestLabel] : null;



// 0.17.x SDK dropped internal thumbnail generation. Skip WASM fetch for ingredients.
const thumbnail = await loadThumbnail(
ingredient.thumbnail?.format,
undefined,
);
// The code prioritizes the signed ingredient's own c2pa.thumbnail.claim, if present,
// over the c2pa.thumbnail.ingredient assertion that the consuming manifest's signer
// references via ingredient.thumbnail. When a signed ingredient already provides
// its own claim thumbnail, a consuming signer's separate ingredient assertion
// thumbnail could be an intentional override pointing at a misleading image. The
// claim thumbnail more faithfully represents the ingredient.
//
// When no claim thumbnail is available for an ingredient, we fall back to
// c2pa.thumbnail.ingredient only if the containing manifest is trusted. If it is
// untrusted, we suppress the thumbnail so an untrusted signer can't force a false
// thumbnail to display for an ingredient that has no claim thumbnail of its own. A
// signed ingredient's own claim thumbnail is still shown when present (untrusted
// state is flagged in the UI).
const containingManifestUntrusted = (runtimeValidationStatuses[containingManifestLabel] ?? [])
.some((s) => s.code.includes('signingCredential.untrusted') || s.code.includes('signingCredential.invalid'));

const thumbnail = ingredientManifestLabel && ingredientManifest?.thumbnail
? await lookupThumbnail(ingredientManifest.thumbnail, ingredientManifestLabel)
: !containingManifestUntrusted
? await lookupThumbnail(ingredient.thumbnail, containingManifestLabel)
: await loadThumbnail(undefined, undefined);

const activeManifestValidationResults =
(ingredient.validation_results?.activeManifest || (ingredient as ExtendedIngredient).validationResults?.activeManifest) ?? undefined;
Expand All @@ -317,12 +357,13 @@ export async function resultToAssetMap({
title: ingredient.title ?? null,
thumbnail: thumbnail.info,
mimeType: ingredient.format || '',
children: (showChildren && ingredientManifest?.ingredients)
children: (showChildren && ingredientManifest?.ingredients && ingredientManifestLabel)
? await processIngredients(
ingredientManifest.ingredients,
manifestStore,
runtimeValidationStatuses,
id,
ingredientManifestLabel,
)
: [],
manifestData: await getManifestData(ingredientManifest, validationResult),
Expand Down Expand Up @@ -469,6 +510,7 @@ export async function resultToAssetMap({
manifestStore: ManifestStore,
runtimeValidationStatuses: ManifestLabelValidationStatusMap,
id: string,
containingManifestLabel: string,
): Promise<string[]> {
const ingredientIds = ingredients.map(async (ingredient, idx) => {
const ingredientId = `${id}.${idx}`;
Expand All @@ -478,6 +520,7 @@ export async function resultToAssetMap({
manifestStore,
runtimeValidationStatuses,
ingredientId,
containingManifestLabel,
);

return ingredientId;
Expand Down
66 changes: 66 additions & 0 deletions src/lib/resolveThumbnails.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright 2021-2024 Adobe, Copyright 2025 The C2PA Contributors

import type { ManifestStore, Reader } from '@contentauth/c2pa-web';

/**
* Convert a relative thumbnail URI to absolute form. Already absolute
* identifiers pass through unchanged.
*/
export function toAbsoluteIdentifier(
identifier: string,
manifestLabel: string,
): string {
if (identifier.startsWith('self#jumbf=/c2pa/')) return identifier;

if (identifier.startsWith('self#jumbf=')) {
const path = identifier.slice('self#jumbf='.length);

return `self#jumbf=/c2pa/${manifestLabel}/${path}`;
}

return identifier;
}

/** Pre-fetch every embedded thumbnail's bytes, keyed by absolute identifier. */
export async function resolveThumbnails(
manifestStore: ManifestStore,
reader: Reader,
): Promise<Map<string, Blob>> {
const refs = new Map<string, string>();

for (const [label, manifest] of Object.entries(manifestStore.manifests || {})) {
if (manifest.thumbnail?.identifier && manifest.thumbnail.format) {
refs.set(
toAbsoluteIdentifier(manifest.thumbnail.identifier, label),
manifest.thumbnail.format,
);
}

for (const ingredient of manifest.ingredients || []) {
if (ingredient.thumbnail?.identifier && ingredient.thumbnail.format) {
refs.set(
toAbsoluteIdentifier(ingredient.thumbnail.identifier, label),
ingredient.thumbnail.format,
);
}
}
}

const entries = await Promise.all(
Array.from(refs.entries()).map(
async ([identifier, format]): Promise<[string, Blob] | null> => {
try {
const bytes = await reader.resourceToBytes(identifier);

return [identifier, new Blob([new Uint8Array(bytes)], { type: format })];
} catch (err) {
console.warn(`Failed to resolve thumbnail ${identifier}:`, err);

return null;
}
},
),
);

return new Map(entries.filter((e): e is [string, Blob] => e !== null));
}
5 changes: 4 additions & 1 deletion src/routes/verify/stores/c2paReader.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright 2021-2024 Adobe, Copyright 2025 The C2PA Contributors

import { resultToAssetMap, type AssetDataMap } from '$lib/asset';
import { resolveThumbnails } from '$lib/resolveThumbnails';
import { getLegacySdk, getSdk, getOfficialToolkitSettings, getLegacyToolkitSettings } from '$lib/sdk';
import type { Loadable } from '$lib/types';
import {
Expand Down Expand Up @@ -226,8 +227,10 @@ export function createC2paReader(): C2paReaderStore {
});
}

const thumbnails = await resolveThumbnails(finalStore, currentReader);

const { assetMap, dispose: assetMapDisposer } =
await resultToAssetMap({ manifestStore: finalStore, source });
await resultToAssetMap({ manifestStore: finalStore, source, thumbnails });

dispose = () => {
assetMapDisposer();
Expand Down