| name | embedded-image-studio |
| description | Use whenever a form needs to accept ONE image and produce permanent public URLs for one or more platform-specific sizes (OG image, social posts, avatars, logos, favicons, app icons). Replaces every legacy image-URL input, single-size uploader, and base64-data-URL flow. Triggers when you see a manual `<input type="url">` for an image, a single-size uploader, or anywhere a `dataUrl` could leak into a clipboard or a saved record. |
Embedded Image Studio — Skill
Drop the <EmbeddedImageStudio> component into any form that needs an image. The user drops a file → cropper opens → all caller-requested sizes generate → variants upload as public to Cloud Files → host receives permanent Cloudflare CDN URLs.
This is the only approved pattern for image inputs going forward. It is forbidden to:
- Save base64
data: URLs in any record (chat clipboard, DB column, redux slice).
- Use
<input type="url"> to capture an image URL when an embedded studio could capture it from a real file upload.
- Upload images with
visibility: "private" for fields meant to be embedded in HTML, OG meta tags, emails, or anywhere else a paste might appear.
- Generate variants client-side with
canvas.toDataURL() and ship them as URLs.
- Hand-roll a "drop → resize → upload" pipeline. There is one pipeline; use it.
Three intake paths — all built in
The user has three ways to give us an image. The component surfaces them up-front so we never push the user out of our system to find what they already have.
| Path | When to use | What happens |
|---|
| Drop / Browse | Brand-new image | Crop dialog → Sharp generates every preset → uploads as PUBLIC → CDN URLs returned |
| Paste URL | The user already has a public URL elsewhere | The URL is used as-is. No re-upload, no resize. onSaved fires immediately with that URL as primary. |
| From library | The user already saved this image to Cloud Files | Opens useFilePicker → user picks a file → its permanent CDN publicUrl (or a 1h signed URL for private files) is used as-is. onSaved fires immediately. |
Drop is the "I want all the sizes" path. The other two are "I already have what I need" paths — they emit result.primary.publicUrl straight away so the host form gets populated with one click. Never push a user out of the system to find an image they already have.
The pattern at a glance
import { EmbeddedImageStudio } from "@/features/image-studio/components/EmbeddedImageStudio";
<EmbeddedImageStudio
// Which preset sizes to auto-generate. From features/image-studio/presets.ts.
presetIds={["og-image", "fb-post", "tw-card-large", "ig-square"]}
// Which one's URL the host actually cares about — the result's `primary` URL.
primaryPresetId="og-image"
// Folder segment under Images/Generated/. One per host feature.
rootFolderSegment="html-pages"
// Recommended: pass a meaningful default (page title, app name, person's name).
defaultFilenameBase={page.title || "page-social-image"}
// Show the existing public URL with a Replace CTA when the field is already set.
initialUrl={page.ogImage || null}
disabled={!user}
label="Social Share Image"
onSaved={(result) => {
// result.primary.publicUrl is a permanent Cloudflare CDN URL.
if (result.primary?.publicUrl) {
setOgImage(result.primary.publicUrl);
}
// result.byPreset has every preset's CDN URL — store them too if useful.
}}
onCleared={() => setOgImage("")}
/>
What the user sees
- Drop zone (or "Replace" button if
initialUrl was set).
- InitialCropWindow opens automatically (a floating WindowPanel) with aspect-ratio chips, panning, and resize handles.
- Source preview + filename input — the user can rename the slug; the slug becomes the per-source subfolder AND every variant's filename.
- "Generate & save" button — one click runs Sharp + cloud upload.
- Variant grid — every requested preset shows preview, dimensions, file size, compression %, and a permanent CDN URL with a Copy button.
onSaved fires with result.primary.publicUrl ready to drop into the host's state.
Where the bytes go
Cloud Files
└── Images/
└── Generated/
└── <rootFolderSegment>/ ← e.g. html-pages
└── <filenameBase>/ ← e.g. how-to-bake-bread
├── how-to-bake-bread-og-image.webp
├── how-to-bake-bread-fb-post.webp
├── how-to-bake-bread-tw-card-large.webp
└── how-to-bake-bread-ig-square.webp
Every file is visibility: "public" → API returns public_url → the result is a https://cdn.aidream.com/.../...?v=<checksum> URL that:
- Never expires.
- Cache-busts automatically when content changes (the checksum suffix flips).
- Is served from Cloudflare's CDN, not a presigned S3 URL.
Picking the right presetIds
Read presets.ts for the full catalog (60+ across 10 categories). The right set depends on the host feature.
| Host feature | Suggested presetIds | primaryPresetId |
|---|
| HTML pages / blog SEO | ["og-image", "fb-post", "tw-card-large", "ig-square"] | "og-image" |
| Agent app cover / preview | ["agent-app-cover", "fb-post", "ig-square"] | "agent-app-cover" |
| Avatar / profile picture | ["avatar-xl", "avatar-lg", "avatar-md", "avatar-sm", "avatar-xs"] | "avatar-md" |
| Org logo | ["logo-xl", "logo-md", "logo-favicon"] | "logo-md" |
| Favicon set | ["favicon-32", "favicon-192", "apple-touch-icon", "android-chrome-192", "android-chrome-512"] | "favicon-192" |
| Podcast cover art | ["podcast-cover", "ig-square", "fb-post"] | "podcast-cover" |
| Single OG-only field | ["og-image"] | "og-image" |
If a preset you need doesn't exist, add it to PRESET_CATEGORIES in presets.ts — don't pass arbitrary dimensions inline.
When the host has a richer surface
If the host feature wants different filenames per record (e.g. per-page title), pass defaultFilenameBase from that record. It's used as the per-source subfolder AND as the prefix for every variant filename.
defaultFilenameBase="social-image"
defaultFilenameBase={page.title}
When the user later AI-describes the image (or types a new name), the variants are regenerated under the new slug.
Replacing existing image inputs
Step-by-step migration recipe
- Identify the field. Search for
<input type="url"> near the word "image", or useState<string> whose default is an image URL.
- Identify the presets. Match the field's purpose to the table above.
- Identify a meaningful filename source. Title, name, slug, agent name, etc.
- Replace the input with
<EmbeddedImageStudio> exactly as shown above.
- Remove any
dataUrl/base64 plumbing. The component never returns one.
- Verify by uploading an image: the URL the host stores should start with
https://cdn. and contain ?v=.
Anti-patterns to delete on sight
<input type="url" value={ogImage} onChange={(e) => setOgImage(e.target.value)} />
<ImageAssetUploader onComplete={(r) => setOgImage(r.dataUrl)} />
dispatch(updateMetadata({ ogImage: canvas.toDataURL() }));
const f = await uploadFile(...); const s = await getSignedUrl(f.id);
setUrl(s);
What the component handles for you
- Drop / paste / browse — multi-source intake (file picker, drag-drop, paste-from-clipboard).
- EXIF rotation — Sharp auto-rotates so portrait phone shots aren't sideways.
- Crop dialog — full freeform + aspect-ratio chips, with rule-of-thirds guides.
- Per-source subfolder — variants for one image stay grouped, never jumbled.
- Filename gate — auto-named files surface a banner before mass generation.
- Public-by-default —
visibility: "public" so the API returns a CDN URL.
- Per-variant copy buttons — every result has a "Copy CDN" action. We never copy a data URL once the variant is saved.
- Replace flow — clicking Replace clears state and re-opens the drop zone.
onSaved callback — host receives result.primary.publicUrl to fold back into its state.
Forbidden in callers
- Don't read
variant.dataUrl for anything user-facing once savedAt is set.
- Don't bypass
onSaved to manually fish URLs out of the cloud-files slice.
- Don't override
visibility: "private" unless the field is genuinely never going to be embedded anywhere (rare — usually the answer is public).
- Don't render the
<EmbeddedImageStudio> inside a tight grid cell. It needs ~400px of width.
- Don't reuse the same
rootFolderSegment across unrelated features. Use the host feature's slug.
Reference implementation
SavePageTab.tsx — the canonical example. Copy the pattern:
<EmbeddedImageStudio
presetIds={["og-image", "fb-post", "tw-card-large", "ig-square"]}
primaryPresetId="og-image"
rootFolderSegment="html-pages"
defaultFilenameBase={state.metadata.title?.trim() || "page-social-image"}
initialUrl={state.metadata.ogImage || null}
disabled={!user}
label="Social Share Image"
onSaved={(result) => {
if (result.primary?.publicUrl) {
actions.setMetadataField("ogImage", result.primary.publicUrl);
}
}}
onCleared={() => actions.setMetadataField("ogImage", "")}
/>
Files of record
Change log
- 2026-05-04 — Three-way intake (Drop / Paste URL / From library). URL paste and library pick emit
onSaved immediately with the chosen URL as primary — no forced pipeline. Stops pushing users out of the system when they already have what they need. Also fixed a closure-staleness bug in saveAll where variants saved right after generate() had no publicUrl because the function read from a frozen files reference; now reads through filesRef.current.
- 2026-05-03 — Initial skill. Defines
EmbeddedImageStudio as the canonical image-input component for every form. SavePageTab is the reference implementation.