embedded-image-studio
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 le
SKILL.mdfeatures/image-studio/SKILL.md
GitHubEmbedded 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
initialUrlwas 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.
onSavedfires withresult.primary.publicUrlready 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" |
| Applet cover / preview | ["applet-cover", "fb-post", "ig-square"] |
"applet-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.
// Bad — every page's variants land in /image-studio/social-image/
defaultFilenameBase="social-image"
// Good — every page gets its own folder, slugged from the title
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", oruseState<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/base64plumbing. 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
// ❌ Plain URL input
<input type="url" value={ogImage} onChange={(e) => setOgImage(e.target.value)} />
// ❌ Single-size uploader that returns a dataUrl
<ImageAssetUploader onComplete={(r) => setOgImage(r.dataUrl)} />
// ❌ Saving a data: URL into Redux/DB
dispatch(updateMetadata({ ogImage: canvas.toDataURL() }));
// ❌ Manual `uploadFile` + `getSignedUrl` chains for a public image
const f = await uploadFile(...); const s = await getSignedUrl(f.id);
setUrl(s); // ← signed URL expires, breaks shares
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.
onSavedcallback — host receivesresult.primary.publicUrlto fold back into its state.
Forbidden in callers
- Don't read
variant.dataUrlfor anything user-facing oncesavedAtis set. - Don't bypass
onSavedto 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
rootFolderSegmentacross 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
components/EmbeddedImageStudio.tsx— the component itself.hooks/useImageStudio.ts— the underlying state machine.presets.ts— the preset catalog (add new presets here, never inline).components/InitialCropWindow.tsx— the crop step (floating window; the older Dialog wrapper was removed in 2026-05).../files/SKILL.md— the cloud-files contract this component honors.
Change log
- 2026-05-04 — Three-way intake (Drop / Paste URL / From library). URL paste and library pick emit
onSavedimmediately with the chosen URL asprimary— no forced pipeline. Stops pushing users out of the system when they already have what they need. Also fixed a closure-staleness bug insaveAllwhere variants saved right aftergenerate()had nopublicUrlbecause the function read from a frozenfilesreference; now reads throughfilesRef.current. - 2026-05-03 — Initial skill. Defines
EmbeddedImageStudioas the canonical image-input component for every form. SavePageTab is the reference implementation.