Skip to content
73 changes: 59 additions & 14 deletions src/lib/asset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,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 @@ -126,9 +131,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 @@ -170,6 +177,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 @@ -224,14 +251,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 @@ -241,7 +265,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 @@ -256,6 +280,7 @@ export async function resultToAssetMap({
manifestStore,
runtimeValidationStatuses,
id,
activeManifestLabel,
),
manifestData: await getManifestData(manifest, rootValidationResult),
dataType: null,
Expand All @@ -277,15 +302,32 @@ export async function resultToAssetMap({
manifestStore: ManifestStore,
runtimeValidationStatuses: ManifestLabelValidationStatusMap,
id: string,
containingManifestLabel: string,
): Promise<AssetData> {
const ingredientManifestLabel = ingredient.active_manifest;
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).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/**
 * 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)
 */

Can we use a multiline comment here to make it more readable

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MassivaM I just fixed this. Thanks!

const containingManifestUntrusted = (runtimeValidationStatuses[containingManifestLabel] ?? [])
.some((s) => s.code.includes('signingCredential.untrusted') || s.code.includes('signingCredential.invalid'));

const thumbnail = ingredientManifestLabel && ingredientManifest?.thumbnail
Comment thread
tmathern marked this conversation as resolved.
? await lookupThumbnail(ingredientManifest.thumbnail, ingredientManifestLabel)
: !containingManifestUntrusted
? await lookupThumbnail(ingredient.thumbnail, containingManifestLabel)
: await loadThumbnail(undefined, undefined);

const activeManifestValidationResults =
ingredient.validation_results?.activeManifest ?? undefined;
Expand All @@ -307,12 +349,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 @@ -462,6 +505,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 @@ -471,6 +515,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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is a new file this can be Copyright 2026 The C2PA contributors

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MassivaM Can this be // Copyright 2021-2024 Adobe, Copyright 2026 The C2PA Contributors instead? There is an eslint header rule that enforces this format.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes I forgot we had that linter , we'll want to change this eventually but yes that's okay for now!


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