481 lines
17 KiB
Diff
481 lines
17 KiB
Diff
diff --git a/src/animated-cache.js b/src/animated-cache.js
|
|
new file mode 100644
|
|
index 0000000000000000000000000000000000000000..7826a8d85a2ad9d6ae1a780d0fdbceb2ebd8cb52
|
|
--- /dev/null
|
|
+++ b/src/animated-cache.js
|
|
@@ -0,0 +1,205 @@
|
|
+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 */ }
|
|
+}
|
|
+
|
|
+// ─── 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..fc314fa1a2ed9f2d4776cc0f201ef2ec35f1c091 100644
|
|
--- a/src/index.js
|
|
+++ b/src/index.js
|
|
@@ -1,17 +1,94 @@
|
|
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, with_img_cache } from './animated-cache.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();
|
|
+ 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/vite-plugin.js b/src/vite-plugin.js
|
|
index c8ccb26b3f17cb69eb1e725bab1cde02c88c36cf..3a52193c249edb0ccf60e2733fb360c21cce73b7 100644
|
|
--- a/src/vite-plugin.js
|
|
+++ b/src/vite-plugin.js
|
|
@@ -1,6 +1,7 @@
|
|
/** @import { AST } from 'svelte/compiler' */
|
|
import { existsSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
+import { read_animated_cache, write_animated_cache, read_img_cache, write_img_cache } from './animated-cache.js';
|
|
import MagicString from 'magic-string';
|
|
import sharp from 'sharp';
|
|
import { parse } from 'svelte-parse-markup';
|
|
@@ -8,6 +9,57 @@ 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;
|
|
+
|
|
+/**
|
|
+ * 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 }
|
|
+ });
|
|
+}
|
|
|
|
/**
|
|
* Creates the Svelte image plugin.
|
|
@@ -110,8 +162,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();
|
|
@@ -194,18 +259,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());
|
|
}
|
|
|
|
/**
|