casanovalabs

Article 50 of the EU AI Act becomes applicable on 2 August 2026. It is one of the few provisions in the regulation that translates directly into a coding task rather than a policy document: an AI system that generates images, audio, or video has to produce outputs that can be detected as artificially generated. We build CasaNova Labs, an AI studio for real estate photo and video editing, and we shipped it ahead of the date — across every image and every video the product outputs. This is a description of the actual code, not a compliance explainer: file paths, function names, and the two failure modes we hit that no amount of reading the regulation would have surfaced.

Two obligations, two different actors

Article 50 bundles together requirements that fall on different parties. The two relevant here:

  • §2 — obligation on the provider of the generative system (us). Every synthetic output must carry marking in a machine-readable format, detectable by an automated tool. Nothing about human visibility.
  • §4 — obligation on the deployer, the one who publishes. When content qualifies as a deepfake under Art. 3(60) — manipulated content resembling existing people, objects, or places, in a way that could pass as authentic — it must be disclosed "in a clear and recognisable manner." A virtually staged photo of a real listing fits that definition closely: an existing place, a manipulated image, an authentic appearance.

These are not the same mechanism. §2 is metadata a detector reads; §4 is a label a human sees. Our codebase keeps them in two separate modules for exactly this reason: src/lib/server/ai-marking.ts for §2, src/lib/server/visible-ai-mark.ts and src/lib/server/visible-ai-mark-video.ts for §4.

The one claim we make about our own implementation, and the only one this article will make: our outputs carry machine-readable marking within the meaning of Article 50(2).

The image path

For still images, markGeneratedImage in src/lib/server/ai-marking.ts writes EXIF and XMP through sharp (the repo pins sharp@^0.35.3). The EXIF side:

function buildExifOptions(generationId: string) {
  return {
    IFD0: {
      Software: getSoftwareTag(),
      ImageDescription: AI_GENERATED_IMAGE_DESCRIPTION,
    },
    IFD2: {
      UserComment: generationId,
    },
  };
}

Enter fullscreen mode Exit fullscreen mode

withExif() fully replaces IFD0/IFD2 instead of merging, which is what keeps re-marking idempotent. UserComment (Exif sub-IFD, tag 0x9286) is where the generation id ends up, not a more obvious field like ImageUniqueID — the code comment notes that sharp writes that one empty without erroring, so it was dropped after being verified not to round-trip.

Alongside EXIF, the same call writes an XMP packet with the IPTC DigitalSourceType field set to trainedAlgorithmicMedia — the controlled-vocabulary value AI-content detectors actually look for:

const marked = await sharp(bytes)
  .withExif(exifOptions)
  .withXmp(DIGITAL_SOURCE_TYPE_XMP)
  .toBuffer();

Enter fullscreen mode Exit fullscreen mode

The function has one hard rule, stated in its own doc comment: it must never throw, and must never fail a generation. Unsupported format, corrupted buffer, sharp error — anything falls back to returning the original bytes unmarked rather than breaking delivery. A missing mark is a compliance gap; a broken generation is a production incident, and the code treats the second as strictly worse.

It's called from a single choke point, persistGeneratedOutputFromUrl in src/lib/server/uploads.ts, right after the generated bytes are fetched and before either storage backend (Supabase or local) writes them — so both media types and both backends get marked bytes without duplicating the call.

One gotcha worth calling out for anyone doing the same thing: sharp(bytes).resize({width: 32}).webp() strips all EXIF by default. Add .withMetadata() after any resize and it survives. We had two thumbnail routes — src/app/generated/[...slug]/route.ts and src/app/[locale]/s/[token]/media/[assetId]/[variant]/route.ts — doing exactly that resize without the call, which meant every thumbnail silently shipped unmarked regardless of what the original file carried. Both now call .withMetadata().

The video path

sharp cannot touch MP4, and we didn't want a hard dependency on a video toolchain just to append metadata. An MP4 is a flat sequence of ISO-BMFF boxes — [4-byte big-endian size][4-byte type][payload] — and the XMP spec (part 3, "Storage in Files") reserves a top-level uuid box, carrying a fixed UUID, for exactly this purpose. So markGeneratedVideo in the same ai-marking.ts file writes the marking as a byte-level append, no re-encode:

const boxes = readTopLevelBoxes(bytes);
if (!boxes) return bytes;
if (hasXmpUuidBox(bytes, boxes)) return bytes;

const packet = Buffer.from(buildVideoXmpPacket(generationId), "utf-8");
const header = Buffer.alloc(8);
header.writeUInt32BE(8 + ISO_BMFF_XMP_UUID.length + packet.length, 0);
header.write("uuid", 4, "latin1");

return Buffer.concat([bytes, header, ISO_BMFF_XMP_UUID, packet]);

Enter fullscreen mode Exit fullscreen mode

Where the box goes matters more than it looks. The moov atom's stco table stores absolute byte offsets into mdat. Insert anything before mdat and every offset now points to the wrong place — measured directly: a box inserted right after ftyp produced a file that decoded 0 frames (Invalid NAL unit size). Appended after the last box, it shifts nothing, and decoding is unaffected. That ordering constraint is the whole reason this isn't "obviously" implementable by just picking a natural-looking insertion point.

The §4 visible mark on video is a separate pass, in src/lib/server/visible-ai-mark-video.ts, and it does need ffmpeg — it overlays a rendered label PNG onto every frame and re-encodes:

const { code, stderr } = await run("ffmpeg", [
  "-y", "-i", inputFile, "-i", overlayFile,
  "-filter_complex", `[0:v][1:v]overlay=${overlay.left}:${overlay.top}:format=auto`,
  "-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
  "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-c:a", "copy",
  outputFile,
], FFMPEG_TIMEOUT_MS);

Enter fullscreen mode Exit fullscreen mode

The overlay itself — the pill-shaped label with "AI-generated image/video" — is rendered once with Pango text metrics via sharp in visible-ai-mark.ts (buildVisibleAiMarkOverlay) and shared between the image compositing path and the ffmpeg filter, so both media types render an identical label rather than two implementations drifting apart.

Because ffmpeg rebuilds the MP4 container from scratch, it would wipe out an XMP uuid box appended before the re-encode. So call order in persistGeneratedOutputFromUrl is: visible mark (ffmpeg) first, machine mark (byte append) second — reversed, and the §2 marking silently disappears on every video.

Scope: marking at the source

Article 50(2) asks for outputs to carry a machine-readable indication that they were artificially generated, and that is precisely what the code above does: written at generation time, at the single point every output funnels through, in a controlled vocabulary any parser can read. It's worth separating from the neighbouring problem it often gets confused with — cryptographically binding a file to its origin, which is a different threat model, a different toolchain and a different order of cost. §2 is the mechanical one, and it's the one that ships today.

The one thing worth stealing from this build, if you take nothing else: the two visible-mark dependencies (system fonts for Pango, ffmpeg) fail silently when absent. No exception, no log — just an output that looks fine and isn't marked. A local test suite can't catch it, because it runs on a machine where both packages happen to be installed. So /api/health reports marking.fonts and marking.ffmpeg as a runtime check, and this whole class of failure becomes one curl away instead of invisible in production. If you implement §2 yourself, instrument your rendering dependencies before you ship.

Closing

The implementation-level takeaway is that Article 50(2) is a small, mechanical requirement once you separate it cleanly from §4: write a controlled-vocabulary metadata field, in a format that survives your own resize and re-encode pipeline, at the one point where every output funnels through. The video path in particular didn't need a heavyweight dependency — it needed reading the ISO-BMFF spec closely enough to find the one insertion point that doesn't corrupt the file.

We shipped this ahead of the 2 August 2026 date across every image and video CasaNova Labs produces. If you're building on the generation side and hit the same two traps — the resize that eats your metadata, the re-encode that eats your container box — the fixes are the two ordering rules above, and they cost an afternoon.