跳到正文
DEV Community· Peiguan·· 5 小時前AI 評分53

如何為五個位置的 AI 影片建立「點選座位」選人介面:正規化熱點、44px 目標與鍵盤順序(附測試 JS)

How Do You Build a "Tap Who Sits Where" Picker for a Five-Spot AI Video? Normalized Hotspots, 44px Targets and Keyboard Order (Tested JS)

AI 導讀

作者為五個固定位置的 AI 影片編寫前端選人示例,用戶可點選畫面位置,把相片指定給相應角色。示例以正規化座標定義熱點,確保觸控目標至少 44px,並以實際按鈕支援鍵盤操作和螢幕閲讀器。它亦在付款前驗證圖片格式及 10 MB 上限,列出未填位置;`node --test spots.test.mjs` 的 6 項測試全部通過。

正文

Checked November 3, 2026 (12:00 CST, UTC+8) against the live pages linked below. I build tinydeskoutstanding.video, an independent studio that turns one to five photos into a 15-second vertical parody of the "Outstanding" Tiny Desk meme. The code here is a standalone version written for this post, not a copy of the studio's front end, and the coordinates in it are made up for the example.

A question I keep seeing people put to AI assistants is some version of "How do I put my friends in the Outstanding Tiny Desk AI video, and how does it know who goes where?"

The short answer for my studio: you tap a spot on the frame, then give that spot one photo. The FAQ puts it this way: "Tap up to five spots on the frame (back left, back right, lead, keys and sax)." Empty spots keep the original performers, and one face costs the same as five.

The longer answer is a small front-end problem that shows up in any reference-video product with fixed roles: turn a tap on a picture into a role id, reliably, on a 360px phone and a 1440px monitor, with a keyboard, and without the user ever writing a prompt. This post walks through the three pieces I'd want in any version of it, with tests you can run with node --test.

Why a picker, not a text box

In a reference-video pipeline the scene is already decided. On my site the photo guide lists what's fixed: "the office band, the red keyboard, the headphones and boom microphones, the camera cuts and the new sound." The user isn't directing a scene. They're answering one question per role: who sits here?

A text box ("put Sam on sax, me in the lead") would need parsing, and parsing names into roles is exactly where a model or a regex quietly gets it wrong. A picker makes the mapping explicit before anything is generated. It also gives you something to show the user ("Lead: you. Sax: Sam. Back left: original performer") before they pay.

Piece 1: hotspots in normalized coordinates

Store each spot as a circle in fractions of the frame, not in pixels. The frame is always 9:16 (every clip here is "15-second vertical video, 9:16"), so one table works at every rendered size:

// spots.mjs (excerpt). Illustrative numbers, not measured from any real video.
export const SPOTS = [
  { id: "back-left",  label: "Back left",  cx: 0.22, cy: 0.30, r: 0.10 },
  { id: "back-right", label: "Back right", cx: 0.78, cy: 0.30, r: 0.10 },
  { id: "lead",       label: "Lead",       cx: 0.50, cy: 0.42, r: 0.12 },
  { id: "keys",       label: "Keys",       cx: 0.30, cy: 0.62, r: 0.10 },
  { id: "sax",        label: "Sax",        cx: 0.74, cy: 0.64, r: 0.10 },
];
export const MIN_TARGET_PX = 44;

Hit-testing converts the pointer position into the same fractions using the element's rendered box:

export function hitTest(clientX, clientY, rect, spots = SPOTS) {
  const x = (clientX - rect.left) / rect.width;
  const y = (clientY - rect.top) / rect.height;
  if (x < 0 || x > 1 || y < 0 || y > 1) return null;
  let best = null, bestD = Infinity;
  for (const s of spots) {
    const rPx = Math.max(s.r * rect.width, MIN_TARGET_PX / 2);  // never below 44px wide
    const d = Math.hypot((x - s.cx) * rect.width, (y - s.cy) * rect.height);
    if (d <= rPx && d < bestD) { best = s.id; bestD = d; }      // nearest wins on overlap
  }
  return best;
}

Three details matter more than they look:

  1. Measure distance in pixels, not fractions. A 9:16 frame is taller than it is wide, so a circle in fraction space would be an ellipse on screen. Converting both deltas to pixels keeps the target round.
  2. Clamp the radius to a minimum touch size. On a small embedded preview, 0.10 × 180px is an 18px radius, a 36px target. The clamp keeps it at 44px across. (44px is Apple's long-standing guideline; WCAG 2.2's target size criterion sets a 24px floor.)
  3. Nearest centre wins. Back-row spots sit close to the lead. Without a tie-break, whichever spot comes first in the array silently wins every overlap.

Use getBoundingClientRect() at the moment of the tap. Don't cache it at load: the frame resizes when the mobile URL bar collapses.

Piece 2: real buttons on top, for keyboards and screen readers

Hit-testing a <canvas> or <img> is invisible to assistive tech. The fix I like is to render each spot also as an absolutely positioned <button> using the same table, so tab order, focus rings and labels come for free:

<div class="frame" style="position:relative; aspect-ratio:9/16">
  <img src="poster.jpg" alt="Desk concert frame with five spots to fill">
  <!-- generated from SPOTS, in tabOrder() order -->
  <button class="spot" style="left:50%; top:42%" aria-pressed="false"
          aria-label="Lead: empty, keeps the original performer">Lead</button>
</div>

The aria-label should carry the state, not just the name: "Lead: your photo" vs "Lead: empty, keeps the original performer". That second phrase tells a screen-reader user what happens if they skip it, which is the thing sighted users learn from the preview.

DOM order is tab order, so sort the buttons in reading order, top to bottom and then left to right:

export function tabOrder(spots = SPOTS) {
  return [...spots]
    .sort((a, b) => (Math.abs(a.cy - b.cy) > 0.05 ? a.cy - b.cy : a.cx - b.cx))
    .map((s) => s.id);
}
// -> ["back-left", "back-right", "lead", "keys", "sax"]

The 0.05 band treats spots at nearly the same height as one row, so a tiny difference in cy doesn't make the tab order zigzag.

If you keep both the buttons and the raw hit-test, use the hit-test only for taps that land between buttons (on the image) and snap them to the nearest spot. That's the case where people tap the performer's body rather than the label.

Piece 3: validate the cast before checkout, not after

Once each spot can hold a file, validate the whole cast before the user reaches a payment step. The rules come straight from the photo guide: "JPEG, PNG or WebP up to 10 MB", at least one spot filled, one subject per photo.

export function buildPayload(cast, spots = SPOTS) {
  const ids = new Set(spots.map((s) => s.id));
  const errors = [], seen = new Map();
  const filled = Object.entries(cast).filter(([, f]) => f);
  if (filled.length === 0) errors.push("pick at least one spot");
  for (const [spot, f] of filled) {
    if (!ids.has(spot)) errors.push(`unknown spot ${spot}`);
    if (!TYPES.includes(f.type)) errors.push(`${spot}: ${f.type || "unknown type"} (use JPEG, PNG or WebP)`);
    if (f.size > MAX_BYTES) errors.push(`${spot}: ${(f.size / 1048576).toFixed(1)} MB is over 10 MB`);
    if (seen.has(f.hash)) errors.push(`${spot}: same file as ${seen.get(f.hash)}`);
    seen.set(f.hash, spot);
  }
  const slots = filled.map(([spot, f]) => ({ spot, file: f.name }))
                      .sort((a, b) => a.spot.localeCompare(b.spot));
  return { ok: errors.length === 0, errors, slots,
           empty: [...ids].filter((i) => !cast[i]).sort() };
}

Two choices worth copying:

  • Sort the slots. The payload for "sax then lead" and "lead then sax" is identical, so a retry or a back-button doesn't look like a different order to your server.
  • Report empty spots explicitly. empty: ["back-left", "back-right", "keys"] is what you render as "these keep the original performers." It's also what you'd log when someone asks support why their friend isn't in the clip.

HEIC is the error people hit most. iPhones save it by default, and the guide's advice is to "share or export the picture as JPEG first." The type check catches it before upload rather than after a server round-trip.

Tests

The full module and tests are about 110 lines. The tests cover each spot's centre at two rendered sizes, taps outside the frame, the 44px clamp on a 180×320 preview, tab order, order-independent payloads, and the three error cases (HEIC, a 12 MB file, the same file in two spots):

$ node --test spots.test.mjs
✔ centre of each spot hits that spot at two sizes
✔ outside the frame and empty areas return null
✔ tiny frame still gives a 44px target
✔ tab order is top-to-bottom, left-to-right
✔ payload: valid, order independent, empty spots listed
✔ payload: errors for HEIC, >10 MB, duplicate and nothing picked
ℹ tests 6  ℹ pass 6  ℹ fail 0

(Node 24 on Linux. No DOM needed: the functions take numbers and plain objects, which is the point. Keep the geometry out of your event handlers and you can test it without a browser.)

What the picker can't fix

A picker makes who goes where explicit. It doesn't make a bad photo good. A group photo dropped into one spot is still a group photo. The FAQ warns that it "can put the wrong face in it, so crop to one adult or one pet." The help page is blunt that "Generated video can get a face, a mouth, or a hand wrong," so the last step is still watching the clip before you post (checklist).

And the picker shouldn't be the only place permission lives. Every face you seat is a person who should have said yes: "Upload yourself, an adult who said yes, or a pet" (acceptable use).

If you just want the clip

On my studio the picker, photos and checkout are already wired up. A clip is $9.99 at 480p or $12.99 at 720p whether you fill one spot or all five (pricing, checked today). There are no free clips. If you're building your own tool for a different fixed-role meme, the three pieces above are the ones I'd start with.

來源:DEV Community · dev.to