diff --git a/docs/DATADOG.md b/docs/DATADOG.md
index 99c552e63..a9bc269e4 100644
--- a/docs/DATADOG.md
+++ b/docs/DATADOG.md
@@ -158,7 +158,7 @@ audio_only,
color_adjustments, sharpen, denoise, stabilization, fill_framing,
custom_size, crop. Interactive features: `history` (undo, redo), `project_file`
(save, open), `preview_tool` (compare, grid, framing_guide, save_frame),
-`safe_area`, `export_profile`, `trim_timeline` (seek, drag_handle), `crop_tool` (aspect, drag, reset), `batch_export` (start, done, done_with_failures, cancel, save_folder), `text_tool` (import_captions, set_timing, caption_box), `join_clips` (add, reorder, start, done, cancel, undo), `soundtrack` (add, remove, replace, mix, loop). Only these names are ever sent; never values,
+`safe_area`, `export_profile`, `trim_timeline` (seek, drag_handle), `crop_tool` (aspect, drag, reset), `batch_export` (start, done, done_with_failures, cancel, save_folder), `text_tool` (import_captions, set_timing, caption_box), `join_clips` (add, reorder, start, done, cancel, undo), `soundtrack` (add, remove, replace, mix, loop), `pwa` (installed, offline_ready, update). Only these names are ever sent; never values,
names or text.
**Synthetic check.** A Datadog Synthetic browser test opens production every
diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md
index 0e62b3d90..aea530ab8 100644
--- a/docs/DEPLOYMENT.md
+++ b/docs/DEPLOYMENT.md
@@ -64,6 +64,26 @@ npx netlify-cli deploy --dir out # draft URL
npx netlify-cli deploy --dir out --prod # production (maintainers only)
```
+## Offline support (service worker)
+
+`public/sw.js` lets the editor open and export without a connection once it
+has been used online. `bun run build` ends with
+`scripts/build-sw-manifest.mjs`, which writes the list of files to
+precache, and a version hash of their contents, into `out/sw.js`. Every
+deploy that changes the site therefore changes `sw.js`, which is how
+browsers notice the update. Visitors get a "Reload" notice; the page
+never reloads by itself.
+
+- Serve `/sw.js` with `Cache-Control: no-cache`, so browsers always check
+ it for updates. `netlify.toml`, `public/_headers` and `vercel.json` all
+ do this.
+- Cached pages keep the headers they were served with, COOP/COEP included,
+ so offline behaves like online. Fix isolation on the host, not in the
+ worker.
+- The FFmpeg cores (jsDelivr) are cached the first time they download and
+ still pass their SRI checks. User videos and exports are never cached.
+- Development builds (`bun run dev`) don't register the worker.
+
## Other static hosts
| Host | Headers | Notes |
diff --git a/docs/USAGE.md b/docs/USAGE.md
index 2302faf03..d74a3f7b2 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -159,6 +159,18 @@ field.
Reframe works it out when the file loads; this can take a moment for long
recordings.
+## Offline and installing
+
+After you've opened a video once with a connection, Reframe also works
+offline: the editor and its video engine are saved in your browser, and
+the footer says **Works offline** once both are. With the network off you
+can open the site, load videos and export as usual. Browsers can install it
+as an app (the install button in the address bar, or **Add to Home
+Screen** on phones). When a new version is out, a small notice offers
+**Reload**; nothing reloads by itself, so an export is never interrupted.
+The saved copy takes about 35 MB, and the browser may clear it when space
+runs low, in which case it's saved again next time you're online.
+
## Privacy
Your videos never leave your device. Reframe collects anonymous performance
diff --git a/netlify.toml b/netlify.toml
index 9018a7dc4..582dc43cb 100644
--- a/netlify.toml
+++ b/netlify.toml
@@ -54,3 +54,11 @@
Strict-Transport-Security = "max-age=31536000; includeSubDomains"
Cross-Origin-Opener-Policy = "same-origin"
Cross-Origin-Embedder-Policy = "require-corp"
+
+# The service worker (public/sw.js) must always be re-checked, or browsers
+# keep running an old one after a deploy. Its content changes with every
+# build (scripts/build-sw-manifest.mjs), which is how updates are noticed.
+[[headers]]
+ for = "/sw.js"
+ [headers.values]
+ Cache-Control = "no-cache"
diff --git a/package.json b/package.json
index 723be2926..9af43be3f 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev --webpack",
- "build": "next build --webpack",
+ "build": "next build --webpack && node scripts/build-sw-manifest.mjs out",
"start": "next start",
"lint": "eslint .",
"test": "vitest",
diff --git a/public/_headers b/public/_headers
index 0462c40a6..5a95c6bf0 100644
--- a/public/_headers
+++ b/public/_headers
@@ -21,3 +21,7 @@
Strict-Transport-Security: max-age=31536000; includeSubDomains
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
+
+# The service worker must always be re-checked (see netlify.toml).
+/sw.js
+ Cache-Control: no-cache
diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png
new file mode 100644
index 000000000..dd446e205
Binary files /dev/null and b/public/icons/apple-touch-icon.png differ
diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png
new file mode 100644
index 000000000..c21219b98
Binary files /dev/null and b/public/icons/icon-192.png differ
diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png
new file mode 100644
index 000000000..e40b98ed5
Binary files /dev/null and b/public/icons/icon-512.png differ
diff --git a/public/icons/maskable-512.png b/public/icons/maskable-512.png
new file mode 100644
index 000000000..9fee289c4
Binary files /dev/null and b/public/icons/maskable-512.png differ
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
new file mode 100644
index 000000000..8e8ee8856
--- /dev/null
+++ b/public/manifest.webmanifest
@@ -0,0 +1,18 @@
+{
+ "name": "Reframe — video editor",
+ "short_name": "Reframe",
+ "description": "Resize, trim, crop and export videos on your device. Works offline once opened.",
+ "id": "/",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "background_color": "#f5f5f3",
+ "theme_color": "#12100e",
+ "categories": ["photo", "productivity", "utilities"],
+ "icons": [
+ { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
+ { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
+ { "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
+ { "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }
+ ]
+}
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 000000000..41e3127a3
--- /dev/null
+++ b/public/sw.js
@@ -0,0 +1,111 @@
+/* Reframe service worker: opens the editor offline.
+ *
+ * - The app's own files are precached on install from PRECACHE, which
+ * scripts/build-sw-manifest.mjs writes into the built copy of this file.
+ * Pages are fetched from the network first (so a deploy shows at once) and
+ * from the cache when offline; other files come from the cache first.
+ * - The video engine (FFmpeg core files on jsDelivr) is kept in its own
+ * cache the first time it downloads, and served from there after. They are
+ * CORS responses, so the browser still checks their SRI hashes.
+ * - A cached page keeps the headers it was served with, including the
+ * cross-origin isolation ones (COOP/COEP), so the multi-threaded engine
+ * works offline wherever it works online.
+ * - User videos and exports are never cached: they are blob: URLs, which
+ * never reach a service worker.
+ */
+const PRECACHE = null; // replaced at build time
+
+const APP_CACHE_PREFIX = "reframe-app-";
+const ENGINE_CACHE = "reframe-engine-v1";
+const ENGINE_URLS = ["https://cdn.jsdelivr.net/npm/@ffmpeg/core@", "https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@"];
+/** How long a page request waits for the network before using the cache. */
+const PAGE_TIMEOUT_MS = 4000;
+
+const appCache = PRECACHE ? `${APP_CACHE_PREFIX}${PRECACHE.version}` : null;
+
+/** What to do with a request: "engine", "page", "asset" or "network" (leave it alone). */
+function route(url, request) {
+ if (request.method !== "GET") return "network";
+ if (ENGINE_URLS.some((prefix) => url.href.startsWith(prefix))) return "engine";
+ if (url.origin !== self.location.origin || !appCache) return "network";
+ if (request.mode === "navigate") return "page";
+ if (url.pathname === "/sw.js") return "network";
+ return "asset";
+}
+
+/** The precached page for a path: "/contact/" and "/contact.html" are "/contact"; unknown paths get "/". */
+function pageKey(pathname) {
+ const path = pathname.replace(/\.html$/, "").replace(/\/+$/, "") || "/";
+ const name = path === "/index" ? "/" : path;
+ return PRECACHE.pages.includes(name) ? name : "/";
+}
+
+async function page(request, url) {
+ const cached = () => caches.open(appCache).then((cache) => cache.match(pageKey(url.pathname)));
+ try {
+ const network = await Promise.race([
+ fetch(request),
+ new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), PAGE_TIMEOUT_MS)),
+ ]);
+ return network;
+ } catch (error) {
+ const hit = await cached();
+ if (hit) return hit;
+ throw error;
+ }
+}
+
+async function asset(request) {
+ const cache = await caches.open(appCache);
+ // Next's page data requests carry a ?_rsc= query; the file is the same.
+ const hit = await cache.match(request, { ignoreSearch: true });
+ return hit ?? fetch(request);
+}
+
+async function engine(request) {
+ const cache = await caches.open(ENGINE_CACHE);
+ const hit = await cache.match(request.url);
+ if (hit) return hit;
+ const response = await fetch(request);
+ if (response.ok && response.type === "cors") {
+ await cache.put(request.url, response.clone());
+ // The page checks whether every file it needs is in now.
+ const clients = await self.clients.matchAll({ includeUncontrolled: true });
+ clients.forEach((client) => client.postMessage({ type: "engine-cached" }));
+ }
+ return response;
+}
+
+self.addEventListener("install", (event) => {
+ if (!appCache) return;
+ event.waitUntil(
+ caches
+ .open(appCache)
+ .then((cache) => cache.addAll([...PRECACHE.pages, ...PRECACHE.assets].map((url) => new Request(url, { cache: "reload" }))))
+ );
+});
+
+self.addEventListener("activate", (event) => {
+ event.waitUntil(
+ caches
+ .keys()
+ .then((keys) => Promise.all(keys.filter((key) => key.startsWith(APP_CACHE_PREFIX) && key !== appCache).map((key) => caches.delete(key))))
+ .then(() => self.clients.claim())
+ );
+});
+
+// The page asks for this when the user chooses to load an update.
+self.addEventListener("message", (event) => {
+ if (event.data && event.data.type === "skip-waiting") self.skipWaiting();
+});
+
+self.addEventListener("fetch", (event) => {
+ const url = new URL(event.request.url);
+ const kind = route(url, event.request);
+ if (kind === "engine") event.respondWith(engine(event.request));
+ else if (kind === "page") event.respondWith(page(event.request, url));
+ else if (kind === "asset") event.respondWith(asset(event.request));
+});
+
+// For tests (test code reads these; the browser ignores them).
+self.__reframeSw = { route, pageKey, ENGINE_CACHE, APP_CACHE_PREFIX };
diff --git a/scripts/build-sw-manifest.mjs b/scripts/build-sw-manifest.mjs
new file mode 100644
index 000000000..025de3cf9
--- /dev/null
+++ b/scripts/build-sw-manifest.mjs
@@ -0,0 +1,76 @@
+#!/usr/bin/env node
+// Lists the static export's files for the service worker (public/sw.js) to
+// precache, so the editor opens offline. Runs after `next build`:
+//
+// node scripts/build-sw-manifest.mjs [out]
+//
+// Writes { version, pages, assets } into out/sw.js in place of its
+// placeholder. `version` is a hash of every listed file's contents, so a
+// deploy that changes anything changes sw.js too: browsers then install the
+// new worker, which precaches into a new cache and removes the old one.
+// Source maps, the headers file and sw.js itself are left out.
+import { createHash } from "node:crypto";
+import { readdir, readFile, writeFile } from "node:fs/promises";
+import { join, relative, sep } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const SKIP = [/\.map$/, /^_headers$/, /^sw\.js$/, /(^|\/)\.DS_Store$/];
+/** The line in public/sw.js that the build replaces. */
+export const PLACEHOLDER = "const PRECACHE = null; // replaced at build time";
+/** Error pages aren't opened directly, so they aren't precached as pages. */
+const SKIP_PAGES = new Set(["404", "_not-found"]);
+
+/**
+ * Splits the export's files (paths relative to out/, with "/") into page
+ * URLs ("index.html" → "/", "contact.html" → "/contact") and asset URLs.
+ */
+export function precacheList(files) {
+ const pages = [];
+ const assets = [];
+ for (const file of [...files].sort()) {
+ if (SKIP.some((re) => re.test(file))) continue;
+ if (file.endsWith(".html") && !file.includes("/")) {
+ const name = file.slice(0, -".html".length);
+ if (SKIP_PAGES.has(name)) continue;
+ pages.push(name === "index" ? "/" : `/${name}`);
+ continue;
+ }
+ if (file.endsWith(".html")) continue;
+ assets.push(`/${file}`);
+ }
+ return { pages, assets };
+}
+
+async function walk(dir) {
+ const entries = await readdir(dir, { withFileTypes: true });
+ const files = await Promise.all(
+ entries.map((entry) => (entry.isDirectory() ? walk(join(dir, entry.name)) : [join(dir, entry.name)]))
+ );
+ return files.flat();
+}
+
+export async function buildManifest(root) {
+ const files = (await walk(root)).map((path) => relative(root, path).split(sep).join("/"));
+ const list = precacheList(files);
+ const hash = createHash("sha256");
+ const included = new Set([...list.assets.map((a) => a.slice(1)), ...files.filter((f) => f.endsWith(".html"))]);
+ for (const file of [...included].sort()) {
+ hash.update(file);
+ hash.update(await readFile(join(root, file)));
+ }
+ return { version: hash.digest("hex").slice(0, 16), ...list };
+}
+
+/** sw.js with the precache list in place of its placeholder. */
+export function injectManifest(source, manifest) {
+ if (!source.includes(PLACEHOLDER)) throw new Error("sw.js has no precache placeholder");
+ return source.replace(PLACEHOLDER, `const PRECACHE = ${JSON.stringify(manifest)};`);
+}
+
+if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
+ const root = process.argv[2] ?? "out";
+ const manifest = await buildManifest(root);
+ const swPath = join(root, "sw.js");
+ await writeFile(swPath, injectManifest(await readFile(swPath, "utf8"), manifest));
+ console.log(`sw.js: ${manifest.pages.length} pages, ${manifest.assets.length} assets, version ${manifest.version}`);
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index d15a5478b..a87bf5df1 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -64,7 +64,9 @@ export const metadata: Metadata = {
icons: {
icon: [{ url: "/favicon.svg", type: "image/svg+xml" }],
shortcut: "/favicon.svg",
+ apple: "/icons/apple-touch-icon.png",
},
+ manifest: "/manifest.webmanifest",
};
export default function RootLayout({
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
index a57a9c5a5..936b865c7 100644
--- a/src/components/Footer.tsx
+++ b/src/components/Footer.tsx
@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
+import OfflineStatus from "./OfflineStatus";
import {
Github,
Twitter,
@@ -51,6 +52,7 @@ export default function Footer() {
))}
+
{/* Navigation */}
diff --git a/src/components/OfflineStatus.tsx b/src/components/OfflineStatus.tsx
new file mode 100644
index 000000000..506360098
--- /dev/null
+++ b/src/components/OfflineStatus.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import { CloudOff, RefreshCw, X } from "lucide-react";
+import { useOfflineSupport, type OfflineState } from "@/hooks/useOfflineSupport";
+
+const TEXT: Record, string> = {
+ preparing: "Saving the editor for offline use…",
+ "needs-engine": "Open a video once while online to use the editor offline.",
+ ready: "Works offline",
+};
+
+/**
+ * Whether the editor opens offline (in the footer), and a small notice when
+ * a new version is ready to load.
+ */
+export default function OfflineStatus() {
+ const { state, updateReady, applyUpdate, dismissUpdate } = useOfflineSupport();
+ return (
+ <>
+ {state !== "unavailable" && (
+
+
+ {TEXT[state]}
+
+ )}
+ {updateReady && (
+
+ A new version of Reframe is ready.
+
+
+
+ )}
+ >
+ );
+}
diff --git a/src/hooks/useOfflineSupport.ts b/src/hooks/useOfflineSupport.ts
new file mode 100644
index 000000000..896629c35
--- /dev/null
+++ b/src/hooks/useOfflineSupport.ts
@@ -0,0 +1,114 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { trackFeature } from "@/lib/featureTelemetry";
+import { engineFileUrls } from "@/lib/engineUrls";
+
+/** Must match ENGINE_CACHE in public/sw.js. */
+const ENGINE_CACHE = "reframe-engine-v1";
+const REPORTED_KEY = "reframe:offline-ready-reported";
+
+export type OfflineState =
+ /** No service worker (unsupported, or a development build). */
+ | "unavailable"
+ /** The app is being saved for offline use. */
+ | "preparing"
+ /** The app is saved, but the video engine hasn't been downloaded yet. */
+ | "needs-engine"
+ /** The editor and the engine both open offline. */
+ | "ready";
+
+async function engineCached(): Promise {
+ if (typeof caches === "undefined") return false;
+ try {
+ const cache = await caches.open(ENGINE_CACHE);
+ const cached = new Set((await cache.keys()).map((request) => request.url));
+ return engineFileUrls(self.crossOriginIsolated === true).every((url) => cached.has(url));
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Registers the service worker (production builds only) and reports whether
+ * the editor works offline yet. When a new version has been installed,
+ * `updateReady` is true and `applyUpdate` switches to it and reloads; it
+ * never reloads on its own, so it can't interrupt an export.
+ */
+export function useOfflineSupport() {
+ const [state, setState] = useState("unavailable");
+ const [waiting, setWaiting] = useState(null);
+
+ useEffect(() => {
+ if (process.env.NODE_ENV !== "production" || typeof navigator === "undefined" || !("serviceWorker" in navigator)) return;
+ let cancelled = false;
+ const sw = navigator.serviceWorker;
+
+ const refresh = async () => {
+ if (cancelled) return;
+ if (!sw.controller) {
+ setState("preparing");
+ return;
+ }
+ const ready = await engineCached();
+ if (cancelled) return;
+ setState(ready ? "ready" : "needs-engine");
+ if (ready) {
+ try {
+ if (!localStorage.getItem(REPORTED_KEY)) {
+ localStorage.setItem(REPORTED_KEY, "1");
+ trackFeature("pwa", "offline_ready");
+ }
+ } catch {
+ // storage blocked: report nothing
+ }
+ }
+ };
+
+ const watch = (registration: ServiceWorkerRegistration) => {
+ // A worker waiting while another controls the page is an update.
+ if (registration.waiting && sw.controller) setWaiting(registration.waiting);
+ registration.addEventListener("updatefound", () => {
+ const installing = registration.installing;
+ installing?.addEventListener("statechange", () => {
+ if (installing.state === "installed" && sw.controller) setWaiting(installing);
+ if (installing.state === "activated") void refresh();
+ });
+ });
+ };
+
+ const onMessage = (event: MessageEvent) => {
+ if (event.data?.type === "engine-cached") void refresh();
+ };
+ const onControllerChange = () => void refresh();
+ const onInstalled = () => trackFeature("pwa", "installed");
+
+ sw.addEventListener("message", onMessage);
+ sw.addEventListener("controllerchange", onControllerChange);
+ window.addEventListener("appinstalled", onInstalled);
+ sw.register("/sw.js", { scope: "/" })
+ .then((registration) => {
+ if (!cancelled) watch(registration);
+ return refresh();
+ })
+ .catch(() => {
+ if (!cancelled) setState("unavailable");
+ });
+
+ return () => {
+ cancelled = true;
+ sw.removeEventListener("message", onMessage);
+ sw.removeEventListener("controllerchange", onControllerChange);
+ window.removeEventListener("appinstalled", onInstalled);
+ };
+ }, []);
+
+ const applyUpdate = useCallback(() => {
+ if (!waiting) return;
+ trackFeature("pwa", "update");
+ navigator.serviceWorker.addEventListener("controllerchange", () => window.location.reload(), { once: true });
+ waiting.postMessage({ type: "skip-waiting" });
+ }, [waiting]);
+
+ return { state, updateReady: waiting !== null, applyUpdate, dismissUpdate: () => setWaiting(null) };
+}
diff --git a/src/lib/engineUrls.ts b/src/lib/engineUrls.ts
new file mode 100644
index 000000000..67e4f1f69
--- /dev/null
+++ b/src/lib/engineUrls.ts
@@ -0,0 +1,25 @@
+/**
+ * Where the FFmpeg cores are downloaded from. Shared by the engine worker
+ * (src/lib/ffmpeg.worker.ts, which checks each file's SRI hash) and the
+ * offline check (src/hooks/useOfflineSupport.ts, which looks for these files
+ * in the service worker's cache).
+ */
+export const CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd";
+// MT core aligned to the same version and distribution format as CORE_BASE_URL.
+// Previously used @0.12.6/dist/esm which caused two problems:
+// 1. The version mismatch meant SRI hashes computed for @0.12.10 were applied
+// to different file contents, causing all MT fetches to fail with an integrity
+// error instead of falling back gracefully.
+// 2. The ESM distribution is not consumed the same way as UMD by ffmpeg.js and
+// produced subtle runtime failures in crossOriginIsolated contexts.
+export const MT_CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@0.12.10/dist/umd";
+
+/**
+ * The engine files a page loads: the multi-threaded core when cross-origin
+ * isolated, which also needs its worker script, otherwise the
+ * single-threaded one.
+ */
+export function engineFileUrls(isolated: boolean): string[] {
+ const base = isolated ? MT_CORE_BASE_URL : CORE_BASE_URL;
+ return ["ffmpeg-core.js", "ffmpeg-core.wasm", ...(isolated ? ["ffmpeg-core.worker.js"] : [])].map((name) => `${base}/${name}`);
+}
diff --git a/src/lib/featureTelemetry.ts b/src/lib/featureTelemetry.ts
index 5e3602139..e29d41e6f 100644
--- a/src/lib/featureTelemetry.ts
+++ b/src/lib/featureTelemetry.ts
@@ -22,6 +22,7 @@ export const FEATURE_ACTIONS = {
text_tool: ["import_captions", "set_timing", "caption_box"],
join_clips: ["add", "reorder", "start", "done", "cancel", "undo"],
soundtrack: ["add", "remove", "replace", "mix", "loop"],
+ pwa: ["installed", "offline_ready", "update"],
// One event per editing tool an export used (see toolsUsed).
trim: ["exported"],
rotate: ["exported"],
diff --git a/src/lib/ffmpeg.worker.ts b/src/lib/ffmpeg.worker.ts
index 49f0e5387..84e6bac1e 100644
--- a/src/lib/ffmpeg.worker.ts
+++ b/src/lib/ffmpeg.worker.ts
@@ -16,15 +16,7 @@ import {
} from "./ffmpegArgs";
import { frameRateFromLog } from "./joinClips";
-const CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd";
-// MT core aligned to the same version and distribution format as CORE_BASE_URL.
-// Previously used @0.12.6/dist/esm which caused two problems:
-// 1. The version mismatch meant SRI hashes computed for @0.12.10 were applied
-// to different file contents, causing all MT fetches to fail with an integrity
-// error instead of falling back gracefully.
-// 2. The ESM distribution is not consumed the same way as UMD by ffmpeg.js and
-// produced subtle runtime failures in crossOriginIsolated contexts.
-const MT_CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@0.12.10/dist/umd";
+import { CORE_BASE_URL, MT_CORE_BASE_URL } from "./engineUrls";
// Keys are full URLs so hashes for the non-MT and MT packages never collide.
// Every engine file is integrity-checked, so a tampered CDN response fails to
diff --git a/src/lib/tests/deployConfig.test.ts b/src/lib/tests/deployConfig.test.ts
index cfed8dedf..c964d13c6 100644
--- a/src/lib/tests/deployConfig.test.ts
+++ b/src/lib/tests/deployConfig.test.ts
@@ -67,9 +67,11 @@ describe("cross-origin isolation compatibility", () => {
// Under COEP require-corp, cross-origin loads need CORS (or CORP).
// fetch() defaults to mode "cors"; jsdelivr answers with
// Access-Control-Allow-Origin: *. The cores are then used as blob: URLs,
- // which are same-origin.
- expect(worker).toContain("https://cdn.jsdelivr.net/npm/@ffmpeg/core@");
- expect(worker).toContain("https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@");
+ // which are same-origin. The URLs live in engineUrls.ts.
+ const urls = read("src/lib/engineUrls.ts");
+ expect(worker).toContain('from "./engineUrls"');
+ expect(urls).toContain("https://cdn.jsdelivr.net/npm/@ffmpeg/core@");
+ expect(urls).toContain("https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@");
expect(worker).not.toMatch(/mode:\s*["']no-cors["']/);
});
diff --git a/src/lib/tests/serviceWorker.test.ts b/src/lib/tests/serviceWorker.test.ts
new file mode 100644
index 000000000..11ecf2d31
--- /dev/null
+++ b/src/lib/tests/serviceWorker.test.ts
@@ -0,0 +1,91 @@
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { precacheList, injectManifest, PLACEHOLDER } from "../../../scripts/build-sw-manifest.mjs";
+import { engineFileUrls } from "../engineUrls";
+
+const SOURCE = readFileSync(join(__dirname, "../../../public/sw.js"), "utf8");
+
+type Route = (url: URL, request: { method: string; mode: string }) => string;
+
+/** Runs sw.js against a stand-in `self`, as the browser would, and returns its test hooks. */
+function loadWorker(precache: unknown) {
+ const listeners: string[] = [];
+ const self = {
+ location: { origin: "https://reframe.test" },
+ addEventListener: (type: string) => listeners.push(type),
+ } as Record;
+ const source = precache ? injectManifest(SOURCE, precache) : SOURCE;
+ new Function("self", "caches", source)(self, {});
+ return { ...(self.__reframeSw as { route: Route; pageKey: (path: string) => string }), listeners };
+}
+
+const PRECACHE = { version: "abc", pages: ["/", "/contact", "/privacy"], assets: ["/_next/static/chunks/app.js"] };
+const get = (mode = "cors") => ({ method: "GET", mode });
+
+describe("precache list (scripts/build-sw-manifest.mjs)", () => {
+ it("turns HTML files into page URLs and keeps everything else as assets", () => {
+ const list = precacheList([
+ "index.html",
+ "contact.html",
+ "privacy.html",
+ "404.html",
+ "_not-found.html",
+ "contact/__next._tree.txt",
+ "_next/static/chunks/app.js",
+ "_next/static/chunks/app.js.map",
+ "_headers",
+ "sw.js",
+ "fonts/Geist-Regular.ttf",
+ ]);
+ expect(list.pages).toEqual(["/contact", "/", "/privacy"]);
+ expect(list.assets).toEqual(["/_next/static/chunks/app.js", "/contact/__next._tree.txt", "/fonts/Geist-Regular.ttf"]);
+ });
+
+ it("writes the list into sw.js in place of its placeholder", () => {
+ expect(SOURCE).toContain(PLACEHOLDER);
+ const built = injectManifest(SOURCE, PRECACHE);
+ expect(built).not.toContain(PLACEHOLDER);
+ expect(built).toContain('const PRECACHE = {"version":"abc"');
+ expect(() => injectManifest("// no placeholder", PRECACHE)).toThrow(/placeholder/);
+ });
+});
+
+describe("service worker routing (public/sw.js)", () => {
+ const sw = loadWorker(PRECACHE);
+
+ it("handles install, activate, fetch and update messages", () => {
+ expect(sw.listeners).toEqual(expect.arrayContaining(["install", "activate", "fetch", "message"]));
+ });
+
+ it("keeps every engine file the page loads, in either mode", () => {
+ for (const url of [...engineFileUrls(true), ...engineFileUrls(false)]) {
+ expect(sw.route(new URL(url), get())).toBe("engine");
+ }
+ });
+
+ it("serves the app's pages and files, and leaves everything else alone", () => {
+ expect(sw.route(new URL("https://reframe.test/?preset=square"), get("navigate"))).toBe("page");
+ expect(sw.route(new URL("https://reframe.test/_next/static/chunks/app.js"), get())).toBe("asset");
+ expect(sw.route(new URL("https://reframe.test/sw.js"), get())).toBe("network");
+ expect(sw.route(new URL("https://reframe.test/"), { method: "POST", mode: "cors" })).toBe("network");
+ // Other sites (analytics, other CDN files) are never cached.
+ expect(sw.route(new URL("https://browser-intake-datadoghq.com/api"), get())).toBe("network");
+ expect(sw.route(new URL("https://cdn.jsdelivr.net/npm/other@1/x.js"), get())).toBe("network");
+ });
+
+ it("finds the cached page for a path, and the editor for anything unknown", () => {
+ expect(sw.pageKey("/")).toBe("/");
+ expect(sw.pageKey("/contact")).toBe("/contact");
+ expect(sw.pageKey("/contact/")).toBe("/contact");
+ expect(sw.pageKey("/privacy.html")).toBe("/privacy");
+ expect(sw.pageKey("/index.html")).toBe("/");
+ expect(sw.pageKey("/nope")).toBe("/");
+ });
+
+ it("does nothing for the app itself before a build has written the list", () => {
+ const dev = loadWorker(null);
+ expect(dev.route(new URL("https://reframe.test/"), get("navigate"))).toBe("network");
+ expect(dev.route(new URL(engineFileUrls(false)[1]!), get())).toBe("engine");
+ });
+});
diff --git a/test/e2e/offline.spec.ts b/test/e2e/offline.spec.ts
new file mode 100644
index 000000000..36aa2c224
--- /dev/null
+++ b/test/e2e/offline.spec.ts
@@ -0,0 +1,68 @@
+import { test, expect, type Page } from "@playwright/test";
+import { execFileSync } from "node:child_process";
+import { join } from "node:path";
+
+// Offline support through the real browser: the service worker saves the
+// app and the video engine, then with the network off the editor still
+// opens, is cross-origin isolated exactly as online, and exports.
+const FIXTURES = join(__dirname, "..", "fixtures");
+
+async function openVideo(page: Page) {
+ await page.locator('input[type=file][accept="video/*"]').first().setInputFiles(join(FIXTURES, "normal.mp4"));
+ await expect(page.getByRole("button", { name: "Export video" }).first()).toBeVisible();
+}
+
+test("the editor opens and exports with the network off once it has been used online", async ({ page, context }, testInfo) => {
+ test.slow();
+ await page.addInitScript(() => localStorage.setItem("reframe_onboarding_complete", "1"));
+ await page.goto("/");
+ const onlineIsolated = await page.evaluate(() => crossOriginIsolated);
+ expect(onlineIsolated).toBe(testInfo.project.name === "multi-threaded");
+
+ // The worker installs, precaches the app and takes control of the page.
+ await page.evaluate(async () => {
+ await navigator.serviceWorker.ready;
+ if (!navigator.serviceWorker.controller) {
+ await new Promise((resolve) => navigator.serviceWorker.addEventListener("controllerchange", resolve, { once: true }));
+ }
+ });
+ const status = page.getByTestId("offline-status");
+ await expect(status).toHaveAttribute("data-state", "needs-engine");
+
+ // Opening a video loads the engine (for the filmstrip), which the worker keeps.
+ await openVideo(page);
+ await expect(status).toHaveAttribute("data-state", "ready", { timeout: 120_000 });
+ await expect(status).toHaveText("Works offline");
+
+ await context.setOffline(true);
+ await page.reload();
+ await expect(page.locator('input[type=file][accept="video/*"]').first()).toBeAttached();
+ expect(await page.evaluate(() => navigator.onLine)).toBe(false);
+ // Same isolation as online, so the same engine loads.
+ expect(await page.evaluate(() => crossOriginIsolated)).toBe(onlineIsolated);
+
+ await openVideo(page);
+ await page.getByRole("button", { name: /^Trim\b/ }).first().click();
+ await page.getByLabel("Trim end time in seconds").fill("2");
+ await page.getByRole("button", { name: "Export video" }).first().click();
+ const download = page.getByRole("link", { name: /^Download MP4$/ });
+ await expect(download).toBeVisible({ timeout: 180_000 });
+ const [file] = await Promise.all([page.waitForEvent("download"), download.click()]);
+ const out = testInfo.outputPath("offline.mp4");
+ await file.saveAs(out);
+ const seconds = Number(execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", out], { encoding: "utf8" }));
+ expect(seconds).toBeGreaterThan(1.8);
+ expect(seconds).toBeLessThan(2.3);
+});
+
+test("the app can be installed: the manifest is linked and complete", async ({ page, request }) => {
+ await page.goto("/");
+ const href = await page.locator('link[rel="manifest"]').getAttribute("href");
+ expect(href).toBe("/manifest.webmanifest");
+ const manifest = await (await request.get(href!)).json();
+ expect(manifest).toMatchObject({ name: expect.any(String), short_name: "Reframe", start_url: "/", display: "standalone" });
+ const sizes = manifest.icons.map((icon: { sizes: string }) => icon.sizes);
+ expect(sizes).toEqual(expect.arrayContaining(["192x192", "512x512"]));
+ expect(manifest.icons.some((icon: { purpose?: string }) => icon.purpose === "maskable")).toBe(true);
+ for (const icon of manifest.icons) expect((await request.get(icon.src)).ok()).toBe(true);
+});
diff --git a/vercel.json b/vercel.json
index 709d0f724..320affb8e 100644
--- a/vercel.json
+++ b/vercel.json
@@ -22,6 +22,10 @@
"value": "require-corp"
}
]
+ },
+ {
+ "source": "/sw.js",
+ "headers": [{ "key": "Cache-Control", "value": "no-cache" }]
}
]
}