EN FR

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.

5 min read11 sectionsChapter 10 / 22

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:

src/media/library.ts — created by the integration ts
export const library = import.meta.glob<{ default: ImageMetadata }>(
  './**/*.{png,jpg,jpeg,webp,avif}',
  { eager: true },
);
Why a relative pattern

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:

StageWhereWhat 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.
Why the browser does the pixel work

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.
What the test covers, and what it does not

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:

bash bash
INLINE_HEIC_SAMPLE=/path/to/photo.heic npm run test:heic

What the function verifies

Nothing the caller declares is believed: not the announced MIME type, not the file name, not the dimensions.

CheckRuleRefusal
Rate30 uploads per quarter of an hour429
Declared size20 MB413, before reading the body
Identityvalid session cookie401
Received size20 MB413
FormatJPEG, PNG or WebP, recognised from the bytes415, stating what was recognised
Dimensionsbetween 1 and 10,000 px415
Namealways rewritten, then re-checked400
Collisionan 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.

text text
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

  1. Copy the file

    Into src/media/, named in lowercase, without accents or spaces.

  2. 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
    }

    src is a file name, not a path. Dimensions must be the real ones: they reserve the space before loading.

  3. 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"
    />
The build fails if the file is missing

[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:

recognised forms text
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/123456789

A 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

astro astro
<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

  • alt everywhere — enforced by the schema and by <Image />.
  • width and height always: no layout shift.
  • AVIF and WebP produced at build time, with a fallback.
  • A width set matched to the layout, through widths and sizes.
  • File names that describe the image — they count, and they are normalised on upload.

If something is off

SymptomCause
“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 secondsNormal: the dedicated decoder is loaded, then the photo is decoded. Once per session.
The published image does not appearThe file is in the repository, but the HTML has not been rebuilt yet. Wait for the build.
The build cannot find the imageName case, different extension, or the file was put in public/.
Upload refused for sizeMore than 20 MB after browser processing: rare, and a sign of an unusual source image.