diff --git a/packages/cloudrun/src/gcpInstaller/gcpInstaller.tar b/packages/cloudrun/src/gcpInstaller/gcpInstaller.tar
index 8d24fa64f8f..c456c72cf65 100644
Binary files a/packages/cloudrun/src/gcpInstaller/gcpInstaller.tar and b/packages/cloudrun/src/gcpInstaller/gcpInstaller.tar differ
diff --git a/packages/convert/app/entry.client.tsx b/packages/convert/app/entry.client.tsx
new file mode 100644
index 00000000000..6fb3f8b2536
--- /dev/null
+++ b/packages/convert/app/entry.client.tsx
@@ -0,0 +1,20 @@
+import { RemixBrowser } from "@remix-run/react";
+import { hydrateRoot } from "react-dom/client";
+import { startTransition } from "react";
+
+const registerServiceWorker = () => {
+ if ("serviceWorker" in navigator) {
+ window.addEventListener("load", () => {
+ navigator.serviceWorker
+ .register("/convert/service-worker.js")
+ .catch((error) => {
+ console.log("SW registration failed:", error);
+ });
+ });
+ }
+};
+
+startTransition(() => {
+ hydrateRoot(document, );
+ registerServiceWorker();
+});
\ No newline at end of file
diff --git a/packages/convert/app/root.tsx b/packages/convert/app/root.tsx
index 7aca28603ee..8d6096920a6 100644
--- a/packages/convert/app/root.tsx
+++ b/packages/convert/app/root.tsx
@@ -1,6 +1,7 @@
-import {Links, Outlet, Scripts, ScrollRestoration} from '@remix-run/react';
import './tailwind.css';
+import {Links, Outlet, Scripts, ScrollRestoration} from '@remix-run/react';
+
export function Layout({children}: {readonly children: React.ReactNode}) {
return (
@@ -9,7 +10,8 @@ export function Layout({children}: {readonly children: React.ReactNode}) {
Remotion Convert
-
+
+
diff --git a/packages/convert/app/service-worker.ts b/packages/convert/app/service-worker.ts
new file mode 100644
index 00000000000..c922339f1bc
--- /dev/null
+++ b/packages/convert/app/service-worker.ts
@@ -0,0 +1,104 @@
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-nocheck
+const $FILES = [];
+// ^ leave this - will get filled in during build
+// --auto-generated-until-here
+
+const CACHE_NAME = 'remotion-convert-v1';
+
+// Helper function to determine if a request is under /convert
+function isConvertPath(url) {
+ return url.pathname.startsWith('/convert');
+}
+
+self.addEventListener('install', (event) => {
+ event.waitUntil(
+ (async () => {
+ const cache = await caches.open(CACHE_NAME);
+ await cache.addAll($FILES);
+ // @ts-expect-error no types
+ await self.skipWaiting();
+ })(),
+ );
+});
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(
+ (async () => {
+ // Clean up old caches
+ const cacheNames = await caches.keys();
+ await Promise.all(
+ cacheNames
+ .filter((name) => name !== CACHE_NAME)
+ .map((name) => caches.delete(name)),
+ );
+ // Take control of all pages immediately
+ // @ts-expect-error no types
+ await self.clients.claim();
+ })(),
+ );
+});
+
+self.addEventListener('fetch', (event) => {
+ const url = new URL(event.request.url);
+
+ if (!isConvertPath(url)) {
+ return;
+ }
+
+ // Only handle same-origin requests
+ if (!url.origin.includes(self.location.origin)) {
+ return;
+ }
+
+ // If it is a file selected from the user's device, do not cache it
+ if (url.protocol === 'file:') {
+ return;
+ }
+
+ // Special handling for /convert paths
+ if (isConvertPath(url)) {
+ event.respondWith(
+ (async () => {
+ try {
+ // Try to fetch the network request
+ const response = await fetch(event.request);
+
+ // Cache the new response
+ const cache = await caches.open(CACHE_NAME);
+ await cache.put(event.request, response.clone());
+
+ return response;
+ } catch {
+ // If network fails, try cache
+ const cachedResponse = await caches.match(event.request);
+ if (cachedResponse) {
+ return cachedResponse;
+ }
+
+ // If both network and cache fail, return a basic offline response
+ if (event.request.headers.get('accept')?.includes('text/html')) {
+ return caches.match('/convert');
+ }
+
+ return new Response('Network error happened', {
+ status: 408,
+ headers: {'Content-Type': 'text/plain'},
+ });
+ }
+ })(),
+ );
+ return;
+ }
+
+ // For all other requests, do a simple network-first strategy
+ event.respondWith(
+ (async () => {
+ try {
+ return await fetch(event.request);
+ } catch {
+ return caches.match(event.request);
+ }
+ })(),
+ );
+});
diff --git a/packages/convert/build-service-worker.ts b/packages/convert/build-service-worker.ts
new file mode 100644
index 00000000000..e3915effe23
--- /dev/null
+++ b/packages/convert/build-service-worker.ts
@@ -0,0 +1,43 @@
+import fs from 'fs';
+const files = fs.readdirSync('spa-dist/client');
+const assets = fs.readdirSync('spa-dist/client/assets');
+const toCache = [
+ ...files
+ .filter((f) => {
+ return fs.statSync(`spa-dist/client/${f}`).isFile();
+ })
+ .map((f) => `/convert/${f}`.replace('/index.html', '')),
+ ...assets
+ .filter((f) => {
+ if (!fs.statSync(`spa-dist/client/assets/${f}`).isFile()) {
+ throw new Error('Unexpected output');
+ }
+ return true;
+ })
+ .map((f) => `/convert/assets/${f}`),
+];
+
+const result = await Bun.build({
+ entrypoints: ['./app/service-worker.ts'],
+});
+
+if (!result.success) {
+ console.log(result.logs);
+ throw new Error('Failed to build service worker');
+}
+
+const firstOutput = result.outputs[0];
+
+if (!firstOutput) {
+ throw new Error('No output');
+}
+const text = await firstOutput.text();
+const replaced = '$FILES = [];';
+if (!text.includes(replaced)) {
+ throw new Error('Unexpected output');
+}
+
+await Bun.write(
+ 'spa-dist/client/service-worker.js',
+ text.replace(replaced, `$FILES = ${JSON.stringify(toCache)};`),
+);
diff --git a/packages/convert/package.json b/packages/convert/package.json
index 3d35ec88cc2..1b1a4f66e8f 100644
--- a/packages/convert/package.json
+++ b/packages/convert/package.json
@@ -6,7 +6,7 @@
"type": "module",
"scripts": {
"build-page": "remix vite:build",
- "build-spa": "remix vite:build -c vite-spa.config.ts",
+ "build-spa": "remix vite:build -c vite-spa.config.ts && bun build-service-worker.ts",
"dev": "remix vite:dev",
"typecheck": "tsc",
"test": "bun test test",
diff --git a/packages/convert/public/manifest.json b/packages/convert/public/manifest.json
new file mode 100644
index 00000000000..52f646703a5
--- /dev/null
+++ b/packages/convert/public/manifest.json
@@ -0,0 +1,28 @@
+{
+ "name": "Remotion Convert",
+ "id": "com.remotion.convert",
+ "short_name": "Remotion Convert",
+ "description": "Convert videos using WebCodecs",
+ "start_url": "/convert",
+ "display": "standalone",
+ "background_color": "#ffffff",
+ "theme_color": "#0B84F3",
+ "icons": [
+ {
+ "src": "/convert/pwa-icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "/convert/pwa-icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ },
+ {
+ "src": "/convert/pwa-icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ]
+}
diff --git a/packages/convert/public/pwa-icon-192.png b/packages/convert/public/pwa-icon-192.png
new file mode 100644
index 00000000000..f925e335b04
Binary files /dev/null and b/packages/convert/public/pwa-icon-192.png differ
diff --git a/packages/convert/public/pwa-icon-512.png b/packages/convert/public/pwa-icon-512.png
new file mode 100644
index 00000000000..11a6a15be1b
Binary files /dev/null and b/packages/convert/public/pwa-icon-512.png differ
diff --git a/packages/convert/tsconfig.json b/packages/convert/tsconfig.json
index 6597292eba4..11105109d1d 100644
--- a/packages/convert/tsconfig.json
+++ b/packages/convert/tsconfig.json
@@ -10,7 +10,7 @@
"test"
],
"compilerOptions": {
- "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "lib": ["WebWorker", "DOM", "DOM.Iterable", "ES2022"],
"types": ["@remix-run/node", "vite/client", "@types/bun"],
"isolatedModules": true,
"esModuleInterop": true,
diff --git a/packages/webcodecs/src/auto-select-writer.ts b/packages/webcodecs/src/auto-select-writer.ts
index 3f626044175..12a2618322c 100644
--- a/packages/webcodecs/src/auto-select-writer.ts
+++ b/packages/webcodecs/src/auto-select-writer.ts
@@ -1,7 +1,10 @@
-import {bufferWriter} from '@remotion/media-parser/buffer';
import {canUseWebFsWriter, webFsWriter} from '@remotion/media-parser/web-fs';
-import type {WriterInterface} from '@remotion/media-parser';
+import {
+ MediaParserInternals,
+ type WriterInterface,
+} from '@remotion/media-parser';
+import {bufferWriter} from '@remotion/media-parser/buffer';
import type {LogLevel} from './log';
import {Log} from './log';
@@ -15,14 +18,44 @@ export const autoSelectWriter = async (
}
Log.verbose(logLevel, 'Determining best writer');
- if (await canUseWebFsWriter()) {
- Log.verbose(logLevel, 'Using WebFS writer because it is supported');
- return webFsWriter;
+
+ // Check if we're offline using the navigator API
+ const isOffline = !navigator.onLine;
+
+ if (isOffline) {
+ Log.verbose(logLevel, 'Offline mode detected, using buffer writer');
+ return bufferWriter;
+ }
+
+ try {
+ const {
+ promise: timeout,
+ reject,
+ resolve,
+ } = MediaParserInternals.withResolvers();
+ const time = setTimeout(
+ () => reject(new Error('WebFS check timeout')),
+ 2000,
+ );
+
+ const webFsSupported = await Promise.race([canUseWebFsWriter(), timeout]);
+ resolve();
+ clearTimeout(time);
+
+ if (webFsSupported) {
+ Log.verbose(logLevel, 'Using WebFS writer because it is supported');
+ return webFsWriter;
+ }
+ } catch (err) {
+ Log.verbose(
+ logLevel,
+ `WebFS check failed: ${err}. Falling back to buffer writer`,
+ );
}
Log.verbose(
logLevel,
- 'Using buffer writer because WebFS writer is not supported',
+ 'Using buffer writer because WebFS writer is not supported or unavailable',
);
return bufferWriter;
};