Likeness Offers

Likeness license flow on Z Link, reusing the image candidate pipeline, with an optional reference video

What is a Likeness offer?

A Likeness offer lets a brand buy a license to the talent's likeness through a Z Link, using the exact same brand flow as an Image offer: brief → reference images → AI candidate generation → pick a sample → usage → sharing → budget → submit.

It reuses the image candidate pipeline end-to-end — no new generation endpoints, no new candidate tables. The only Likeness-specific addition on the brand side is an optional reference video clip.

Key differences from an Image offer:

  • Gated on the talent's approved Likeness IP term (not Image).
  • Priced against the talent's Likeness minimum.
  • Seeds the Likeness per-offer term at submit.
  • Adds an optional reference-video upload (context only).

Campaign type

LIKENESS is a campaign category (packages/types/src/types/CampaignType.ts) with one enabled type:

FieldValue
idai_likeness_license
categoryLIKENESS
aiProviderIMAGE_GEN
talentRequires["image_sample"]
enabledtrue

The public chips "Video post" and "Video commercial" (PublicChips.ts) map to ai_likeness_license.

Availability gating

/talent/[handle] lists campaign types from listAvailableCampaignTypesForTalent (apps/zooly-app/lib/campaignTypesForTalent.ts). LIKENESS is shown only when:

  • the talent has an approved Likeness IP term (hasApprovedLikeness), and
  • the talent has an image_sample asset.

A talent without an approved Likeness term never sees a Likeness option.

Flow

sequenceDiagram participant Browser participant ZLinkPage as ZLinkPage (offers SPA) participant API as zooly-app API participant DB as Postgres participant S3 Browser->>ZLinkPage: /z/:slug?type=ai_likeness_license Note over ZLinkPage: isImageFlow === true (LIKENESS reuses image flow) opt Reference video (optional) ZLinkPage->>API: POST /upload-reference-video {filename, contentType} API-->>ZLinkPage: { uploadUrl, publicUrl } ZLinkPage->>S3: PUT file (direct) end ZLinkPage->>API: POST /image-candidates/start (LIKENESS allowed) API->>DB: createImageCandidateSession (+ referenceVideoUrl) ZLinkPage->>API: POST /offers/submit API->>DB: createOffer (campaignType=ai_likeness_license, referenceVideoUrl) Note over API: threshold → Likeness term; superRefine requires candidate+model API->>DB: seedOfferTermsForOffer → seeds Likeness term API->>DB: status DRAFT → ADMIN_REVIEW

Submit validation

submitOfferBodySchema.superRefine (packages/contracts/src/schemas/offers.ts) requires a candidate session + chosen model for LIKENESS exactly like IMAGE, honoring skipImageGeneration. Drafts are exempt.

Threshold

resolveOfferThresholdMinorUnit (submit route) maps LIKENESS → IP_TERMS_TYPES.Likeness.

Terms seeding

deriveIpTermTypesForOffer (apps/zooly-app/lib/seedOfferTerms.ts) returns Likeness (instead of Image) when the offer's campaign type is in the LIKENESS category, so the seeded per-offer term is the Likeness one.

Lifecycle, drafts/resume, counters, below-threshold auto-actions, idempotency, and CORS are all inherited — the offer rides the same submit route and row as image offers.

Reference video

An optional clip on the Likeness reference step (isLikenessFlow in ZLinkPage.tsx). It is context only — stored for talent/admin visibility and seeded onto the candidate session + offer. It does not feed generation (image-gen models can't ingest video), and it never blocks submit.

Stored as reference_video_url on both offers and image_candidate_session, carried as referenceVideoUrl (validated z.string().url()) in the image-candidates/start and offers/submit payloads.

Why presigned direct-to-S3

The product-image upload buffers the file through a route handler (request.formData()uploadMediaFile). That works for images but fails for video: serverless route handlers cap the request body (~4.5 MB on Vercel), so a video makes request.formData() throw and surfaces the misleading Expected multipart/form-data error.

So the reference video uses a presigned PUT, mirroring merch/upload — the file bytes never pass through our API:

  1. POST /api/offers/upload-reference-video with { filename, contentType } returns { uploadUrl, publicUrl } (tiny JSON).
  2. The browser PUTs the file directly to S3 (no credentials, Content-Type matching what was signed).
  3. The public S3 URL is stored on the offer.

Implementation:

  • getPresignedUploadUrl(key, contentType)packages/util-srv/src/s3/s3-service.ts. Uses a dedicated S3 client with requestChecksumCalculation: "WHEN_REQUIRED"; the default mode embeds a CRC32 placeholder browser PUTs can't satisfy (→ 400s).
  • uploadReferenceVideo()packages/offers/client/src/lib/appApi.ts (presign, then PUT). 50 MB client cap, video/* type check.

Bucket prerequisites (already satisfied — merch uses the same bucket): browser PUT allowed via CORS, objects publicly readable for <video> playback.

Database

Migration 0124_likeness_reference_video adds nullable reference_video_url text to offers and image_candidate_session (idempotent ADD COLUMN IF NOT EXISTS). No other schema changes — the offer's category is captured by campaign_type, and the candidate pipeline columns are reused.

Gotchas

  • VIDEO falls through to the Likeness threshold. resolveOfferThresholdMinorUnit uses IMAGE → Image, VOICE → VoiceOver, else → Likeness. Harmless today (no enabled VIDEO type; video chips map to ai_likeness_license), but re-enabling a real VIDEO type would price it against the Likeness minimum unless an explicit branch is added.
  • Reference video never blocks submit — a Likeness offer can submit with images only, video only, or neither.
  • Don't reuse productImageUrls for the video — that array is .url()-validated, image-only, and consumed by generation.