Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion apps/backend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 1 addition & 2 deletions apps/backend/src/upload/upload.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
}
Expand Down
10 changes: 5 additions & 5 deletions apps/frontend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
13 changes: 6 additions & 7 deletions apps/frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
63 changes: 0 additions & 63 deletions apps/frontend/src/consts/products.ts

This file was deleted.

2 changes: 0 additions & 2 deletions apps/frontend/src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 }
]
Expand Down
2 changes: 1 addition & 1 deletion apps/frontend/src/ui/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export function Footer() {
to="/products"
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
>
Products
Upscale Video
</Link>
</li>
</ul>
Expand Down
146 changes: 33 additions & 113 deletions apps/frontend/src/ui/components/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<header className="sticky top-0 z-50 w-full border-b border-border bg-background/80 backdrop-blur-md">
Expand Down Expand Up @@ -43,61 +37,19 @@ export function Navbar() {
</NavLink>
))}

<div className="group relative">
<button
className={cn(
'flex items-center gap-1 rounded-md px-3 py-2 text-sm font-medium transition-colors outline-none',
isProductsActive
<NavLink
to="/products"
className={({ isActive }) =>
cn(
'rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'text-primary'
: 'text-muted-foreground hover:text-foreground'
)}
>
Products
<ChevronDown className="size-3.5 transition-transform group-hover:rotate-180" />
</button>

<div className="invisible absolute left-1/2 top-full z-50 pt-2 opacity-0 transition-all duration-150 group-hover:visible group-hover:opacity-100 -translate-x-1/2">
<div className="w-56 overflow-hidden rounded-md border border-border bg-popover p-1 shadow-md">
{freeProducts.map((product) => (
<Link
key={product.slug}
to={`/products/${product.slug}`}
className={cn(
'flex items-center gap-2.5 rounded-sm px-2 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground',
product.isWip
? 'text-muted-foreground'
: 'text-popover-foreground'
)}
>
<product.icon className="size-4 text-muted-foreground" />
<span className="font-medium">{product.name}</span>
{product.isWip && (
<span className="ml-auto text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Soon
</span>
)}
</Link>
))}
{proProduct && (
<>
<div className="-mx-1 my-1 h-px bg-border" />
<Link
to={`/products/${proProduct.slug}`}
className="flex items-center gap-2.5 rounded-sm px-2 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Crown className="size-4 text-amber-500" />
<span className="font-medium">
{proProduct.name}
<span className="ml-1.5 inline-flex items-center rounded-full bg-gradient-to-r from-amber-500 to-amber-600 px-1.5 py-0.5 text-[10px] font-bold leading-none text-white">
PRO
</span>
</span>
</Link>
</>
)}
</div>
</div>
</div>
)
}
>
Upscale Video
</NavLink>

{NAV_LINKS_AFTER.map((link) => (
<NavLink
Expand All @@ -119,9 +71,9 @@ export function Navbar() {

<div className="hidden md:block">
<Button asChild size="sm">
<Link to="/products/pro">
<Crown className="size-3.5" data-icon="inline-start" />
Try Pro
<Link to="/products">
<Upload className="size-3.5" data-icon="inline-start" />
Upscale Video
</Link>
</Button>
</div>
Expand Down Expand Up @@ -163,43 +115,23 @@ export function Navbar() {
</NavLink>
))}

<div className="px-3 pb-1 pt-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Products
</div>
{PRODUCTS.map((product) => (
<NavLink
key={product.slug}
to={`/products/${product.slug}`}
onClick={() => {
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 ? (
<Crown className="size-4 text-amber-500" />
) : (
<product.icon className="size-4" />
)}
{product.name}
{product.isPro && (
<span className="ml-auto inline-flex items-center rounded-full bg-gradient-to-r from-amber-500 to-amber-600 px-1.5 py-0.5 text-[10px] font-bold leading-none text-white">
PRO
</span>
)}
{product.isWip && (
<span className="ml-auto text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Soon
</span>
)}
</NavLink>
))}
<NavLink
to="/products"
onClick={() => {
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'
)
}
>
<Upload className="size-4" />
Upscale Video
</NavLink>

{NAV_LINKS_AFTER.map((link) => (
<NavLink
Expand All @@ -220,18 +152,6 @@ export function Navbar() {
{link.label}
</NavLink>
))}

<Button asChild size="sm" className="mt-2">
<Link
to="/products/pro"
onClick={() => {
setMobileOpen(false);
}}
>
<Crown className="size-3.5" data-icon="inline-start" />
Try Pro
</Link>
</Button>
</nav>
</div>
)}
Expand Down
2 changes: 1 addition & 1 deletion apps/frontend/src/ui/components/home/HeroSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function HeroSection() {
</p>
<div className="mt-8 flex flex-wrap gap-4">
<Button asChild size="lg">
<Link to="/products/pro">
<Link to="/products">
<Play className="size-4" data-icon="inline-start" />
Try It Now
</Link>
Expand Down
Loading