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; };