From 8acc692b087df915238ce29026fb4fc61fa98836 Mon Sep 17 00:00:00 2001 From: Doloro1978 Date: Fri, 5 Jun 2026 12:20:45 +0100 Subject: [PATCH] added scub.mp4 and more patches --- Dockerfile | 1 + patches/@sveltejs__enhanced-img@0.10.4.patch | 345 ++++++++++++++++++- pnpm-lock.yaml | 6 +- src/routes/scublings/+page.svelte | 44 +++ src/routes/scublings/scub.mp4 | Bin 0 -> 3943437 bytes src/routes/store/snacks.scss | 6 + 6 files changed, 388 insertions(+), 14 deletions(-) create mode 100644 src/routes/scublings/+page.svelte create mode 100644 src/routes/scublings/scub.mp4 diff --git a/Dockerfile b/Dockerfile index 61d7a4a..c884607 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,7 @@ ENV CI=true RUN pnpm runtime set node 22 -g RUN apt update RUN apt install git -y +RUN apt install ffmpeg -y WORKDIR /app COPY pnpm-lock.yaml /app diff --git a/patches/@sveltejs__enhanced-img@0.10.4.patch b/patches/@sveltejs__enhanced-img@0.10.4.patch index 664d2dd..401368a 100644 --- a/patches/@sveltejs__enhanced-img@0.10.4.patch +++ b/patches/@sveltejs__enhanced-img@0.10.4.patch @@ -1,9 +1,9 @@ diff --git a/src/animated-cache.js b/src/animated-cache.js new file mode 100644 -index 0000000000000000000000000000000000000000..7826a8d85a2ad9d6ae1a780d0fdbceb2ebd8cb52 +index 0000000000000000000000000000000000000000..3ab3e8466866d99c20959638523c00cd6d4ca819 --- /dev/null +++ b/src/animated-cache.js -@@ -0,0 +1,205 @@ +@@ -0,0 +1,240 @@ +import { createHash } from 'node:crypto'; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import path from 'node:path'; @@ -54,6 +54,41 @@ index 0000000000000000000000000000000000000000..7826a8d85a2ad9d6ae1a780d0fdbceb2 + } 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]). @@ -210,16 +245,17 @@ index 0000000000000000000000000000000000000000..7826a8d85a2ad9d6ae1a780d0fdbceb2 + return plugin; +} diff --git a/src/index.js b/src/index.js -index 013b9405436dd61d02d947a49c56ae1d2cad9f57..fc314fa1a2ed9f2d4776cc0f201ef2ec35f1c091 100644 +index 013b9405436dd61d02d947a49c56ae1d2cad9f57..2f9205521c0bd01646f00cd051f158360322d4de 100644 --- a/src/index.js +++ b/src/index.js -@@ -1,17 +1,94 @@ +@@ -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, with_img_cache } from './animated-cache.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[]} @@ -258,6 +294,42 @@ index 013b9405436dd61d02d947a49c56ae1d2cad9f57..fc314fa1a2ed9f2d4776cc0f201ef2ec + + const filepath = q >= 0 ? id.slice(0, q) : id; + const ext = path.extname(filepath).toLowerCase(); ++ ++ // ── MP4 → WebM + MP4 (for ) ────────────────────── ++ 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(); @@ -310,24 +382,94 @@ index 013b9405436dd61d02d947a49c56ae1d2cad9f57..fc314fa1a2ed9f2d4776cc0f201ef2ec /** * @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} ++ */ ++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} ++ */ ++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..3a52193c249edb0ccf60e2733fb360c21cce73b7 100644 +index c8ccb26b3f17cb69eb1e725bab1cde02c88c36cf..08392ceb8d2788917c1557ce81161982d97ea60e 100644 --- a/src/vite-plugin.js +++ b/src/vite-plugin.js -@@ -1,6 +1,7 @@ +@@ -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_img_cache, write_img_cache } from './animated-cache.js'; ++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 +9,57 @@ import { walk } from 'zimmerframe'; +@@ -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 elements). ++const VIDEO_FORMATS = new Set(['webm', 'mp4']); + +/** + * Process an animated GIF/WebP into optimised animated WebP assets. @@ -376,11 +518,58 @@ index c8ccb26b3f17cb69eb1e725bab1cde02c88c36cf..3a52193c249edb0ccf60e2733fb360c2 + 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, 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. -@@ -110,8 +162,21 @@ export function image_plugin(imagetools_plugin) { +@@ -38,7 +131,7 @@ export function image_plugin(imagetools_plugin) { + transform: { + order: 'pre', // puts it before vite-plugin-svelte:compile + filter: { +- code: />} ++ * Handles a static or dynamic 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 `); ++ } ++ 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>} + */ + 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} */ @@ -478,3 +741,63 @@ index c8ccb26b3f17cb69eb1e725bab1cde02c88c36cf..3a52193c249edb0ccf60e2733fb360c2 } /** +@@ -345,6 +543,59 @@ function to_value(src) { + return src.startsWith('__VITE_ASSET__') ? `{"${src}"}` : `"${src}"`; + } + ++/** ++ * Generates a static