Files
playable-showcase/DEVLOG.md
HPL-JesusCastro 5d6f228224 Initial Commit
2026-05-26 16:04:14 +08:00

11 KiB
Raw Blame History

Playable Showcase — Dev Log

Project Overview

Internal tool for uploading, previewing, and organizing HTML playable ads. Single-file HTML playables are uploaded, stored on disk, and served via Express. React SPA (Vite) for the frontend. JSON file-based DB (no SQL).

Stack: Node.js + Express · React + Vite · React Router DOM · Multer · Puppeteer


Architecture

PlayableShowcase/
├── server/
│   ├── index.js          — Express server (port 3000), serves /public and /uploads
│   ├── db.js             — JSON file-based database (ads + apps)
│   ├── routes/
│   │   ├── ads.js        — GET/PATCH /api/ads/:id, POST /api/ads/:id/screenshot
│   │   ├── upload.js     — POST /api/upload (multer, creates ad record)
│   │   └── apps.js       — CRUD /api/apps, GET /api/apps/lookup
│   └── utils/
│       └── screenshot.js — Puppeteer headless screenshot utility
├── client/
│   ├── vite.config.js    — outDir: '../public' (matches Express static path)
│   └── src/
│       ├── App.jsx        — Routes: / (Catalog), /apps, /viewer/:id
│       ├── api.js         — All fetch calls to Express API
│       ├── components/
│       │   ├── AppLayout.jsx/css   — Shared sidebar shell + mobile hamburger
│       │   ├── AdCard.jsx/css      — Catalog grid card (thumbnail + app icon)
│       │   ├── AppModal.jsx        — Create/edit application modal (shared)
│       │   ├── MetadataModal.jsx   — Edit playable metadata in Viewer
│       │   └── UploadPreviewModal.jsx/css — Upload flow modal
│       └── pages/
│           ├── Catalog.jsx/css     — Main grid + sidebar with search/filter
│           ├── Viewer.jsx/css      — Playable preview with device frame
│           └── Apps.jsx/css        — Application management page
├── uploads/              — Uploaded HTML files + thumbnail PNGs
└── public/               — Vite build output (served by Express)

Features Implemented

Catalog (/)

  • Grid of AdCards with thumbnails, app icon, title, app name
  • Sidebar: brand, upload button (hidden file input → UploadPreviewModal), app filter, search
  • Search filters by: title, developer, app name, tags
  • Apps sorted alphabetically in filter list

Viewer (/viewer/:id)

  • Device frame (phone/tablet/desktop) with scale-to-fit
  • Rotate frame button
  • Manual screenshot button (server-side Puppeteer)
  • Auto-screenshot on iframe load if no thumbnail exists
  • Sidebar: playable info, app icon + store links, repo@branch, tags, frame controls, edit button
  • MetadataModal for editing: title, app, tags, developer, repo, branch, notes, replace HTML file

Apps (/apps)

  • List all applications with icon, name, store links
  • Create/edit/delete apps
  • App Store / Play Store URL auto-fill (lookupApp API)

Upload Flow

  • Upload HTML → UploadPreviewModal opens
  • Left: live iframe preview of the playable
  • Right: metadata form (title, app, tags, developer, repo, branch, notes)
  • "Take Screenshot" button (required before Save) — calls Puppeteer server-side
  • "New Application" inline button opens AppModal without leaving the flow
  • Screenshot taken via server-side Puppeteer (not client-side canvas capture)

Mobile

  • Sidebar collapses to hamburger menu at ≤680px
  • Overlay closes sidebar when tapped
  • Upload preview frame uses aspect-ratio: 9/19.5

Key Technical Decisions

Screenshot: Server-side Puppeteer (not client-side html2canvas)

Client-side approaches (html2canvas, canvas.toDataURL) were tried and failed for various reasons:

  • html2canvas can't access iframe CSS from parent window context → HTML overlays invisible
  • WebGL preserveDrawingBuffer: false → canvas.toDataURL() always returns blank
  • Composite-canvas fallback only captures WebGL, misses HTML UI layers

Decision: All screenshots go through POST /api/ads/:id/screenshot which runs Puppeteer. Puppeteer captures the GPU-composited output, getting WebGL + HTML overlays correctly.

Vite Build Output

vite.config.js sets build: { outDir: '../public', emptyOutDir: true }. Express serves static files from ./public. Without this, Vite outputs to client/dist and Express throws ENOENT: no such file or directory, stat '.../public/index.html'.

AdCard Thumbnail Display

.ad-card__thumb uses aspect-ratio: 9 / 19.5 to match the 390×844 Puppeteer output. object-fit: cover fills the container cleanly since both are the same ratio.

AppModal Shared Component

AppModal was extracted from Apps.jsx into components/AppModal.jsx so it can be used by both Apps.jsx (edit apps) and UploadPreviewModal.jsx (create app inline during upload). Imports styles from '../pages/Apps.css' (note the relative path from components/).

Sidebar Layout Pattern

All pages use AppLayout + consistent CSS class names:

  • .sb-brand — top brand name
  • .sb-nav — nav button area
  • .sb-btn — sidebar button
  • .sb-divider — horizontal rule
  • .sb-section-label — uppercase section label
  • .viewer__sb-info — padded content block inside a section

Outstanding Issues

1. White Screenshots — Unity WebGL (UNRESOLVED)

Symptom: Clicking "Take Screenshot" on Unity WebGL playables returns a white/blank image.

Root cause analysis: Unity WebGL uses preserveDrawingBuffer: false by default. This means after the GPU compositor reads the WebGL framebuffer to display the frame, the buffer is cleared. canvas.toDataURL() called between frames returns blank.

Fixes attempted:

  1. html2canvas with frozen canvas data — failed (doesn't capture HTML overlays)
  2. Canvas composite fallback — failed (same issue)
  3. Puppeteer with networkidle2 + 1500ms wait — failed (Unity initializes after network idle)
  4. Puppeteer with waitForFunction polling canvas.toDataURL() — failed (still blank due to preserveDrawingBuffer)
  5. evaluateOnNewDocument monkey-patch to force preserveDrawingBuffer: true — partial
    • Monkey-patches HTMLCanvasElement.prototype.getContext before any page scripts run
    • Forces { preserveDrawingBuffer: true } on all webgl/webgl2 contexts
    • Changed waitUntil: 'networkidle2''load' because Unity's fetch requests keep the network permanently busy, causing networkidle2 timeout on large files
    • Test script confirmed this works in isolation (saw "PL Island House" 3D screenshot)
    • But user still reports white screenshots in the actual app

Suspected remaining cause: The server process was NOT restarted after screenshot.js was changed. Node.js caches require()d modules — the old code runs until the server restarts. Or: there are additional Unity playables with different initialization patterns.

What to try next:

  • Restart the server (Ctrl+C then node index.js or npm start in /server)
  • If still white: add console logging to screenshot.js to see what waitForFunction detects
  • Consider: page.waitForFunction might not see window.unityInstance if Unity sets it as a local variable (not on window) — check the actual Unity HTML template used
  • Nuclear option: after canvas polling resolves, take 3 screenshots 500ms apart and keep the largest (most content = most bytes)

Current screenshot.js logic (server/utils/screenshot.js):

// 1. Inject monkey-patch BEFORE page scripts run
await page.evaluateOnNewDocument(() => {
  const orig = HTMLCanvasElement.prototype.getContext;
  HTMLCanvasElement.prototype.getContext = function(type, attrs) {
    if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') {
      attrs = Object.assign({}, attrs || {}, { preserveDrawingBuffer: true });
    }
    return orig.call(this, type, attrs);
  };
});

// 2. Navigate (load, not networkidle2)
await page.goto(url, { waitUntil: 'load', timeout: 60000 });

// 3. Poll canvas for non-blank pixels (up to 20s)
await page.waitForFunction(() => {
  const canvases = Array.from(document.querySelectorAll('canvas'))
    .filter(c => c.width > 0 && c.height > 0);
  return canvases.some(c => {
    try {
      const d = c.toDataURL('image/png');
      return (d.split(',')[1] || '').length > 2000;
    } catch { return false; }
  });
}, { timeout: 20000, polling: 500 });

// 4. Settle 800ms, then screenshot
await new Promise(r => setTimeout(r, 800));
const buffer = await page.screenshot({ type: 'png' });

2. Simulated Device Too Small on Mobile (UNRESOLVED)

Symptom: On mobile viewports, the device frame in Viewer is very small — doesn't fill the available space.

Root cause: .viewer__stage is a flex child of .app-layout__main which is display: flex; flex-direction: column. The stage needs flex: 1; min-height: 0 to grow and fill the column. min-height: 0 is required because flex children default to min-height: auto, preventing them from shrinking below their content size.

Fix was applied (Viewer.css sets flex: 1; min-height: 0 on .viewer__stage) but mobile may still show the device too small because:

  • On mobile, the hamburger bar takes 56px at the top (padding-top: 56px on .app-layout__main)
  • The scale calculation in Viewer.jsx uses stageDims from a ResizeObserver
  • If ResizeObserver fires before layout settles, stageDims could be stale
  • The scale formula: Math.min(1, (sw - 32) / fw, (sh - 32) / fh) where fw/fh are frame dimensions

What to try next:

  • Verify .viewer__stage actually has flex: 1; min-height: 0 in the built CSS (run npm run build in /client and check /public/assets/index-*.css)
  • On mobile, check if stageDims.height reflects the full available height minus hamburger
  • The DeviceFrame component's getFrameSize(frame, rotated) returns fixed pixel dimensions — if the stage is e.g. 400px tall on mobile but the frame is 844px, scale = 400/844 ≈ 0.47 which might feel too small. Consider increasing margin or adjusting min-scale.

Playable Fields (DB Schema)

Each ad record:

{
  "id": "uuid",
  "filename": "uuid.html",
  "title": "string",
  "appId": "uuid (ref to apps)",
  "tags": ["string"],
  "developer": "string",
  "repo": "string",
  "branch": "string",
  "notes": "string",
  "thumbnail": "uuid-thumb.png",
  "uploaded_at": 1234567890,
  "updated_at": 1234567890
}

Each app record:

{
  "id": "uuid",
  "name": "string",
  "ios_url": "string",
  "android_url": "string",
  "icon_url": "string"
}

Dev Commands

# Server (from /server)
node index.js
# or if package.json has start script:
npm start

# Client dev server (from /client)
npm run dev

# Client production build (from /client)
npm run build
# Output goes to /public (Express static root)