Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/DATADOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
12 changes: 12 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions public/_headers
Original file line number Diff line number Diff line change
Expand Up @@ -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
Binary file added public/icons/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/icon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/maskable-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 18 additions & 0 deletions public/manifest.webmanifest
Original file line number Diff line number Diff line change
@@ -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" }
]
}
111 changes: 111 additions & 0 deletions public/sw.js
Original file line number Diff line number Diff line change
@@ -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 };
76 changes: 76 additions & 0 deletions scripts/build-sw-manifest.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
}
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions src/components/Footer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import Link from "next/link";
import OfflineStatus from "./OfflineStatus";
import {
Github,
Twitter,
Expand Down Expand Up @@ -51,6 +52,7 @@ export default function Footer() {
</span>
))}
</div>
<OfflineStatus />
</div>

{/* Navigation */}
Expand Down
43 changes: 43 additions & 0 deletions src/components/OfflineStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"use client";

import { CloudOff, RefreshCw, X } from "lucide-react";
import { useOfflineSupport, type OfflineState } from "@/hooks/useOfflineSupport";

const TEXT: Record<Exclude<OfflineState, "unavailable">, 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" && (
<p className="flex items-center gap-1.5 text-2xs text-[var(--muted)]" data-testid="offline-status" data-state={state}>
<CloudOff size={12} aria-hidden="true" />
{TEXT[state]}
</p>
)}
{updateReady && (
<div
role="status"
className="fixed bottom-4 left-4 z-50 flex items-center gap-2 rounded-pill border border-[var(--border)] bg-[var(--surface)] px-3 py-1.5 text-xs shadow-sm"
>
<span>A new version of Reframe is ready.</span>
<button type="button" className="inline-flex items-center gap-1 font-medium underline underline-offset-2" onClick={applyUpdate}>
<RefreshCw size={12} aria-hidden="true" />
Reload
</button>
<button type="button" className="text-[var(--muted)]" aria-label="Dismiss" onClick={dismissUpdate}>
<X size={12} aria-hidden="true" />
</button>
</div>
)}
</>
);
}
Loading
Loading