Likeness license flow on Z Link, reusing the image candidate pipeline, with an optional reference video
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:
LIKENESS is a campaign category (packages/types/src/types/CampaignType.ts)
with one enabled type:
| Field | Value |
|---|---|
id | ai_likeness_license |
category | LIKENESS |
aiProvider | IMAGE_GEN |
talentRequires | ["image_sample"] |
enabled | true |
The public chips "Video post" and "Video commercial"
(PublicChips.ts) map to ai_likeness_license.
/talent/[handle] lists campaign types from
listAvailableCampaignTypesForTalent
(apps/zooly-app/lib/campaignTypesForTalent.ts). LIKENESS is shown only when:
Likeness IP term (hasApprovedLikeness), andimage_sample asset.A talent without an approved Likeness term never sees a Likeness option.
submitOfferBodySchema.superRefine (packages/contracts/src/schemas/offers.ts)
requires a candidate session + chosen model for LIKENESS exactly like
IMAGE, honoring skipImageGeneration. Drafts are exempt.
resolveOfferThresholdMinorUnit (submit route) maps
LIKENESS → IP_TERMS_TYPES.Likeness.
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.
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.
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:
POST /api/offers/upload-reference-video with { filename, contentType }
returns { uploadUrl, publicUrl } (tiny JSON).PUTs the file directly to S3 (no credentials, Content-Type
matching what was signed).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.
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.
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.productImageUrls for the video — that array is
.url()-validated, image-only, and consumed by generation.