Compare image changes with three focused review modes
Built for encoding-group review workflows with a fast path from local imports to published static compare pages.
Quick Start · View Workflow · Uploader Docs
Magic Compare Web is a monorepo for an image compare platform aimed at encoding groups.
The project has two deployment targets:
internal-site: a server-backed Next.js app for the internal catalog, case workspace, compare viewer, reorder operations, publish operations, public export, and Pages deploy.public-site: a static export target that consumes published artifacts fromcontent/published.
This repository is deliberately not a video previewer, not an online VapourSynth runner, and not an in-site review/comment system. The current scope is "look" and "publish".
Sliderfor before/after inspection on the same frame.A/Bfor direct version toggling during image review.Heatmapfor difference emphasis when visual deltas need extra contrast.- An internal import-to-publish workflow that turns local image sets into reviewable cases.
- A static public delivery target that reads published bundles from
content/published. - S3-compatible internal asset storage with uploader direct upload.
- Explicit public export and Cloudflare Pages deploy actions from the internal workspace.
cp .env.example .env
docker compose up -d rustfs rustfs-init
pnpm install
pnpm db:push
pnpm db:seed
pnpm public:export
# terminal 1
pnpm dev:internal
# terminal 2
pnpm dev:publicLocal entry points:
- internal site:
http://localhost:3000 - public site:
http://localhost:3001 - demo public page:
http://localhost:3001/g/demo-grain-study--banding-check
Demo content:
- internal case slug:
demo-grain-study - internal group slug:
banding-check - public group slug:
demo-grain-study--banding-check
💡 Note:
pnpm db:pushcurrently runsapps/internal-site/prisma/init-db.tsbecauseprisma db pushfails in the current local environment.
docker/internal-site.Dockerfilebuilds a full-workspace internal-site image that can also trigger public export and Pages deploy.docker-compose.ymlwires together:internal-siterustfsas the S3-compatible object storerustfs-init, a lightweightminio/mcbootstrapper that ensures the bucket exists on startupinternal-site-init, a one-off initializer that runsdb:pushanddb:seedbefore the app starts
- server-side Docker deployment now only needs:
docker-compose.yml.env- the published runtime image
- the base Compose file now defaults to the GHCR runtime image via
MAGIC_COMPARE_INTERNAL_SITE_IMAGE, so servers candocker compose upwithout a local build step - the base Compose file now uses Docker named volumes, so a server can run it without depending on repository-relative
docker-datapaths - if you want a local image build plus inspectable bind mounts during development, add
-f docker/dev.compose.override.yml - for local development, prefer the root scripts:
pnpm docker:dev:up,pnpm docker:dev:down,pnpm docker:dev:logs - the bundled local object-storage container now uses fixed runtime defaults; if you need to change its ports or runtime flags, edit
docker-compose.ymldirectly - internal assets now live in S3-compatible storage configured by
MAGIC_COMPARE_S3_* - published bundles still live under
MAGIC_COMPARE_PUBLISHED_ROOT - static public exports are mirrored into
MAGIC_COMPARE_PUBLIC_EXPORT_DIR
- Prepare a local case directory that matches the uploader convention.
- Run the uploader to validate required files and scan the source set.
- Upload source images, generated thumbnails, and generated heatmaps into S3-compatible storage.
- Build an
ImportManifestand post it toPOST /api/ops/import-sync. - Let the internal site upsert case and group metadata, then recreate replaced frame and asset rows from the manifest.
Result:
- imported review data is available in the internal site workspace
- internal assets live in S3-compatible storage; the database keeps logical
/internal-assets/...paths while browser-facing URLs resolve fromMAGIC_COMPARE_S3_PUBLIC_BASE_URL - detailed uploader usage lives in
tools/uploader/README.md - a Chinese note about the difference between built-in demo content and real case/group flows lives in
docs/demo-vs-real-case-flow.zh-CN.md
- Trigger
POST /api/ops/case-publishfrom the internal site. - Filter
group.isPublic,frame.isPublic, andasset.isPublicfor the selected case. - Derive or reuse a stable
publicSlugfor each public group. - Write a
manifest.jsonwithschemaVersionand absolute public S3 image URLs for each published group. - Trigger
pnpm public:exportorPOST /api/ops/public-exportwhen you want a fresh static public bundle. - Trigger
pnpm public:deployorPOST /api/ops/public-deploywhen you want a direct Cloudflare Pages upload.
Result:
- published artifacts live under
content/published apps/public-siteconsumes those artifacts as static content- the public deployment target stays read-only
The repository is fully bootstrapped and verified:
pnpm installworks.pnpm db:pushinitializes the SQLite schema.pnpm db:seedseeds a demo case into the internal database and uploads demo internal assets to S3-compatible storage.pnpm public:exportbuilds the static public bundle intodist/public-siteby default.pnpm buildsucceeds for both Next.js apps.pnpm testsucceeds for shared schema and viewer logic tests.
The seeded demo case is:
- Internal site case slug:
demo-grain-study - Internal group slug:
banding-check - Public group slug:
demo-grain-study--banding-check
apps/
internal-site/
public-site/
packages/
compare-core/
content-schema/
shared-utils/
ui/
tools/
uploader/
content/
published/
🏗️ Architecture Notes
Internal site responsibilities:
- show the case catalog
- show a case workspace
- show the internal group viewer
- accept import manifests from the uploader
- reorder groups within a case
- reorder frames within a group
- publish public artifacts into
content/published - export and deploy the public static site on demand
Key implementation areas:
app/: Next.js App Router routescomponents/: internal-only UI such as the case directory and workspace listlib/server/repositories/: read/write data access backed by Prisma clientlib/server/publish/: publish pipeline that filters public content and writes manifestslib/server/storage/: S3-backed internal asset helpers and published artifact writerslib/server/public-site/: runtime public export and Pages deploy helpersprisma/schema.prisma: Prisma data modelprisma/init-db.ts: SQLite schema bootstrap script used bypnpm db:push
Public site responsibilities:
- statically export published group pages
- read only from
content/published/groups/*/manifest.json - expose no catalog, no upload UI, and no write APIs
Key implementation areas:
app/g/[publicSlug]/page.tsx: SSG/static-export group viewer entrylib/content.ts: published manifest reader
Shared Zod schemas and TypeScript types for:
- case
- group
- frame
- asset
- import manifest
- publish manifest
- enums such as
CaseStatus,ViewerMode, andAssetKind
Important current rule:
- internal
slugvalues use kebab-case with single hyphens - public
publicSlugvalues allow double hyphen separators such ascase--group
Shared viewer logic:
- viewer dataset shape
- asset lookup helpers
- available mode calculation
- heatmap fallback resolution
- client-side viewer controller state
Shared viewer workbench and theme:
- dark modern MUI theme
- group viewer shell
- top toolbar
- main stage
- filmstrip rail
- right sidebar
Python CLI that:
- validates a local case directory
- uploads source images and thumbnails into S3-compatible internal asset storage
- generates thumbnails
- builds an import manifest
- posts the manifest to
POST /api/ops/import-sync
There is a dedicated uploader document at tools/uploader/README.md.
🗂️ Data Model
The current implementation uses four content entities.
Case is the top-level container.
Fields:
idslugtitlesubtitlesummarytags[]statuscoverAssetIdpublishedAtupdatedAt
Group is the smallest public sharing unit.
Fields:
idcaseIdslugpublicSlugtitledescriptionorderdefaultModeisPublictags[]
Frame is one position in the filmstrip. A group can contain multiple frames.
Fields:
idgroupIdtitlecaptionorderisPublic
Asset is one concrete image variant attached to a frame.
Fields:
idframeIdkindlabelimageUrlthumbUrlwidthheightnoteisPublicisPrimaryDisplay
Current semantic rules:
beforeandafterare required for every framebeforeandafterare the default primary display assetsheatmapis optionalcropandmiscare optional
🛣️ Routing
//cases/[caseSlug]/cases/[caseSlug]/groups/[groupSlug]POST /api/ops/import-syncPOST /api/ops/group-reorderPOST /api/ops/frame-reorderPOST /api/ops/case-publish
/g/[publicSlug]
The public site intentionally has no index page.
🖼️ Viewer Behavior
The shared viewer layout follows the agreed workbench structure:
- top lightweight toolbar
- central main stage
- bottom filmstrip rail
- collapsible right sidebar
Supported v1 viewer modes:
before-aftera-bheatmap
Current heatmap degradation rules:
- if a frame has no heatmap asset, the public site hides the heatmap entry
- on the internal site, heatmap is shown as unavailable through state and sidebar information
- if the current frame does not support heatmap, viewer mode falls back to
group.defaultMode - if
group.defaultModealso depends on heatmap, the final fallback isbefore-after
Keyboard support currently includes:
- left and right arrow for frame navigation
1for before/after2for A/B3for heatmapifor sidebar toggle
📥 Detailed Import Flow
The current import flow is local-processing + S3 upload.
- A local case directory is prepared according to the uploader convention.
- The uploader scans the directory and validates required files.
- The uploader uploads source images, thumbnails, and generated heatmaps into S3-compatible storage.
- The uploader builds an
ImportManifest. - The uploader posts the manifest to
POST /api/ops/import-sync. - The internal site upserts case/group metadata, deletes existing frame/asset rows for replaced groups, and recreates them from the manifest.
📦 Detailed Publish Flow
The current publish flow is explicit and case-scoped.
- Internal site calls
POST /api/ops/case-publish. - The publish pipeline loads the full case and filters
group.isPublic,frame.isPublic, andasset.isPublic. - Each public group gets a stable
publicSlug. If it does not exist yet, it is derived fromcaseSlug--groupSlug. Collisions add a short suffix. - A
manifest.jsonwithschemaVersionand absolute public S3 image URLs is written for each published group. pnpm public:exportbuilds a fresh static public bundle and mirrors it intoMAGIC_COMPARE_PUBLIC_EXPORT_DIR.pnpm public:deployoptionally uploads that bundle to Cloudflare Pages through Wrangler.
Important current rule:
- a public frame without both
beforeandaftercauses publish to fail
🛠️ Local Development Details
pnpm installpnpm db:pushNote:
pnpm db:pushcurrently runsapps/internal-site/prisma/init-db.ts- Prisma remains the runtime ORM
- this workaround exists because
prisma db pushitself fails in the current local environment
docker compose up -d rustfs rustfs-init
pnpm db:seed
pnpm public:exportIn separate terminals:
pnpm dev:internal
pnpm dev:publicSuggested local URLs:
- internal site:
http://localhost:3000 - public site:
http://localhost:3001or another port if you launch it separately
🧪 Build, Test, and Useful Commands
| Task | Command |
|---|---|
| Build both apps | pnpm build |
| Run all tests | pnpm test |
| Run workspace type checks | pnpm typecheck |
| Start internal site | pnpm dev:internal |
| Start public site | pnpm dev:public |
| Initialize SQLite | pnpm db:push |
| Seed demo content | pnpm db:seed |
| Export public static site | pnpm public:export |
| Deploy public static site | pnpm public:deploy |
Build everything:
pnpm buildRun all tests:
pnpm testRun workspace type checks:
pnpm typecheckThe repository includes a checked-in published demo bundle:
content/published/groups/demo-grain-study--banding-check/manifest.json- corresponding SVG assets in the same directory
This is used for:
- public-site static generation
- local verification of the publish artifact shape
- seed/bootstrap reference content
- Prisma migrations are not yet wired; SQLite bootstrap is currently implemented through a manual init script.
- The public site still consumes published artifacts from the same repository checkout before export.
- Cloudflare Pages deploy assumes an existing Pages project and Wrangler-compatible credentials.
- There is no browser-side upload UI in v1.
- There is no in-site discussion, scoring, annotation, or review workflow.
- Uploader README
- VSEditor workflow guide (Simplified Chinese)
- Demo vs real case/group flow (Simplified Chinese)
- Chinese root README
- add a proper migration workflow once the Prisma schema engine issue is resolved in this environment
- move internal assets from app public storage to object storage or a dedicated managed path
- add richer error reporting for reorder and publish failures in the UI
- add end-to-end tests around internal reorder and publish flows
Released under the MIT License.