diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 8154f02..1dc3718 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -65,7 +65,6 @@ States: `queued → processing → completed | failed | cancelled`. Terminal sta - Jobs live only in memory: lost on restart, never cleaned up (disk files included). No queue — concurrent uploads hit the AI service concurrently. No auth or rate limiting. - `result.downloadUrl` is a relative path (`/api/upload/stream/{jobId}`); `outputFilename` is a display name (`{base}_enhanced_by_upscale{ext}`) — the file on disk is `{jobId}_enhanced{ext}`. -- The Swagger-documented `product` form field on upload is accepted but never read. - Multer's `fileFilter` rejects via plain `Error`, so a rejected extension may not produce a clean ProblemDetails. - Range parsing does not bounds-check `start`/`end` against the file size. diff --git a/apps/backend/src/upload/upload.controller.ts b/apps/backend/src/upload/upload.controller.ts index 78efa54..83d2c12 100644 --- a/apps/backend/src/upload/upload.controller.ts +++ b/apps/backend/src/upload/upload.controller.ts @@ -61,8 +61,7 @@ export class UploadController { schema: { type: 'object', properties: { - video: { type: 'string', format: 'binary' }, - product: { type: 'string' } + video: { type: 'string', format: 'binary' } }, required: ['video'] } diff --git a/apps/frontend/AGENTS.md b/apps/frontend/AGENTS.md index ccc35f8..e883358 100644 --- a/apps/frontend/AGENTS.md +++ b/apps/frontend/AGENTS.md @@ -7,20 +7,20 @@ Vite 8 + React 19 SPA. Port 5173 (`VITE_PORT`). Tailwind v4 (CSS-config in `src/ ## Structure - `src/main.tsx` / `src/App.tsx` — entry (StrictMode, Redux `Provider`) and `RouterProvider`. -- `src/router/index.ts` — routes: Home, Products, Product (`:slug`), Technology, About, all nested under `RootLayout` (Navbar + Footer). +- `src/router/index.ts` — routes: Home, Products (the single upload tool, no slug), Technology, About, all nested under `RootLayout` (Navbar + Footer). - `src/store/` — Redux store; `store/api/upscale.api.ts` is the RTK Query API (upload with XHR progress, status, result, cancel); `store/slices/job.slice.ts` holds `activeJobs` (currently write-only scaffolding — nothing reads it). - `src/config/api.ts` — `API_ORIGIN` from `VITE_API_BASE_URL` (origin only, no `/api` — it strips a legacy `/api` suffix), plus `buildApiUrl`/`interpolatePath` helpers for contract paths. - `src/ui/pages|components|layouts` — feature components; `src/ui/shadcn/` is vendored shadcn (lint-relaxed, avoid editing). `cn()` lives at `@/ui/shadcn/lib/utils` (the `components.json` `utils` alias is stale — use the lib path). -- `src/consts/` — frontend-only UI data (`products.ts` catalog with `isPro`/`isWip` flags, navigation, features). +- `src/consts/` — frontend-only UI data (navigation, features). - `src/utils/format.ts` — `formatFileSize` (used), `formatDuration` (currently unused). ## The upscale flow -Everything lives under `/products/:slug`. Only `upscaler` and `pro` accept uploads; WIP slugs show a "Coming Soon" card; unknown slugs redirect to `/products/upscaler`. +Everything lives under `/products` — a single page for the one real model the AI service runs (BasicVSR + SPyNet, fixed 4x super-resolution). There used to be a multi-"product" catalog (Denoise/Deblur/Artifacts/Pro) and a `/products/:slug` route, but the backend never read the slug and those tools don't exist in the inference pipeline — they were removed rather than left as misleading marketing. -`src/ui/pages/Product.tsx` orchestrates with a **local** `PageState` machine (`idle | uploading | processing | completed | failed | cancelled`) in `useState` — not Redux. A page refresh loses in-flight job UI state. +`src/ui/pages/Products.tsx` orchestrates with a **local** `PageState` machine (`idle | uploading | processing | completed | failed | cancelled`) in `useState` — not Redux. A page refresh loses in-flight job UI state. -1. **Upload** — `VideoUploadForm.tsx` (drag-and-drop, MIME allowlist, 500 MB client cap). `uploadVideo` in `upscale.api.ts` uses a **custom XHR in `queryFn`** because fetch has no upload progress events — do not refactor it to a plain `builder.mutation`/fetch. FormData fields: `video` (file) and `product` (slug; backend currently ignores it). Errors are parsed as RFC 7807 (`detail`/`title`). +1. **Upload** — `VideoUploadForm.tsx` (drag-and-drop, MIME allowlist, 500 MB client cap). `uploadVideo` in `upscale.api.ts` uses a **custom XHR in `queryFn`** because fetch has no upload progress events — do not refactor it to a plain `builder.mutation`/fetch. FormData fields: `video` (file) only. Errors are parsed as RFC 7807 (`detail`/`title`). 2. **Live updates** — `JobStatusPanel.tsx` opens a native `EventSource` to `UPLOAD_EVENTS_ENDPOINT` (SSE is **outside** RTK Query). Each message is validated with `jobUpdateSchema`. On SSE failure it falls back to polling `getJobStatusContract.path` every 1s with raw `fetch`, giving up after 30 attempts. Terminal states close the stream and notify the parent. (`useGetJobStatusQuery` exists in the RTK API but is unused by the UI.) 3. **Cancel** — `useCancelJobMutation`; UI sets `isStopping` and waits for SSE/polling to report `cancelled` (the terminal transition is asynchronous). 4. **Result** — `JobResultPanel.tsx` fetches metadata via `useGetJobResultQuery`, plays the video from `buildApiUrl(UPLOAD_STREAM_ENDPOINT, { jobId })` (HTTP Range), and downloads by fetching the stream as a blob named `result.outputFilename`. **The `downloadUrl` field from the API is ignored** — always build the stream URL from `@repo/consts/upload`. diff --git a/apps/frontend/README.md b/apps/frontend/README.md index bea2c6f..3a6c485 100644 --- a/apps/frontend/README.md +++ b/apps/frontend/README.md @@ -15,13 +15,12 @@ The backend (`apps/backend`, port 3000) must be running for the upscale flow; fo ## Pages -| Route | Page | -| ----------------- | ---------------------------------------------------------------- | -| `/` | Home (hero, features, how-it-works) | -| `/products` | Product catalog | -| `/products/:slug` | Product page — `upscaler` and `pro` host the working upload flow | -| `/technology` | Tech overview (pipeline, architecture, stack) | -| `/about` | Project, team, academic context | +| Route | Page | +| -------------- | ------------------------------------------------ | +| `/` | Home (hero, features, how-it-works) | +| `/products` | Video Upscaler — the one working upload flow | +| `/technology` | Tech overview (pipeline, architecture, stack) | +| `/about` | Project, team, academic context | ## Configuration diff --git a/apps/frontend/src/consts/products.ts b/apps/frontend/src/consts/products.ts deleted file mode 100644 index 9fd0808..0000000 --- a/apps/frontend/src/consts/products.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { ZoomIn, Eraser, Focus, Wand2, Crown } from 'lucide-react'; -import type { LucideIcon } from 'lucide-react'; - -export interface ProductDefinition { - slug: string; - name: string; - shortName: string; - description: string; - icon: LucideIcon; - isPro?: boolean; - isWip?: boolean; -} - -export const PRODUCTS: ProductDefinition[] = [ - { - slug: 'upscaler', - name: 'Video Upscaler', - shortName: 'Upscaler', - description: - 'Increase video resolution up to 4x. Recover fine spatial details lost in low-resolution recordings using deep learning super-resolution.', - icon: ZoomIn - }, - { - slug: 'denoise', - name: 'Noise Reducer', - shortName: 'Denoise', - description: - 'Remove grain, sensor noise, and analog artifacts from legacy video content while preserving texture and detail.', - icon: Eraser, - isWip: true - }, - { - slug: 'deblur', - name: 'Blur Fix', - shortName: 'Deblur', - description: - 'Correct focus issues and motion blur. Sharpen soft footage to reveal details hidden by optical and motion degradation.', - icon: Focus, - isWip: true - }, - { - slug: 'artifacts', - name: 'Artifact Cleaner', - shortName: 'Artifacts', - description: - 'Eliminate compression blocks, ringing effects, and encoding artifacts introduced by aggressive video compression.', - icon: Wand2, - isWip: true - }, - { - slug: 'pro', - name: 'Upscale Pro', - shortName: 'Pro', - description: - 'The complete restoration pipeline. Combines super-resolution, denoising, deblurring, and artifact removal in a single pass for maximum quality.', - icon: Crown, - isPro: true - } -]; - -export function getProductBySlug(slug: string): ProductDefinition | undefined { - return PRODUCTS.find((p) => p.slug === slug); -} diff --git a/apps/frontend/src/router/index.ts b/apps/frontend/src/router/index.ts index ace6009..2d43c45 100644 --- a/apps/frontend/src/router/index.ts +++ b/apps/frontend/src/router/index.ts @@ -3,7 +3,6 @@ import { Root } from '@/ui/Root'; import { RootLayout } from '@/ui/layouts/RootLayout'; import { Home } from '@/ui/pages/Home'; import { Products } from '@/ui/pages/Products'; -import { Product } from '@/ui/pages/Product'; import { Technology } from '@/ui/pages/Technology'; import { About } from '@/ui/pages/About'; @@ -17,7 +16,6 @@ export const router = createBrowserRouter([ children: [ { index: true, Component: Home }, { path: 'products', Component: Products }, - { path: 'products/:slug', Component: Product }, { path: 'technology', Component: Technology }, { path: 'about', Component: About } ] diff --git a/apps/frontend/src/ui/components/Footer.tsx b/apps/frontend/src/ui/components/Footer.tsx index 9100ac2..e8a7c9c 100644 --- a/apps/frontend/src/ui/components/Footer.tsx +++ b/apps/frontend/src/ui/components/Footer.tsx @@ -43,7 +43,7 @@ export function Footer() { to="/products" className="text-sm text-muted-foreground transition-colors hover:text-foreground" > - Products + Upscale Video diff --git a/apps/frontend/src/ui/components/Navbar.tsx b/apps/frontend/src/ui/components/Navbar.tsx index b553244..fbfe882 100644 --- a/apps/frontend/src/ui/components/Navbar.tsx +++ b/apps/frontend/src/ui/components/Navbar.tsx @@ -1,18 +1,12 @@ import { useState } from 'react'; -import { NavLink, Link, useLocation } from 'react-router'; -import { Menu, X, ChevronDown, Crown } from 'lucide-react'; +import { NavLink, Link } from 'react-router'; +import { Menu, X, Upload } from 'lucide-react'; import { cn } from '@/ui/shadcn/lib/utils'; import { Button } from '@/ui/shadcn/ui/button'; import { NAV_LINKS_BEFORE, NAV_LINKS_AFTER } from '@/consts/navigation'; -import { PRODUCTS } from '@/consts/products'; export function Navbar() { const [mobileOpen, setMobileOpen] = useState(false); - const location = useLocation(); - const isProductsActive = location.pathname.startsWith('/products'); - - const freeProducts = PRODUCTS.filter((p) => !p.isPro); - const proProduct = PRODUCTS.find((p) => p.isPro); return (
@@ -43,61 +37,19 @@ export function Navbar() { ))} -
- - -
-
- {freeProducts.map((product) => ( - - - {product.name} - {product.isWip && ( - - Soon - - )} - - ))} - {proProduct && ( - <> -
- - - - {proProduct.name} - - PRO - - - - - )} -
-
-
+ ) + } + > + Upscale Video + {NAV_LINKS_AFTER.map((link) => (
@@ -163,43 +115,23 @@ export function Navbar() { ))} -
- Products -
- {PRODUCTS.map((product) => ( - { - setMobileOpen(false); - }} - className={({ isActive }) => - cn( - 'flex items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium transition-colors', - isActive - ? 'bg-accent text-primary' - : 'text-muted-foreground hover:bg-accent hover:text-foreground' - ) - } - > - {product.isPro ? ( - - ) : ( - - )} - {product.name} - {product.isPro && ( - - PRO - - )} - {product.isWip && ( - - Soon - - )} - - ))} + { + setMobileOpen(false); + }} + className={({ isActive }) => + cn( + 'flex items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium transition-colors', + isActive + ? 'bg-accent text-primary' + : 'text-muted-foreground hover:bg-accent hover:text-foreground' + ) + } + > + + Upscale Video + {NAV_LINKS_AFTER.map((link) => ( ))} - - )} diff --git a/apps/frontend/src/ui/components/home/HeroSection.tsx b/apps/frontend/src/ui/components/home/HeroSection.tsx index a8d30e9..b152783 100644 --- a/apps/frontend/src/ui/components/home/HeroSection.tsx +++ b/apps/frontend/src/ui/components/home/HeroSection.tsx @@ -25,7 +25,7 @@ export function HeroSection() {

- - - ) : ( - <> - {uploadError && ( - - {uploadError} - - )} - - {(pageState === 'idle' || pageState === 'uploading') && ( - { - void handleUpload(file); - }} - isUploading={pageState === 'uploading'} - uploadProgress={uploadProgress} - /> - )} - - {pageState === 'processing' && jobId && ( - { - setPageState('completed'); - }} - onCancelled={(reason) => { - setProcessingError(reason ?? 'Upscaling cancelled by user.'); - setPageState('cancelled'); - setIsStopping(false); - }} - onFailed={(reason) => { - setProcessingError( - reason ?? 'AI inference failed. Please try again.' - ); - setPageState('failed'); - setIsStopping(false); - }} - onStop={() => { - void handleStopUpscaling(); - }} - isStopping={isStopping} - /> - )} - - {pageState === 'completed' && jobId && ( - - )} - - {pageState === 'failed' && ( -
- - - {processingError ?? - 'Video processing failed. This may be due to an unsupported format or a server issue.'} - - - -
- )} - - {pageState === 'cancelled' && ( -
- - - {processingError ?? 'Upscaling cancelled successfully.'} - - - -
- )} - - )} - - - ); -} diff --git a/apps/frontend/src/ui/pages/Products.tsx b/apps/frontend/src/ui/pages/Products.tsx index 1efa5e3..c9417a2 100644 --- a/apps/frontend/src/ui/pages/Products.tsx +++ b/apps/frontend/src/ui/pages/Products.tsx @@ -1,137 +1,235 @@ -import { Link } from 'react-router'; -import { ArrowRight, Crown } from 'lucide-react'; +import { useState, useCallback } from 'react'; +import { ZoomIn, RotateCcw } from 'lucide-react'; import { PageContainer } from '@/ui/components/PageContainer'; -import { SectionHeading } from '@/ui/components/SectionHeading'; -import { - Card, - CardHeader, - CardTitle, - CardDescription -} from '@/ui/shadcn/ui/card'; -import { Badge } from '@/ui/shadcn/ui/badge'; +import { VideoUploadForm } from '@/ui/components/product/VideoUploadForm'; +import { JobStatusPanel } from '@/ui/components/product/JobStatusPanel'; +import { JobResultPanel } from '@/ui/components/product/JobResultPanel'; +import { Alert, AlertDescription } from '@/ui/shadcn/ui/alert'; import { Button } from '@/ui/shadcn/ui/button'; -import { PRODUCTS } from '@/consts/products'; -import { cn } from '@/ui/shadcn/lib/utils'; +import { + useUploadVideoMutation, + useCancelJobMutation +} from '@/store/api/upscale.api'; +import { useAppDispatch } from '@/store/hooks'; +import { addJob } from '@/store/slices/job.slice'; + +type PageState = + | 'idle' + | 'uploading' + | 'processing' + | 'completed' + | 'failed' + | 'cancelled'; + +type UploadMutationError = + | { + error?: string; + // The backend returns RFC 7807 ProblemDetails (`detail`/`title`); the + // legacy `message` shape is kept as a fallback. + data?: { detail?: string; title?: string; message?: string | string[] }; + status?: string | number; + } + | undefined; + +function getApiErrorMessage(error: unknown): string | null { + const typedError = error as UploadMutationError; + if (!typedError) { + return null; + } + + if (typeof typedError.error === 'string' && typedError.error.length > 0) { + return typedError.error; + } + + const { detail, title, message } = typedError.data ?? {}; + if (typeof detail === 'string' && detail.length > 0) { + return detail; + } + if (Array.isArray(message)) { + return message.join(', '); + } + if (typeof message === 'string' && message.length > 0) { + return message; + } + if (typeof title === 'string' && title.length > 0) { + return title; + } + + return null; +} + +function getUploadErrorMessage(error: unknown): string { + return ( + getApiErrorMessage(error) ?? + 'Failed to upload video. Please check your connection and try again.' + ); +} + +function getCancelErrorMessage(error: unknown): string { + return ( + getApiErrorMessage(error) ?? 'Failed to stop upscaling. Please try again.' + ); +} export function Products() { - const freeProducts = PRODUCTS.filter((p) => !p.isPro); - const proProduct = PRODUCTS.find((p) => p.isPro); + const [pageState, setPageState] = useState('idle'); + const [jobId, setJobId] = useState(null); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadError, setUploadError] = useState(null); + const [processingError, setProcessingError] = useState(null); + const [isStopping, setIsStopping] = useState(false); + const [uploadVideo] = useUploadVideoMutation(); + const [cancelJob] = useCancelJobMutation(); + const dispatch = useAppDispatch(); + + const handleUpload = useCallback( + async (file: File) => { + setPageState('uploading'); + setUploadProgress(0); + setUploadError(null); + setProcessingError(null); + setIsStopping(false); + + try { + const formData = new FormData(); + formData.append('video', file); + + const result = await uploadVideo({ + formData, + onProgress: (p) => { + setUploadProgress(p); + } + }).unwrap(); + + setJobId(result.jobId); + setPageState('processing'); + dispatch( + addJob({ + jobId: result.jobId, + filename: file.name, + submittedAt: new Date().toISOString() + }) + ); + } catch (error) { + setUploadError(getUploadErrorMessage(error)); + setPageState('idle'); + } + }, + [uploadVideo, dispatch] + ); + + const handleReset = useCallback(() => { + setPageState('idle'); + setJobId(null); + setUploadProgress(0); + setUploadError(null); + setProcessingError(null); + setIsStopping(false); + }, []); + + const handleStopUpscaling = useCallback(async () => { + if (!jobId) return; + try { + setIsStopping(true); + await cancelJob(jobId).unwrap(); + } catch (error) { + setProcessingError(getCancelErrorMessage(error)); + setPageState('failed'); + setIsStopping(false); + } + }, [cancelJob, jobId]); return ( - <> -
- -

- Our Products +
+ +
+
+ +
+

+ Video Upscaler

-

- Choose the right tool for your video restoration needs, or go all-in - with Upscale Pro. +

+ Increase video resolution 4x using our BasicVSR-based deep + learning super-resolution model, recovering fine spatial detail + lost in low-resolution recordings.

- -
- -
- - + + {uploadError && ( + + {uploadError} + + )} + + {(pageState === 'idle' || pageState === 'uploading') && ( + { + void handleUpload(file); + }} + isUploading={pageState === 'uploading'} + uploadProgress={uploadProgress} + /> + )} + + {pageState === 'processing' && jobId && ( + { + setPageState('completed'); + }} + onCancelled={(reason) => { + setProcessingError(reason ?? 'Upscaling cancelled by user.'); + setPageState('cancelled'); + setIsStopping(false); + }} + onFailed={(reason) => { + setProcessingError( + reason ?? 'AI inference failed. Please try again.' + ); + setPageState('failed'); + setIsStopping(false); + }} + onStop={() => { + void handleStopUpscaling(); + }} + isStopping={isStopping} /> -
- {freeProducts.map((product) => ( - - - -
- -
- - {product.name} - {product.isWip ? ( - - Coming Soon - - ) : ( - - )} - - - {product.description} - -
-
- - ))} + )} + + {pageState === 'completed' && jobId && ( + + )} + + {pageState === 'failed' && ( +
+ + + {processingError ?? + 'Video processing failed. This may be due to an unsupported format or a server issue.'} + + + +
+ )} + + {pageState === 'cancelled' && ( +
+ + + {processingError ?? 'Upscaling cancelled successfully.'} + + +
- -
- - {proProduct && ( -
- - -
- -
- -
-
- - {proProduct.name} - - - PRO - -
- - {proProduct.description} - - -
- {freeProducts.map((p) => ( - - - {p.shortName} - - ))} - - — all in one pass - -
- - -
- - -
- )} - + )} + +

); }