diff --git a/package.json b/package.json index 57bf4fcc..dedae72e 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@ffmpeg/ffmpeg": "^0.12.10", "@ffmpeg/util": "^0.12.2", + "bun": "^1.3.14", "clsx": "^2.1.1", "focus-trap-react": "^12.0.1", "jszip": "^3.10.1", @@ -32,6 +33,7 @@ "@types/node": "^25", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^6.0.2", "eslint": "^9", "eslint-config-next": "^15.1.8", "eslint-plugin-jsx-a11y": "^6.10.2", diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 00000000..1b982bb7 Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 00000000..29c5fad2 Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 00000000..084b8d8b --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,53 @@ +{ + "name": "Reframe — Browser-based Video Editor", + "short_name": "Reframe", + "description": "Free, open-source video editor that runs entirely in your browser. Resize, trim, rotate, and export videos offline.", + "id": "/", + "start_url": "/", + "display": "standalone", + "background_color": "#12100e", + "theme_color": "#e63946", + "orientation": "any", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "screenshots": [ + { + "src": "/screenshots/desktop.png", + "sizes": "1280x720", + "type": "image/png", + "form_factor": "wide", + "label": "Reframe Desktop Video Editor" + }, + { + "src": "/screenshots/mobile.png", + "sizes": "720x1280", + "type": "image/png", + "form_factor": "narrow", + "label": "Reframe Mobile Video Editor" + } + ] +} diff --git a/public/screenshots/desktop.png b/public/screenshots/desktop.png new file mode 100644 index 00000000..f0a4072d Binary files /dev/null and b/public/screenshots/desktop.png differ diff --git a/public/screenshots/mobile.png b/public/screenshots/mobile.png new file mode 100644 index 00000000..78b19931 Binary files /dev/null and b/public/screenshots/mobile.png differ diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 00000000..c793a3f3 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,113 @@ +const CACHE_NAME = "reframe-pwa-cache-v1"; + +const PRECACHE_ASSETS = [ + "/", + "/index.html", + "/favicon.svg", + "/manifest.json", + "/robots.txt", + "/sitemap.xml", + "/sounds/export-complete.mp3", + "/screenshots/desktop.png", + "/screenshots/mobile.png" +]; + +// Install Event: cache static shell assets resiliently +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + // Fetch each asset individually to prevent any 404 error (e.g. dev-mode missing files) + // from blocking the entire service worker installation. + const cachePromises = PRECACHE_ASSETS.map((asset) => { + return fetch(asset) + .then((response) => { + if (response.ok) { + return cache.put(asset, response); + } + }) + .catch((err) => { + console.warn(`[SW Install] Skip precaching ${asset} due to:`, err); + }); + }); + return Promise.all(cachePromises); + }).then(() => { + return self.skipWaiting(); + }) + ); +}); + +// Activate Event: clean old caches +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => { + return Promise.all( + keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)) + ); + }).then(() => self.clients.claim()) + ); +}); + +// Fetch Event: apply custom caching strategies +self.addEventListener("fetch", (event) => { + const request = event.request; + if (request.method !== "GET") return; + + const url = new URL(request.url); + const isHtml = request.mode === "navigate" || url.pathname.endsWith(".html") || !url.pathname.includes("."); + + if (isHtml) { + // Network-First strategy for HTML navigations + event.respondWith( + fetch(request) + .then((response) => { + if (response.status === 200) { + const responseClone = response.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(request, responseClone); + }); + } + return response; + }) + .catch(() => { + return caches.match(request).then((cachedResponse) => { + if (cachedResponse) return cachedResponse; + // Safer static fallback supporting both / and /index.html structures + return caches.match("/").then((res1) => res1 || caches.match("/index.html")); + }); + }) + ); + } else { + // Cache-First strategy for assets (WASM, JS, CSS, Media) + event.respondWith( + caches.match(request).then((cachedResponse) => { + if (cachedResponse) { + return cachedResponse; + } + + return fetch(request).then((response) => { + const isCacheable = response.status === 200 || response.status === 0; + + // Explicitly check for Next.js chunks, CDN resources, and static assets + const isNextAsset = url.pathname.startsWith("/_next/") || + url.pathname.endsWith(".js") || + url.pathname.endsWith(".mjs") || + url.pathname.endsWith(".css"); + + const shouldCache = isCacheable && ( + url.origin === self.location.origin || + isNextAsset || + url.hostname.includes("jsdelivr.net") + ); + + if (shouldCache) { + const responseClone = response.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(request, responseClone); + }); + } + return response; + }); + }) + ); + } +}); diff --git a/scripts/resize_icons.ps1 b/scripts/resize_icons.ps1 new file mode 100644 index 00000000..9603c310 --- /dev/null +++ b/scripts/resize_icons.ps1 @@ -0,0 +1,49 @@ +Add-Type -AssemblyName System.Drawing + +$sourceImage = "C:\Users\verma\.gemini\antigravity-ide\brain\4edb9a97-81fe-47c8-8ab1-caac8ad98a39\reframe_logo_1779370016792.png" +$publicDir = "c:\Users\verma\reframe\public" + +if (-not (Test-Path $sourceImage)) { + Write-Error "Source image not found at $sourceImage" + exit 1 +} + +Write-Host "Loading source image..." +$srcBitmap = New-Object System.Drawing.Bitmap($sourceImage) + +# Resize helper function +function Resize-Image { + param( + [System.Drawing.Bitmap]$src, + [int]$width, + [int]$height, + [string]$outputPath + ) + Write-Host "Resizing to $width x $height..." + $dest = New-Object System.Drawing.Bitmap($width, $height) + $graphics = [System.Drawing.Graphics]::FromImage($dest) + + # Configure high-quality scaling + $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality + $graphics.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality + $graphics.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality + + # Draw resized + $graphics.DrawImage($src, 0, 0, $width, $height) + + # Save + $dest.Save($outputPath, [System.Drawing.Imaging.ImageFormat]::Png) + + # Cleanup + $graphics.Dispose() + $dest.Dispose() + Write-Host "Saved to $outputPath" +} + +# Create icons +Resize-Image $srcBitmap 192 192 "$publicDir\icon-192.png" +Resize-Image $srcBitmap 512 512 "$publicDir\icon-512.png" + +$srcBitmap.Dispose() +Write-Host "All icons generated successfully!" diff --git a/scripts/resize_screenshots.ps1 b/scripts/resize_screenshots.ps1 new file mode 100644 index 00000000..b65648e5 --- /dev/null +++ b/scripts/resize_screenshots.ps1 @@ -0,0 +1,67 @@ +Add-Type -AssemblyName System.Drawing + +$publicDir = "c:\Users\verma\reframe\public\screenshots" +$desktopSrc = "$publicDir\desktop.png" +$mobileSrc = "$publicDir\mobile.png" + +# Verify they exist +if (-not (Test-Path $desktopSrc) -or -not (Test-Path $mobileSrc)) { + Write-Error "Source screenshots not found!" + exit 1 +} + +# 1. Desktop: Crop 1024x1024 to 1024x576, then resize to 1280x720 +Write-Host "Processing desktop screenshot..." +$bmpDesktopSrc = New-Object System.Drawing.Bitmap($desktopSrc) +$bmpDesktopDest = New-Object System.Drawing.Bitmap(1280, 720) +$graphDesktop = [System.Drawing.Graphics]::FromImage($bmpDesktopDest) + +# Settings +$graphDesktop.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic +$graphDesktop.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality +$graphDesktop.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality +$graphDesktop.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality + +# Source crop rectangle: 16:9 from center +# Width: 1024, Height: 576. Y offset: (1024-576)/2 = 224 +$srcRectDesktop = New-Object System.Drawing.Rectangle(0, 224, 1024, 576) +$destRectDesktop = New-Object System.Drawing.Rectangle(0, 0, 1280, 720) + +$graphDesktop.DrawImage($bmpDesktopSrc, $destRectDesktop, $srcRectDesktop, [System.Drawing.GraphicsUnit]::Pixel) + +$graphDesktop.Dispose() +$bmpDesktopSrc.Dispose() + +# Delete old and save new +if (Test-Path $desktopSrc) { Remove-Item $desktopSrc -Force } +$bmpDesktopDest.Save($desktopSrc, [System.Drawing.Imaging.ImageFormat]::Png) +$bmpDesktopDest.Dispose() +Write-Host "Desktop screenshot processed successfully!" + +# 2. Mobile: Crop 1024x1024 to 576x1024, then resize to 720x1280 +Write-Host "Processing mobile screenshot..." +$bmpMobileSrc = New-Object System.Drawing.Bitmap($mobileSrc) +$bmpMobileDest = New-Object System.Drawing.Bitmap(720, 1280) +$graphMobile = [System.Drawing.Graphics]::FromImage($bmpMobileDest) + +# Settings +$graphMobile.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic +$graphMobile.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality +$graphMobile.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality +$graphMobile.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality + +# Source crop rectangle: 9:16 from center +# Height: 1024, Width: 576. X offset: (1024-576)/2 = 224 +$srcRectMobile = New-Object System.Drawing.Rectangle(224, 0, 576, 1024) +$destRectMobile = New-Object System.Drawing.Rectangle(0, 0, 720, 1280) + +$graphMobile.DrawImage($bmpMobileSrc, $destRectMobile, $srcRectMobile, [System.Drawing.GraphicsUnit]::Pixel) + +$graphMobile.Dispose() +$bmpMobileSrc.Dispose() + +# Delete old and save new +if (Test-Path $mobileSrc) { Remove-Item $mobileSrc -Force } +$bmpMobileDest.Save($mobileSrc, [System.Drawing.Imaging.ImageFormat]::Png) +$bmpMobileDest.Dispose() +Write-Host "Mobile screenshot processed successfully!" diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f16cedb2..c03c8ce1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,12 +4,18 @@ import ErrorBoundary from "@/components/ErrorBoundary"; import "./globals.css"; import { ThemeProvider } from "@/components/ThemeProvider"; import { ThemeToggle } from "@/components/ThemeToggle"; +import { InstallButton } from "@/components/InstallButton"; import ScrollToTop from "@/components/ScrollToTop"; import BrandLogo from "@/components/BrandLogo"; +export const viewport = { + themeColor: "#e63946", +}; + export const metadata: Metadata = { title: "Reframe — Resize, trim, and export videos in your browser", description: "Free, open-source video editor that runs entirely in your browser. No login, no uploads, no ads. Resize for any platform, trim, rotate, adjust speed, and export.", + manifest: "/manifest.json", keywords: [ "video editor", "browser video editor", @@ -49,6 +55,20 @@ export default function RootLayout({ return ( + +