diff --git a/src/animated-cache.js b/src/animated-cache.js
new file mode 100644
index 0000000000000000000000000000000000000000..3ab3e8466866d99c20959638523c00cd6d4ca819
--- /dev/null
+++ b/src/animated-cache.js
@@ -0,0 +1,240 @@
+import { createHash } from 'node:crypto';
+import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
+import path from 'node:path';
+
+const CACHE_DIR = path.join(process.cwd(), 'node_modules/.cache/enhanced-img-animated');
+
+/**
+ * @param {string} filepath
+ * @returns {string}
+ */
+function file_hash(filepath) {
+	const content = readFileSync(filepath);
+	return createHash('sha256').update(content).digest('hex');
+}
+
+// ─── Animated WebP cache ─────────────────────────────────────────────────────
+
+/**
+ * @typedef {{ width: number, height: number, pages: number, loop: number }} AnimatedMeta
+ * @returns {{ webp_buf: Buffer, meta: AnimatedMeta } | null}
+ */
+export function read_animated_cache(filepath) {
+	try {
+		const dir = path.join(CACHE_DIR, 'animated', file_hash(filepath));
+		const webp_path = path.join(dir, 'output.webp');
+		const meta_path = path.join(dir, 'meta.json');
+		if (!existsSync(webp_path) || !existsSync(meta_path)) return null;
+		return {
+			webp_buf: readFileSync(webp_path),
+			meta: JSON.parse(readFileSync(meta_path, 'utf8'))
+		};
+	} catch {
+		return null;
+	}
+}
+
+/**
+ * @param {string} filepath
+ * @param {Buffer} webp_buf
+ * @param {AnimatedMeta} meta
+ */
+export function write_animated_cache(filepath, webp_buf, meta) {
+	try {
+		const dir = path.join(CACHE_DIR, 'animated', file_hash(filepath));
+		mkdirSync(dir, { recursive: true });
+		writeFileSync(path.join(dir, 'output.webp'), webp_buf);
+		writeFileSync(path.join(dir, 'meta.json'), JSON.stringify(meta));
+	} catch { /* non-fatal */ }
+}
+
+// ─── MP4 / video cache ───────────────────────────────────────────────────────
+
+/**
+ * @typedef {{ width: number, height: number }} VideoMeta
+ * @returns {{ webm_buf: Buffer, meta: VideoMeta } | null}
+ */
+export function read_video_cache(filepath) {
+	try {
+		const dir = path.join(CACHE_DIR, 'video', file_hash(filepath));
+		const webm_path = path.join(dir, 'output.webm');
+		const meta_path = path.join(dir, 'meta.json');
+		if (!existsSync(webm_path) || !existsSync(meta_path)) return null;
+		return {
+			webm_buf: readFileSync(webm_path),
+			meta: JSON.parse(readFileSync(meta_path, 'utf8'))
+		};
+	} catch {
+		return null;
+	}
+}
+
+/**
+ * @param {string} filepath
+ * @param {Buffer} webm_buf
+ * @param {VideoMeta} meta
+ */
+export function write_video_cache(filepath, webm_buf, meta) {
+	try {
+		const dir = path.join(CACHE_DIR, 'video', file_hash(filepath));
+		mkdirSync(dir, { recursive: true });
+		writeFileSync(path.join(dir, 'output.webm'), webm_buf);
+		writeFileSync(path.join(dir, 'meta.json'), JSON.stringify(meta));
+	} catch { /* non-fatal */ }
+}
+
+// ─── General imagetools cache ─────────────────────────────────────────────────
+// Each cache entry stores the emitted asset buffers plus the module code template
+// with __VITE_ASSET__ refs replaced by stable placeholder keys ($REF[key]).
+// On cache hit the buffers are re-emitted (producing fresh ref IDs) and the
+// placeholders are substituted with the new IDs before the module is returned.
+
+/**
+ * @typedef {{ key: string, name: string, source: Buffer }} CachedAsset
+ * @typedef {{ assets: CachedAsset[], template: string }} ImgCacheEntry
+ */
+
+/**
+ * @param {string} filepath
+ * @param {string} query  raw query string (everything after '?')
+ * @returns {ImgCacheEntry | null}
+ */
+export function read_img_cache(filepath, query) {
+	try {
+		const hash = createHash('sha256')
+			.update(file_hash(filepath))
+			.update('\0')
+			.update(normalise_query(query))
+			.digest('hex');
+		const dir = path.join(CACHE_DIR, 'img', hash);
+		const index_path = path.join(dir, 'index.json');
+		if (!existsSync(index_path)) return null;
+		const index = JSON.parse(readFileSync(index_path, 'utf8'));
+		const assets = index.assets.map((/** @type {any} */ a) => ({
+			key: a.key,
+			name: a.name,
+			source: readFileSync(path.join(dir, a.file))
+		}));
+		return { assets, template: index.template };
+	} catch {
+		return null;
+	}
+}
+
+/**
+ * @param {string} filepath
+ * @param {string} query
+ * @param {ImgCacheEntry} entry
+ */
+export function write_img_cache(filepath, query, entry) {
+	try {
+		const hash = createHash('sha256')
+			.update(file_hash(filepath))
+			.update('\0')
+			.update(normalise_query(query))
+			.digest('hex');
+		const dir = path.join(CACHE_DIR, 'img', hash);
+		mkdirSync(dir, { recursive: true });
+		const index_assets = entry.assets.map((a, i) => {
+			const file = `asset_${i}_${a.name}`;
+			writeFileSync(path.join(dir, file), a.source);
+			return { key: a.key, name: a.name, file };
+		});
+		writeFileSync(path.join(dir, 'index.json'), JSON.stringify({
+			assets: index_assets,
+			template: entry.template
+		}));
+	} catch { /* non-fatal */ }
+}
+
+/**
+ * Normalises a query string so key order doesn't matter for cache keying.
+ * @param {string} query
+ * @returns {string}
+ */
+function normalise_query(query) {
+	const p = new URLSearchParams(query);
+	return [...p.entries()].sort().map(([k, v]) => `${k}=${v}`).join('&');
+}
+
+/**
+ * Wraps a Vite plugin's load hook with the image cache.
+ * Intercepts emitFile calls during the original load to capture asset buffers,
+ * then writes them to cache. On subsequent builds, re-emits cached buffers and
+ * reconstructs the module code.
+ *
+ * @param {import('vite').Plugin} plugin  the imagetools plugin instance
+ * @returns {import('vite').Plugin}
+ */
+export function with_img_cache(plugin) {
+	const original_hook = plugin.load;
+	if (!original_hook) return plugin;
+	const original_fn = typeof original_hook === 'object' ? original_hook.handler : original_hook;
+
+	/**
+	 * @this {import('vite').Rollup.PluginContext}
+	 * @param {string} id
+	 */
+	async function cached_load(id) {
+		// Skip cache entirely in dev/watch mode — emitFile semantics differ there.
+		if (this.meta?.watchMode) return original_fn.call(this, id);
+
+		const q = id.indexOf('?');
+		if (q < 0) return original_fn.call(this, id);
+		const query = id.slice(q + 1);
+		const params = new URLSearchParams(query);
+		if (!params.has('enhanced')) return original_fn.call(this, id);
+
+		const filepath = id.slice(0, q);
+
+		const cached = read_img_cache(filepath, query);
+		if (cached) {
+			/** @type {Record<string, string>} */
+			const new_refs = {};
+			for (const asset of cached.assets) {
+				new_refs[asset.key] = this.emitFile({ type: 'asset', name: asset.name, source: asset.source });
+			}
+			return cached.template.replace(/\$REF\[([^\]]+)\]/g, (_, k) => `__VITE_ASSET__${new_refs[k]}__`);
+		}
+
+		// Cache miss — call original, intercept emitFile to capture assets.
+		/** @type {CachedAsset[]} */
+		const emitted = [];
+		const original_emit = this.emitFile.bind(this);
+
+		const proxy_ctx = new Proxy(this, {
+			get(target, prop) {
+				if (prop === 'emitFile') {
+					return (/** @type {any} */ opts) => {
+						const ref = original_emit(opts);
+						if (opts.type === 'asset' && opts.source) {
+							emitted.push({ key: ref, name: opts.name ?? '', source: Buffer.from(opts.source) });
+						}
+						return ref;
+					};
+				}
+				return /** @type {any} */ (target)[prop];
+			}
+		});
+
+		const result = await original_fn.call(proxy_ctx, id);
+
+		if (result && emitted.length > 0) {
+			let template = typeof result === 'string' ? result : result.code;
+			for (const asset of emitted) {
+				template = template.replaceAll(`__VITE_ASSET__${asset.key}__`, `$REF[${asset.key}]`);
+			}
+			write_img_cache(filepath, query, { assets: emitted, template });
+		}
+
+		return result;
+	}
+
+	if (typeof original_hook === 'object') {
+		plugin.load = { ...original_hook, handler: cached_load };
+	} else {
+		plugin.load = cached_load;
+	}
+
+	return plugin;
+}
diff --git a/src/index.js b/src/index.js
index 013b9405436dd61d02d947a49c56ae1d2cad9f57..2f9205521c0bd01646f00cd051f158360322d4de 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,17 +1,131 @@
 import process from 'node:process';
+import path from 'node:path';
+import sharp from 'sharp';
 import { imagetools } from 'vite-imagetools';
 import { image_plugin } from './vite-plugin.js';
+import { read_animated_cache, write_animated_cache, read_video_cache, write_video_cache, with_img_cache } from './animated-cache.js';
+import { video_dimensions, mp4_to_webm } from './video-convert.js';
 
 /**
  * @returns {import('vite').Plugin[]}
  */
 export function enhancedImages() {
-	const imagetools_instance = imagetools_plugin();
+	const imagetools_instance = with_img_cache(imagetools_plugin());
+	const animated_instance = animated_image_plugin();
 	return !process.versions.webcontainer
-		? [image_plugin(imagetools_instance), imagetools_instance]
+		? [animated_instance, image_plugin(imagetools_instance), imagetools_instance]
 		: [];
 }
 
+/**
+ * Vite plugin (enforce: 'pre') that intercepts animated GIF/WebP imports that
+ * carry ?enhanced before imagetools can strip their frames. Produces optimised
+ * animated WebP and emits them as build assets. In dev mode the original file
+ * is served unmodified.
+ * @returns {import('vite').Plugin}
+ */
+function animated_image_plugin() {
+	/** @type {import('vite').ResolvedConfig} */
+	let vite_config;
+
+	return {
+		name: 'vite-plugin-enhanced-img-animated',
+		enforce: /** @type {'pre'} */ ('pre'),
+		configResolved(config) {
+			vite_config = config;
+		},
+		async load(id) {
+			const q = id.indexOf('?');
+			const params = new URLSearchParams(q >= 0 ? id.slice(q + 1) : '');
+			if (!params.has('enhanced')) return null;
+
+			const filepath = q >= 0 ? id.slice(0, q) : id;
+			const ext = path.extname(filepath).toLowerCase();
+
+			// ── MP4 → WebM + MP4 (for <enhanced:video>) ──────────────────────
+			if (ext === '.mp4') {
+				const stem = path.basename(filepath, ext);
+
+				if (vite_config.command === 'serve') {
+					const rel = '/' + path.relative(vite_config.root, filepath).replace(/\\/g, '/');
+					const { width, height } = await video_dimensions(filepath);
+					return (
+						`export default { sources: { webm: ${JSON.stringify(rel)} },` +
+						` video: { src: ${JSON.stringify(rel)}, w: ${width}, h: ${height} } }`
+					);
+				}
+
+				let cached_v = read_video_cache(filepath);
+				let webm_buf, vid_w, vid_h;
+				if (cached_v) {
+					({ webm_buf, meta: { width: vid_w, height: vid_h } } = cached_v);
+				} else {
+					const dims = await video_dimensions(filepath);
+					vid_w = dims.width;
+					vid_h = dims.height;
+					webm_buf = await mp4_to_webm(filepath);
+					write_video_cache(filepath, webm_buf, { width: vid_w, height: vid_h });
+				}
+
+				const webm_ref = this.emitFile({ type: 'asset', name: `${stem}.webm`, source: webm_buf });
+
+				return [
+					`export default {`,
+					`  sources: { webm: "__VITE_ASSET__${webm_ref}__" },`,
+					`  video: { src: "__VITE_ASSET__${webm_ref}__", w: ${vid_w}, h: ${vid_h} }`,
+					`}`
+				].join('\n');
+			}
+
+			if (ext !== '.gif' && ext !== '.webp') return null;
+
+			const sharp_meta = await sharp(filepath, { animated: true }).metadata();
+			if (!sharp_meta.pages || sharp_meta.pages <= 1) return null; // not animated; let imagetools handle it
+
+			const stem = path.basename(filepath, ext);
+
+			if (vite_config.command === 'serve') {
+				// Dev: serve the original file directly — it is already animated.
+				const rel = '/' + path.relative(vite_config.root, filepath).replace(/\\/g, '/');
+				const w = sharp_meta.width ?? 0;
+				const h = sharp_meta.pageHeight ?? sharp_meta.height ?? 0;
+				return (
+					`export default { sources: { ${JSON.stringify(ext.slice(1))}: ${JSON.stringify(rel)} },` +
+					` img: { src: ${JSON.stringify(rel)}, w: ${w}, h: ${h} } }`
+				);
+			}
+
+			// Build: check cache first, convert only on miss.
+			let cached = read_animated_cache(filepath);
+			let webp_buf, width, height;
+			if (cached) {
+				({ webp_buf, meta: { width, height } } = cached);
+			} else {
+				webp_buf = await sharp(filepath, { animated: true })
+					.webp({ effort: 6, loop: sharp_meta.loop ?? 0 })
+					.toBuffer();
+				width = sharp_meta.width ?? 0;
+				height = sharp_meta.pageHeight ?? sharp_meta.height ?? 0;
+				write_animated_cache(filepath, webp_buf, {
+					width,
+					height,
+					pages: sharp_meta.pages,
+					loop: sharp_meta.loop ?? 0
+				});
+			}
+
+			const webp_ref = this.emitFile({ type: 'asset', name: `${stem}.webp`, source: webp_buf });
+
+			return [
+				`export default {`,
+				`  sources: { webp: "__VITE_ASSET__${webp_ref}__" },`,
+				`  img: { src: "__VITE_ASSET__${webp_ref}__", w: ${width}, h: ${height} }`,
+				`}`
+			].join('\n');
+		}
+	};
+}
+
 /**
  * @param {import('sharp').Metadata} meta
  * @returns {string}
diff --git a/src/video-convert.js b/src/video-convert.js
new file mode 100644
index 0000000000000000000000000000000000000000..0f2ce0d75e71eee14dea3efc82cb7a7851a9f7d8
--- /dev/null
+++ b/src/video-convert.js
@@ -0,0 +1,61 @@
+import process from 'node:process';
+import { execFile } from 'node:child_process';
+import { readFileSync, unlinkSync } from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+/**
+ * @param {string} cmd
+ * @param {string[]} args
+ * @param {number} [timeout]
+ * @returns {Promise<string>}
+ */
+function run(cmd, args, timeout = 120000) {
+	return new Promise((resolve, reject) => {
+		execFile(cmd, args, { timeout }, (err, stdout, stderr) => {
+			if (err) reject(new Error(`${cmd} failed: ${(stderr || '').trim().split('\n').pop()}`));
+			else resolve(stdout.trim());
+		});
+	});
+}
+
+/** @param {string} ext */
+function tmp_path(ext) {
+	return path.join(os.tmpdir(), `enhanced_img_${process.hrtime.bigint()}.${ext}`);
+}
+
+/**
+ * Returns pixel dimensions of the first video stream via ffprobe.
+ * @param {string} filepath
+ * @returns {Promise<{ width: number, height: number }>}
+ */
+export async function video_dimensions(filepath) {
+	const out = await run('ffprobe', [
+		'-v', 'error',
+		'-select_streams', 'v:0',
+		'-show_entries', 'stream=width,height',
+		'-of', 'csv=p=0',
+		filepath
+	], 10000);
+	const [w, h] = out.split(',').map(Number);
+	return { width: w || 0, height: h || 0 };
+}
+
+/**
+ * Converts an MP4 to a VP9+Opus WebM buffer (constant-quality, audio preserved).
+ * @param {string} filepath
+ * @returns {Promise<Buffer>}
+ */
+export async function mp4_to_webm(filepath) {
+	const out = tmp_path('webm');
+	await run('ffmpeg', [
+		'-i', filepath,
+		'-c:v', 'libvpx-vp9',
+		'-b:v', '0', '-crf', '33',
+		'-c:a', 'libopus', '-b:a', '128k',
+		'-y', out
+	]);
+	const buf = readFileSync(out);
+	try { unlinkSync(out); } catch { /* ignore */ }
+	return buf;
+}
diff --git a/src/vite-plugin.js b/src/vite-plugin.js
index c8ccb26b3f17cb69eb1e725bab1cde02c88c36cf..08392ceb8d2788917c1557ce81161982d97ea60e 100644
--- a/src/vite-plugin.js
+++ b/src/vite-plugin.js
@@ -1,6 +1,8 @@
 /** @import { AST } from 'svelte/compiler' */
 import { existsSync } from 'node:fs';
 import path from 'node:path';
+import { read_animated_cache, write_animated_cache, read_video_cache, write_video_cache, read_img_cache, write_img_cache } from './animated-cache.js';
+import { video_dimensions, mp4_to_webm } from './video-convert.js';
 import MagicString from 'magic-string';
 import sharp from 'sharp';
 import { parse } from 'svelte-parse-markup';
@@ -8,6 +10,97 @@ import { walk } from 'zimmerframe';
 
 // TODO: expose this in vite-imagetools rather than duplicating it
 const OPTIMIZABLE = /^[^?]+\.(avif|heif|gif|jpeg|jpg|png|tiff|webp)(\?.*)?$/;
+// Formats that may carry animation — checked before handing off to imagetools.
+const ANIMATED_EXT = /\.(gif|webp)$/i;
+// Video MIME types (use video/* instead of image/* in <source> elements).
+const VIDEO_FORMATS = new Set(['webm', 'mp4']);
+
+/**
+ * Process an animated GIF/WebP into optimised animated WebP assets.
+ * In dev mode returns the original file path so the browser plays it directly.
+ * @param {string} filepath  Absolute path to the source file (no query params).
+ * @param {import('sharp').Metadata} meta  Sharp metadata (animated: true).
+ * @param {import('vite').Rollup.PluginContext} plugin_context
+ * @param {import('vite').ResolvedConfig} vite_config
+ * @returns {Promise<import('vite-imagetools').Picture>}
+ */
+async function process_animated(filepath, meta, plugin_context, vite_config) {
+	const width = meta.width ?? 0;
+	const height = meta.pageHeight ?? meta.height ?? 0;
+	const ext = path.extname(filepath);
+	const stem = path.basename(filepath, ext);
+
+	if (vite_config.command === 'serve') {
+		// Dev: serve the original file — already animated, no optimisation needed.
+		const rel = '/' + path.relative(vite_config.root, filepath).replace(/\\/g, '/');
+		return /** @type {any} */ ({
+			sources: { [ext.slice(1)]: rel },
+			img: { src: rel, w: width, h: height }
+		});
+	}
+
+	// Build: check cache first, convert only on miss.
+	let cached_anim = read_animated_cache(filepath);
+	let webp_buf;
+	if (cached_anim) {
+		({ webp_buf } = cached_anim);
+	} else {
+		webp_buf = await sharp(filepath, { animated: true })
+			.webp({ effort: 6, loop: meta.loop ?? 0 })
+			.toBuffer();
+		write_animated_cache(filepath, webp_buf, {
+			width,
+			height,
+			pages: meta.pages ?? 1,
+			loop: meta.loop ?? 0
+		});
+	}
+
+	const webp_ref = plugin_context.emitFile({ type: 'asset', name: `${stem}.webp`, source: webp_buf });
+
+	return /** @type {any} */ ({
+		sources: { webp: `__VITE_ASSET__${webp_ref}__` },
+		img: { src: `__VITE_ASSET__${webp_ref}__`, w: width, h: height }
+	});
+}
+
+/**
+ * Process an MP4 source into optimised WebM + MP4 assets.
+ * In dev mode returns the original file so the browser can play it directly.
+ * @param {string} filepath
+ * @param {import('vite').Rollup.PluginContext} plugin_context
+ * @param {import('vite').ResolvedConfig} vite_config
+ * @returns {Promise<{ sources: Record<string,string>, video: { src: string, w: number, h: number } }>}
+ */
+async function process_video(filepath, plugin_context, vite_config) {
+	const ext = path.extname(filepath);
+	const stem = path.basename(filepath, ext);
+
+	if (vite_config.command === 'serve') {
+		const rel = '/' + path.relative(vite_config.root, filepath).replace(/\\/g, '/');
+		const { width, height } = await video_dimensions(filepath);
+		return { sources: { webm: rel }, video: { src: rel, w: width, h: height } };
+	}
+
+	let cached_v = read_video_cache(filepath);
+	let webm_buf, width, height;
+	if (cached_v) {
+		({ webm_buf, meta: { width, height } } = cached_v);
+	} else {
+		const dims = await video_dimensions(filepath);
+		width = dims.width;
+		height = dims.height;
+		webm_buf = await mp4_to_webm(filepath);
+		write_video_cache(filepath, webm_buf, { width, height });
+	}
+
+	const webm_ref = plugin_context.emitFile({ type: 'asset', name: `${stem}.webm`, source: webm_buf });
+
+	return {
+		sources: { webm: `__VITE_ASSET__${webm_ref}__` },
+		video: { src: `__VITE_ASSET__${webm_ref}__`, w: width, h: height }
+	};
+}
 
 /**
  * Creates the Svelte image plugin.
@@ -38,7 +131,7 @@ export function image_plugin(imagetools_plugin) {
 		transform: {
 			order: 'pre', // puts it before vite-plugin-svelte:compile
 			filter: {
-				code: /<enhanced:img/ // code filter must match in addition to the id filter set in configResolved hook above
+				code: /<enhanced:(img|video)/ // code filter must match in addition to the id filter set in configResolved hook above
 			},
 
 			async handler(content, filename) {
@@ -110,8 +203,21 @@ export function image_plugin(imagetools_plugin) {
 						);
 					}
 
+					// For GIF/WebP, check animation before handing off to imagetools.
+					if (ANIMATED_EXT.test(original_url)) {
+						const filepath = resolved_id.includes('?')
+							? resolved_id.slice(0, resolved_id.indexOf('?'))
+							: resolved_id;
+						const anim_meta = await sharp(filepath, { animated: true }).metadata();
+						if (anim_meta.pages && anim_meta.pages > 1) {
+							const image = await process_animated(filepath, anim_meta, plugin_context, vite_config);
+							s.update(node.start, node.end, img_to_picture(content, node, image));
+							return;
+						}
+					}
+
 					if (OPTIMIZABLE.test(url)) {
-						const image = await process_id(resolved_id, plugin_context, imagetools_plugin);
+						const image = await process_id(resolved_id, plugin_context, imagetools_plugin, vite_config);
 						s.update(node.start, node.end, img_to_picture(content, node, image));
 					} else {
 						const metadata = await sharp(resolved_id).metadata();
@@ -132,20 +238,58 @@ export function image_plugin(imagetools_plugin) {
 				}
 
 				/**
-				 * @type {Array<ReturnType<typeof update_element>>}
+				 * Handles a static or dynamic <enhanced:video src=...> element.
+				 * @param {import('svelte/compiler').AST.RegularElement} node
+				 * @param {AST.Text | AST.ExpressionTag} src_attribute
+				 */
+				async function update_video_element(node, src_attribute) {
+					if (src_attribute.type === 'ExpressionTag') {
+						const start =
+							'end' in src_attribute.expression
+								? src_attribute.expression.end
+								: src_attribute.expression.range?.[0];
+						const end =
+							'start' in src_attribute.expression
+								? src_attribute.expression.start
+								: src_attribute.expression.range?.[1];
+						if (typeof start !== 'number' || typeof end !== 'number') {
+							throw new Error('ExpressionTag has no range');
+						}
+						const src_var_name = content.substring(start, end).trim();
+						s.update(node.start, node.end, dynamic_video_to_video(content, node, src_var_name));
+						return;
+					}
+
+					const original_url = src_attribute.raw.trim();
+					const resolved_id = (await plugin_context.resolve(original_url, filename))?.id;
+					if (!resolved_id) {
+						throw new Error(`Could not locate ${original_url} for <enhanced:video>`);
+					}
+					const filepath = resolved_id.includes('?')
+						? resolved_id.slice(0, resolved_id.indexOf('?'))
+						: resolved_id;
+					const video = await process_video(filepath, plugin_context, vite_config);
+					s.update(node.start, node.end, video_to_video(content, node, video));
+				}
+
+				/**
+				 * @type {Array<ReturnType<typeof update_element | typeof update_video_element>>}
 				 */
 				const pending_ast_updates = [];
 
 				walk(/** @type {import('svelte/compiler').AST.TemplateNode} */ (ast), null, {
 					RegularElement(node, { next }) {
 						if ('name' in node && node.name === 'enhanced:img') {
-							// Compare node tag match
 							const src = get_attr_value(node, 'src');
-
 							if (!src || typeof src === 'boolean') return;
-
 							pending_ast_updates.push(update_element(node, src));
+							return;
+						}
 
+						if ('name' in node && node.name === 'enhanced:video') {
+							const src = get_attr_value(node, 'src');
+							if (!src || typeof src === 'boolean') return;
+							pending_ast_updates.push(update_video_element(node, src));
 							return;
 						}
 
@@ -172,7 +316,9 @@ export function image_plugin(imagetools_plugin) {
 
 				if (ast.css) {
 					const css = content.substring(ast.css.start, ast.css.end);
-					const modified = css.replaceAll('enhanced\\:img', 'img');
+					const modified = css
+						.replaceAll('enhanced\\:img', 'img')
+						.replaceAll('enhanced\\:video', 'video');
 					if (modified !== css) {
 						s.update(ast.css.start, ast.css.end, modified);
 					}
@@ -194,18 +340,70 @@ export function image_plugin(imagetools_plugin) {
  * @param {import('vite').Plugin} imagetools_plugin
  * @returns {Promise<import('vite-imagetools').Picture>}
  */
-async function process_id(resolved_id, plugin_context, imagetools_plugin) {
+async function process_id(resolved_id, plugin_context, imagetools_plugin, vite_config) {
 	if (!imagetools_plugin.load) {
 		throw new Error('Invalid instance of vite-imagetools. Could not find load method.');
 	}
 	const hook = imagetools_plugin.load;
 	const handler = typeof hook === 'object' ? hook.handler : hook;
-	const module_info = await handler.call(plugin_context, resolved_id);
+
+	// Cache keyed by file content + query params (build only).
+	const q = resolved_id.indexOf('?');
+	const filepath = q >= 0 ? resolved_id.slice(0, q) : resolved_id;
+	const query = q >= 0 ? resolved_id.slice(q + 1) : '';
+
+	if (vite_config?.command === 'serve') {
+		const module_info = await handler.call(plugin_context, resolved_id);
+		if (!module_info) throw new Error(`Could not load ${resolved_id}`);
+		const code = typeof module_info === 'string' ? module_info : module_info.code;
+		return parse_object(code.replace('export default', '').replace(/;$/, '').trim());
+	}
+
+	const cached = read_img_cache(filepath, query);
+	if (cached) {
+		/** @type {Record<string, string>} */
+		const new_refs = {};
+		for (const asset of cached.assets) {
+			new_refs[asset.key] = plugin_context.emitFile({ type: 'asset', name: asset.name, source: asset.source });
+		}
+		const code = cached.template.replace(/\$REF\[([^\]]+)\]/g, (_, k) => `__VITE_ASSET__${new_refs[k]}__`);
+		return parse_object(code.replace('export default', '').replace(/;$/, '').trim());
+	}
+
+	// Cache miss — intercept emitFile to capture assets for caching.
+	/** @type {{ key: string, name: string, source: Buffer }[]} */
+	const emitted = [];
+	const original_emit = plugin_context.emitFile.bind(plugin_context);
+	const proxy_ctx = new Proxy(plugin_context, {
+		get(target, prop) {
+			if (prop === 'emitFile') {
+				return (/** @type {any} */ opts) => {
+					const ref = original_emit(opts);
+					if (opts.type === 'asset' && opts.source) {
+						emitted.push({ key: ref, name: opts.name ?? '', source: Buffer.from(opts.source) });
+					}
+					return ref;
+				};
+			}
+			return /** @type {any} */ (target)[prop];
+		}
+	});
+
+	const module_info = await handler.call(proxy_ctx, resolved_id);
 	if (!module_info) {
 		throw new Error(`Could not load ${resolved_id}`);
 	}
-	const code = typeof module_info === 'string' ? module_info : module_info.code;
-	return parse_object(code.replace('export default', '').replace(/;$/, '').trim());
+	const raw_code = typeof module_info === 'string' ? module_info : module_info.code;
+
+	if (emitted.length > 0) {
+		let template = raw_code;
+		for (const asset of emitted) {
+			template = template.replaceAll(`__VITE_ASSET__${asset.key}__`, `$REF[${asset.key}]`);
+		}
+		write_img_cache(filepath, query, { assets: emitted, template });
+	}
+
+	return parse_object(raw_code.replace('export default', '').replace(/;$/, '').trim());
 }
 
 /**
@@ -345,6 +543,59 @@ function to_value(src) {
 	return src.startsWith('__VITE_ASSET__') ? `{"${src}"}` : `"${src}"`;
 }
 
+/**
+ * Generates a static <video> element from a processed video object.
+ * Emits WebM first (better compression) then MP4 (Safari fallback).
+ * @param {string} content
+ * @param {import('svelte/compiler').AST.RegularElement} node
+ * @param {{ sources: Record<string,string>, video: { src: string, w: number, h: number } }} video
+ */
+function video_to_video(content, node, video) {
+	const skip = new Set(['src', 'width', 'height']);
+	const has = (/** @type {string} */ name) =>
+		node.attributes.some((a) => 'name' in a && /** @type {any} */ (a).name === name);
+	const pass_through = node.attributes
+		.filter((a) => 'name' in a && !skip.has(/** @type {any} */ (a).name))
+		.map((a) => content.substring(a.start, a.end))
+		.join(' ');
+	const extra    = pass_through ? ' ' + pass_through : '';
+	const preload  = has('preload')  ? '' : ' preload="metadata"';
+	const controls = has('controls') ? '' : ' controls';
+	let res = `<video width=${video.video.w} height=${video.video.h}${preload}${controls}${extra}>`;
+	for (const [format, src] of Object.entries(video.sources)) {
+		res += `<source src=${to_value(src)} type="video/${format}" />`;
+	}
+	return res + '</video>';
+}
+
+/**
+ * Generates a dynamic {#if} block for <enhanced:video src={expr} />.
+ * @param {string} content
+ * @param {import('svelte/compiler').AST.RegularElement} node
+ * @param {string} src_var_name
+ */
+function dynamic_video_to_video(content, node, src_var_name) {
+	const skip = new Set(['src', 'width', 'height']);
+	const has = (/** @type {string} */ name) =>
+		node.attributes.some((a) => 'name' in a && /** @type {any} */ (a).name === name);
+	const pass_through = node.attributes
+		.filter((a) => 'name' in a && !skip.has(/** @type {any} */ (a).name))
+		.map((a) => content.substring(a.start, a.end))
+		.join(' ');
+	const extra    = pass_through ? ' ' + pass_through : '';
+	const preload  = has('preload')  ? '' : ' preload="metadata"';
+	const controls = has('controls') ? '' : ' controls';
+	return `{#if typeof ${src_var_name} === 'string'}
+	<video src={${src_var_name}}${preload}${controls}${extra}></video>
+{:else}
+	<video width={${src_var_name}.video.w} height={${src_var_name}.video.h}${preload}${controls}${extra}>
+		{#each Object.entries(${src_var_name}.sources) as [format, src]}
+			<source {src} type={'video/' + format} />
+		{/each}
+	</video>
+{/if}`;
+}
+
 /**
  * For images like `<img src={manually_imported} />`
  * @param {string} content
