Chapter 10Build
Images and videos
An 8 MB photo straight from a phone is the normal case, not the edge case. Where each processing step happens, and why no video is ever uploaded.
Where images live
In src/media/, not in public/media/. That is the only way for
<Image /> from astro:assets to process them at build time:
AVIF, WebP, a set of widths, dimensions written into the HTML.
src/media/photo.webp
Processed at build time. Several formats, several widths, width and height in the tag, a hash in the served name.
public/media/photo.webp
Served as-is. No modern format, no alternative width, no dimensions — so a layout shift on load.
The Media component lives in the package, the images live in the site: so the
file lookup must run on the site side. For that the integration creates a three-line file,
src/media/library.ts, to be left as is:
export const library = import.meta.glob<{ default: ImageMetadata }>(
'./**/*.{png,jpg,jpeg,webp,avif}',
{ eager: true },
);An absolute pattern would be resolved from the project root, whose spelling varies with how the build is launched — on Windows, the case of the drive letter alone is enough to break it.
The three processing stages
Each one happens where it has the means to:
| Stage | Where | What happens |
|---|---|---|
| 1. Decode, orient, crop, resize, WebP conversion | Browser | A 15 MB photo leaves as a few hundred kilobytes. |
| 2. Check and store | Function | Format recognised from the bytes, dimensions read from the header, file renamed, written to the repository. |
| 3. AVIF, WebP, width set | Build | <Image /> from astro:assets. |
The edge function runtime has no codec, and compiling WebAssembly at runtime is forbidden there: the only route would be a WASM module, which would not start. The result is better anyway — what crosses the network is counted in hundreds of kilobytes, and the repository does not fill up with raw photos.
What the client actually does
They click the image, pick a file from their device, and fill in a single field: “Image description”, pre-filled when the file name means something. They never have to resize or convert anything — that is a project rule, not a convenience.
iPhone HEIC photos
An iPhone shoots HEIC by default. Safari can display it, Chrome and Firefox cannot. Without handling, a client on a PC who received a photo by AirDrop or email would have their file refused — that is, they would be asked to convert it, exactly what the project forbids.
- The browser decoder is tried first.
- On failure, and only if the file is recognised as HEIC from its bytes, a dedicated decoder is loaded.
- That module weighs 1.4 MB and only ships in that case: a client who never drops a HEIC never downloads it.
- A 12 Mpx photo decodes in about 1.4 s, then rejoins the common path — crop, resize, WebP.
Full decoding cannot be tested without a real photo: no HEVC encoder is available to make
one, and a personal photo has no business in a repository. So
npm run test:heic always checks format recognition and
explicitly skips decoding for lack of a sample. To run it in full:
INLINE_HEIC_SAMPLE=/path/to/photo.heic npm run test:heicWhat the function verifies
Nothing the caller declares is believed: not the announced MIME type, not the file name, not the dimensions.
| Check | Rule | Refusal |
|---|---|---|
| Rate | 30 uploads per quarter of an hour | 429 |
| Declared size | 20 MB | 413, before reading the body |
| Identity | valid session cookie | 401 |
| Received size | 20 MB | 413 |
| Format | JPEG, PNG or WebP, recognised from the bytes | 415, stating what was recognised |
| Dimensions | between 1 and 10,000 px | 415 |
| Name | always rewritten, then re-checked | 400 |
| Collision | an already-taken name gets a suffix | — |
A .jpg that actually contains an SVG is refused; so is a video file — with a
message saying which of the two it was, so the interface can be clear.
The file name
The name sent by the browser is never used as-is. It is rewritten: lowercase, accents removed, spaces and special characters replaced by hyphens, extension derived from the real format.
Photo de l'Équipe (2).HEIC → photo-de-l-equipe-2.webp
IMG_4832.JPG → img-4832.jpg
The same whitelist decides what /api/upload writes and what
/api/save accepts to see referenced in content:
^[a-z0-9]+(?:-[a-z0-9]+)*\.(jpg|png|webp)$. Content pointing at a file outside
that shape is refused, even if the file exists.
Adding an image as a developer
-
Copy the file
Into
src/media/, named in lowercase, without accents or spaces. -
Reference it in the content
json json { "type": "media", "kind": "image", "src": "bakehouse-at-dawn.webp", "alt": "The bakehouse at dawn", "width": 1600, "height": 900 }srcis a file name, not a path. Dimensions must be the real ones: they reserve the space before loading. -
Place it in the page
astro astro <Media data={data} path="blocks.showcase.visual" widths={[480, 800, 1200, 1600]} sizes="(max-width: 48rem) 100vw, 48rem" />
[Media] Le fichier « … » est absent de src/media. The message lists the
available files: it is almost always a case difference or a mismatched extension.
Videos
The schema accepts only a provider and an id. No video file enters the repository: a heavy file in Git breaks the repository and the builds, permanently, with no simple way back.
The client pastes whatever they have at hand. All these forms work, because none is more “correct” than the others from their point of view:
https://www.youtube.com/watch?v=aqz-KE-bpKQ
https://youtu.be/aqz-KE-bpKQ
https://www.youtube.com/embed/aqz-KE-bpKQ
https://www.youtube.com/shorts/aqz-KE-bpKQ
https://www.youtube.com/live/aqz-KE-bpKQ
youtube.com/watch?v=aqz-KE-bpKQ (no protocol)
<iframe src="https://www.youtube.com/embed/aqz-KE-bpKQ" …></iframe> (whole embed code pasted)
https://vimeo.com/123456789
https://player.vimeo.com/video/123456789
https://vimeo.com/channels/staffpicks/123456789A YouTube id is exactly eleven characters; a Vimeo id is numeric. These rules are checked on both sides: on entry, and on write by the function, which refuses an inconsistent pair.
What gets rendered
<figure data-cms="blocks.showcase.film" data-cms-type="media" data-cms-kind="video">
<iframe src="https://www.youtube.com/embed/aqz-KE-bpKQ" title="A night in the bakehouse" loading="lazy" …></iframe>
<figcaption>A night in the bakehouse</figcaption>
</figure>
The iframe content sits with the provider; the title is in the served HTML.
loading="lazy" takes nothing away from indexing: this is not component hydration,
the element is there in the source.
Image SEO
alteverywhere — enforced by the schema and by<Image />.widthandheightalways: no layout shift.- AVIF and WebP produced at build time, with a fallback.
- A width set matched to the layout, through
widthsandsizes. - File names that describe the image — they count, and they are normalised on upload.
If something is off
| Symptom | Cause |
|---|---|
| “This image format is not accepted” | The file is neither JPEG, PNG nor WebP by its bytes — whatever its extension. |
| A HEIC photo takes several seconds | Normal: the dedicated decoder is loaded, then the photo is decoded. Once per session. |
| The published image does not appear | The file is in the repository, but the HTML has not been rebuilt yet. Wait for the build. |
| The build cannot find the image | Name case, different extension, or the file was put in public/. |
| Upload refused for size | More than 20 MB after browser processing: rare, and a sign of an unusual source image. |