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() {
))}
-
-
+ cn(
+ 'rounded-md px-3 py-2 text-sm font-medium transition-colors',
+ isActive
? 'text-primary'
: 'text-muted-foreground hover:text-foreground'
- )}
- >
- Products
-
-
-
-
-
- {freeProducts.map((product) => (
-
-
-
{product.name}
- {product.isWip && (
-
- Soon
-
- )}
-
- ))}
- {proProduct && (
- <>
-
-
-
-
- {proProduct.name}
-
- PRO
-
-
-
- >
- )}
-
-
-
+ )
+ }
+ >
+ Upscale Video
+
{NAV_LINKS_AFTER.map((link) => (
-
-
- Try Pro
+
+
+ Upscale Video
@@ -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) => (
))}
-
-
- {
- setMobileOpen(false);
- }}
- >
-
- Try Pro
-
-
)}
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() {
-
+
Try It Now
diff --git a/apps/frontend/src/ui/components/technology/ArchitectureSection.tsx b/apps/frontend/src/ui/components/technology/ArchitectureSection.tsx
index c3025c1..75b506e 100644
--- a/apps/frontend/src/ui/components/technology/ArchitectureSection.tsx
+++ b/apps/frontend/src/ui/components/technology/ArchitectureSection.tsx
@@ -7,27 +7,27 @@ import { Separator } from '@/ui/shadcn/ui/separator';
const TOPICS = [
{
icon: Layers,
- title: 'Temporal Windows & the Baseline CNN',
+ title: 'BasicVSR: Flow-Aligned Recurrent Propagation',
paragraphs: [
- 'The core idea behind video super-resolution is that a single degraded frame does not contain enough information to fully reconstruct a high-quality version. However, neighboring frames in a video sequence often capture slightly different perspectives of the same scene due to motion. By processing multiple consecutive frames together, the model can combine complementary information and produce a result that surpasses what any single frame could provide.',
- 'Our baseline architecture takes a fixed-size window of consecutive frames (e.g. 7 frames) and concatenates them along the channel dimension. A standard RGB frame has 3 channels — so 7 frames produce a 21-channel input tensor. This tensor is passed through a series of convolutional layers that learn to extract and merge spatio-temporal features across all frames simultaneously.'
+ 'The core idea is that a single degraded frame does not contain enough information to fully reconstruct a high-quality version, but neighboring frames captured slightly different perspectives of the same scene due to motion. The model exploits this by propagating information between frames rather than processing each one in isolation.',
+ 'A shared CNN extracts features from every frame in a 15-frame window. SPyNet, a pretrained optical-flow network, then estimates the motion between each pair of neighboring frames. Features are propagated through the window twice — backward and forward — and at each step the previous propagated state is warped using the estimated flow before merging with the current frame, so moving content stays spatially aligned instead of blurring together.'
],
highlights: [
{
label: 'Input',
- value: 'N consecutive frames concatenated (N × 3 channels)'
+ value: '15-frame window, processed frame-by-frame (not concatenated)'
},
{
label: 'Output',
- value: 'Single enhanced central frame at target resolution'
+ value: 'One enhanced frame per window, taken from its center'
},
{
label: 'Alignment',
- value: 'Implicit — learned by convolutions, no explicit optical flow'
+ value: 'Explicit — optical flow from a pretrained SPyNet, fine-tuned at a lower learning rate'
},
{
label: 'Upscaling',
- value: 'Sub-pixel convolution layers for 2× or 4× super-resolution'
+ value: 'Pixel-shuffle convolutions, fixed at 4x'
}
]
},
@@ -35,26 +35,26 @@ const TOPICS = [
icon: Target,
title: 'Loss Functions',
paragraphs: [
- 'The loss function defines what "good output" means to the model during training. We use a combination of two complementary objectives that together balance pixel-level accuracy with perceptual quality.',
- 'L1 Loss (Mean Absolute Error) is the primary reconstruction loss. It measures the average absolute difference between each predicted pixel and the corresponding ground-truth pixel. L1 was chosen over L2 (Mean Squared Error) because L2 over-penalizes large errors, which causes the model to produce blurry "average" outputs. L1 preserves sharper edges and finer details.',
- 'Perceptual Loss is an optional secondary objective. Instead of comparing pixels directly, it passes both the predicted and ground-truth frames through a pretrained classification network (e.g. VGG) and compares their internal feature representations. This encourages the output to match the high-level visual structure, texture, and style of the ground truth — capturing qualities that pixel-wise metrics miss.'
+ 'The loss function defines what "good output" means to the model during training. We use a weighted combination of three complementary objectives.',
+ 'Charbonnier loss (a smooth, differentiable approximation of L1) is the primary reconstruction term — it penalizes pixel-wise error while staying more stable near zero than plain L1. A Sobel edge loss compares image gradients between prediction and target, pushing the model toward sharper edges. A VGG19-based perceptual loss compares deep feature activations of both images, encouraging realistic texture beyond what pixel-wise terms capture.',
+ 'The three terms are combined with fixed weights (Charbonnier 1.0, edge 0.05, perceptual 0.1) into a single training objective.'
],
highlights: [
{
- label: 'L1 Loss',
- value: 'Pixel-wise accuracy, sharp edges, stable training'
+ label: 'Charbonnier',
+ value: 'Primary reconstruction loss, weight 1.0'
},
{
- label: 'Perceptual Loss',
- value: 'Texture fidelity, structural similarity, visual realism'
+ label: 'Sobel edge loss',
+ value: 'Gradient-matching for sharper edges, weight 0.05'
},
{
- label: 'Composition',
- value: 'Weighted sum — weights are tunable per experiment'
+ label: 'VGG19 perceptual',
+ value: 'Deep feature matching for realistic texture, weight 0.1'
},
{
- label: 'Temporal',
- value: 'Implicitly enforced via consecutive-frame training pairs'
+ label: 'Composition',
+ value: 'Fixed-weight weighted sum of all three terms'
}
]
},
@@ -62,9 +62,9 @@ const TOPICS = [
icon: BarChart3,
title: 'Evaluation Metrics',
paragraphs: [
- 'Measuring the quality of restored video is a multi-faceted problem. A frame can be pixel-perfect but perceptually flat, or slightly imprecise per-pixel but visually stunning. We use multiple complementary metrics to capture different aspects of quality.',
- 'PSNR (Peak Signal-to-Noise Ratio) measures the ratio between the maximum possible signal power and the power of the distortion. Higher PSNR generally indicates better reconstruction fidelity, but it does not always correlate with human perception — two images with identical PSNR can look very different to the eye.',
- 'SSIM (Structural Similarity Index) goes beyond pixel differences by measuring changes in structural information, luminance, and contrast. It better reflects perceived image quality by considering how humans perceive visual patterns. We also use perceptual metrics based on deep feature distances for a more holistic assessment. All quantitative metrics are complemented by visual inspection on real legacy footage.'
+ 'Measuring the quality of restored video is a multi-faceted problem. A frame can be pixel-perfect but perceptually flat, or slightly imprecise per-pixel but visually convincing. We track multiple metrics and always compare against a bicubic-upscaling baseline rather than looking at absolute numbers alone.',
+ 'PSNR (Peak Signal-to-Noise Ratio) measures pixel-level reconstruction fidelity in dB — higher is better, though it does not always correlate with human perception.',
+ 'SSIM (Structural Similarity Index) measures structural and luminance similarity, better reflecting perceived quality. Both metrics are reported as the model’s gain over a simple bicubic upscale of the same input, which is the baseline the model needs to beat to justify the extra cost.'
],
highlights: [
{
@@ -76,19 +76,22 @@ const TOPICS = [
value: 'Structural similarity (0–1 scale, closer to 1 is better)'
},
{
- label: 'Perceptual',
- value: 'Deep feature distance from pretrained networks'
+ label: 'Baseline',
+ value: 'Bicubic upscale of the same input frame'
},
- { label: 'Visual', value: 'Human inspection on real degraded footage' }
+ {
+ label: 'Reported',
+ value: 'PSNR/SSIM gain over the bicubic baseline'
+ }
]
},
{
icon: GitBranch,
title: 'Training Strategy',
paragraphs: [
- 'A fundamental challenge in video restoration is the absence of paired real-world data. There is no dataset of the same video captured in both "old, degraded" and "pristine" quality. To solve this, we use a supervised learning approach with synthetically generated training pairs.',
- 'We start with high-quality video clips (1080p or higher, minimal compression, clean recordings) as ground truth. Each clip is then passed through a synthetic degradation pipeline that simulates the artifacts found in real legacy footage: spatial downscaling (1080p → 360p/480p), Gaussian noise injection, mild blur (focus and motion), and aggressive compression (low-bitrate H.264 encoding). The degraded version becomes the input, and the original becomes the target.',
- 'The model is trained on short clips of consecutive frames (not full videos) to keep memory requirements manageable. The dataset is split into training, validation, and test subsets. The validation set guides hyperparameter tuning and model selection, while the test set is reserved for final evaluation — never seen during training. This ensures results are not overfit to the training distribution.'
+ 'A fundamental challenge in video restoration is the absence of paired real-world data — there is no dataset of the same footage in both "old, degraded" and "pristine" quality. To solve this, we use supervised learning with synthetically generated training pairs.',
+ 'We start with high-quality video clips as ground truth. Each frame is passed through a randomized degradation function before downscaling: Gaussian blur (60% chance), motion blur (15% chance), Gaussian noise (60% chance, varying intensity), and JPEG re-compression (60% chance, varying quality) — each applied independently and randomly per frame. The degraded, downscaled version becomes the model input; the original becomes the target.',
+ 'The model trains on short 15-frame clips (not full videos) with a 64px patch size to keep memory requirements manageable. Data is split into training, validation, and test sets with strict separation — the test set is only used for final evaluation, never during training or model selection.'
],
highlights: [
{
@@ -97,15 +100,15 @@ const TOPICS = [
},
{
label: 'Degradations',
- value: 'Downscaling + noise + blur + compression artifacts'
+ value: 'Randomized blur, motion blur, noise, and JPEG compression'
},
{
label: 'Data split',
value: 'Train / validation / test — strict separation'
},
{
- label: 'Generalization',
- value: 'Tested on real legacy footage not seen during training'
+ label: 'Note',
+ value: 'Degradation is training-time only — inference upscales the input as-is, with no separate denoise/deblur step'
}
]
}
diff --git a/apps/frontend/src/ui/components/technology/PipelineSection.tsx b/apps/frontend/src/ui/components/technology/PipelineSection.tsx
index 79f0504..51a0ea8 100644
--- a/apps/frontend/src/ui/components/technology/PipelineSection.tsx
+++ b/apps/frontend/src/ui/components/technology/PipelineSection.tsx
@@ -1,4 +1,12 @@
-import { FileVideo, Layers, Cpu, Grid3X3, Film, ArrowDown } from 'lucide-react';
+import {
+ FileVideo,
+ Layers,
+ Waypoints,
+ Repeat,
+ Grid3X3,
+ Film,
+ ArrowDown
+} from 'lucide-react';
import { PageContainer } from '@/ui/components/PageContainer';
import { SectionHeading } from '@/ui/components/SectionHeading';
import { Card, CardContent, CardHeader, CardTitle } from '@/ui/shadcn/ui/card';
@@ -6,61 +14,69 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/ui/shadcn/ui/card';
const PIPELINE_STEPS = [
{
icon: FileVideo,
- title: 'Frame Extraction',
+ title: 'Frame Extraction & Resize',
description:
- 'The input video is decomposed into a sequence of individual frames. This step converts the compressed video stream into raw image data that the neural network can process.',
+ 'The input video is decoded frame-by-frame with OpenCV (falling back to an ffmpeg transcode if the container is not directly decodable). All frames are loaded into memory before processing.',
details: [
- 'Preserves the original frame rate (e.g. 24fps, 30fps) and temporal ordering',
+ 'Preserves the original frame rate and temporal ordering',
'Handles common container formats: MP4, AVI, MKV, MOV, WebM',
- 'Extracts frames as normalized tensor representations ready for GPU processing',
- 'Stores metadata (resolution, duration, codec) for later video reconstruction'
+ 'Frames taller than the configured max height are downscaled (aspect ratio preserved) to keep inference within GPU memory limits',
+ 'The model always upscales the input as-is — there is no separate denoising or artifact-removal stage'
]
},
{
icon: Layers,
- title: 'Temporal Window Creation',
+ title: 'Sliding 15-Frame Window',
description:
- 'Rather than processing each frame in isolation, frames are grouped into overlapping sliding windows of 3–7 consecutive frames. This is a critical step that separates video super-resolution from single-image approaches.',
+ 'Rather than transforming the whole video in one pass, every output frame is produced from its own 15-frame window centered on it. This is what lets the model exploit motion between frames instead of upscaling each one in isolation.',
details: [
- 'A window centered on frame N includes frames [N-3, N-2, N-1, N, N+1, N+2, N+3] for a window size of 7',
- 'Overlapping windows ensure every frame benefits from neighboring context',
- 'Allows the model to detect motion, exploit temporal redundancy, and avoid flickering',
- 'At video boundaries, frames are replicated to maintain consistent window sizes'
+ 'A window centered on frame N covers frames [N-7 .. N+7]',
+ 'Near the start/end of the video, the window is padded by repeating the boundary frame',
+ 'Each window is run independently through the full network — only the center frame’s output is kept',
+ 'This per-frame windowing is what allows progress reporting and mid-job cancellation'
]
},
{
- icon: Cpu,
- title: 'CNN Enhancement',
+ icon: Waypoints,
+ title: 'Feature Extraction & Optical Flow',
description:
- 'Each temporal window is fed into a convolutional neural network (CNN). The consecutive frames are concatenated along the channel dimension — so instead of a single 3-channel RGB image, the network receives a multi-frame tensor (e.g. 21 channels for 7 RGB frames).',
+ 'Each frame in the window is passed through a shared convolutional feature extractor. In parallel, SPyNet — a pretrained spatial-pyramid network — estimates the optical flow between every pair of neighboring frames in both directions.',
details: [
- 'Convolutional layers extract spatio-temporal features across all frames simultaneously',
- 'The network learns to align information between neighboring frames without explicit optical flow',
- 'The output is a single enhanced frame — the central frame of the window — with improved resolution and reduced artifacts',
- 'Optional 2x or 4x spatial upscaling via learned upsampling layers (sub-pixel convolution)'
+ 'SPyNet weights are pretrained, then fine-tuned at a lower learning rate during training',
+ 'Flow is estimated explicitly, not learned implicitly by the main network — this is the key difference from single-image super-resolution',
+ 'The estimated flow is later used to warp propagated features so they stay spatially aligned with the current frame'
+ ]
+ },
+ {
+ icon: Repeat,
+ title: 'Bidirectional Recurrent Propagation',
+ description:
+ 'Features flow through the window twice: once backward (last frame to first) and once forward (first to last). At each step, the previous propagated state is warped using the optical flow before being merged with the current frame’s features through residual blocks.',
+ details: [
+ 'Backward and forward propagation each use their own stack of residual blocks',
+ 'Warping with explicit flow keeps moving objects aligned across frames instead of blurring them together',
+ 'This recurrent, flow-aligned design is what BasicVSR uses instead of channel-concatenating frames into a single CNN'
]
},
{
icon: Grid3X3,
- title: 'Frame Reconstruction',
+ title: 'Fusion & 4x Upsampling',
description:
- 'As the sliding window moves across the video, enhanced frames are collected one by one. Each processed window contributes one restored central frame to the output sequence.',
+ 'For the window’s center frame, the forward and backward propagated features are fused and passed through a pixel-shuffle upsampler that increases spatial resolution by a fixed factor of 4x.',
details: [
- 'Frames are reordered to match the original temporal sequence',
- 'Overlapping window processing ensures seamless transitions with no gaps',
- 'Output frames maintain the same spatial alignment as the input, only at higher quality',
- 'GPU acceleration enables processing hundreds of windows per minute'
+ 'Fusion combines both propagation directions through residual blocks before upsampling',
+ 'Pixel-shuffle convolutions perform the learned upscaling in two 2x stages',
+ 'The scale factor is fixed at training time — it is not configurable per request'
]
},
{
icon: Film,
title: 'Video Assembly',
description:
- 'The final stage encodes all enhanced frames back into a playable video file. The output preserves the original temporal structure while delivering significantly improved visual quality.',
+ 'As each output frame is produced, it is written directly to the result video at the input frame rate and resolution multiplied by the upscale factor.',
details: [
- 'Re-encodes frames using modern codecs (H.264/H.265) with quality-optimized settings',
- 'Preserves original frame rate, duration, and aspect ratio',
- 'Output resolution matches the upscaling factor (e.g. 480p input → 1080p output at 2x)',
+ 'Frames are written in original temporal order as they complete',
+ 'Output resolution is exactly 4x the (possibly resized) input resolution',
'The resulting file is ready for immediate playback and download'
]
}
@@ -72,7 +88,7 @@ export function PipelineSection() {
{PIPELINE_STEPS.map((step, index) => (
diff --git a/apps/frontend/src/ui/components/technology/TechHero.tsx b/apps/frontend/src/ui/components/technology/TechHero.tsx
index f9ac375..9e9c65b 100644
--- a/apps/frontend/src/ui/components/technology/TechHero.tsx
+++ b/apps/frontend/src/ui/components/technology/TechHero.tsx
@@ -12,27 +12,28 @@ export function TechHero() {
The Technology Behind Upscale AI
- A deep learning pipeline that processes video frame-by-frame using
- temporal windows, restoring quality while maintaining smooth,
- consistent motion.
+ BasicVSR with a SPyNet optical-flow estimator: a bidirectional
+ recurrent network that explicitly aligns neighboring frames before
+ upscaling, instead of just stacking frames into a CNN.
-
5 Stages
+
6 Stages
End-to-end pipeline from raw video input to enhanced output
-
3–7 Frames
+
15-Frame Window
- Temporal window size for exploiting inter-frame information
+ Sliding window centered on each output frame, with explicit
+ optical-flow alignment between frames
-
Up to 4x
+
Fixed 4x
- Spatial upscaling from 360p/480p to Full HD and beyond
+ Spatial upscaling, e.g. 480p input to 1080p+ output
diff --git a/apps/frontend/src/ui/pages/Product.tsx b/apps/frontend/src/ui/pages/Product.tsx
deleted file mode 100644
index 1307a7c..0000000
--- a/apps/frontend/src/ui/pages/Product.tsx
+++ /dev/null
@@ -1,288 +0,0 @@
-import { useState, useCallback } from 'react';
-import { useParams, Navigate } from 'react-router';
-import { PageContainer } from '@/ui/components/PageContainer';
-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 { Badge } from '@/ui/shadcn/ui/badge';
-import { Button } from '@/ui/shadcn/ui/button';
-import {
- useUploadVideoMutation,
- useCancelJobMutation
-} from '@/store/api/upscale.api';
-import { useAppDispatch } from '@/store/hooks';
-import { addJob } from '@/store/slices/job.slice';
-import { getProductBySlug } from '@/consts/products';
-import { Crown, RotateCcw, Construction } from 'lucide-react';
-import { Card, CardContent } from '@/ui/shadcn/ui/card';
-
-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 Product() {
- const { slug } = useParams<{ slug: string }>();
- const product = slug ? getProductBySlug(slug) : undefined;
-
- 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);
- if (slug) formData.append('product', slug);
-
- 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, slug]
- );
-
- 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]);
-
- if (!product) {
- return ;
- }
-
- const Icon = product.icon;
-
- return (
-
-
-
-
- {product.isPro ? (
-
- ) : (
-
- )}
-
-
-
- {product.name}
-
- {product.isPro && (
-
- PRO
-
- )}
-
-
- {product.description}
-
-
-
- {product.isWip ? (
-
-
-
-
-
-
- Coming Soon
-
-
- We're currently focused on building the Video Upscaler.
- This tool is next on our roadmap and will be available soon.
-
-
- Try Video Upscaler
-
-
-
- ) : (
- <>
- {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.'}
-
-
-
-
- Try Again
-
-
- )}
-
- {pageState === 'cancelled' && (
-
-
-
- {processingError ?? 'Upscaling cancelled successfully.'}
-
-
-
-
- Upload New Video
-
-
- )}
- >
- )}
-
-
- );
-}
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.'}
+
+
+
+
+ Try Again
+
+
+ )}
+
+ {pageState === 'cancelled' && (
+
+
+
+ {processingError ?? 'Upscaling cancelled successfully.'}
+
+
+
+
+ Upload New Video
+
-
-
-
- {proProduct && (
-
-
-
-
-
-
-
-
-
-
- {proProduct.name}
-
-
- PRO
-
-
-
- {proProduct.description}
-
-
-
- {freeProducts.map((p) => (
-
-
- {p.shortName}
-
- ))}
-
- — all in one pass
-
-
-
-
-
-
- Try Upscale Pro
-
-
-
-
-
-
-
- )}
- >
+ )}
+
+
);
}