Trailer Vision build log — Monday, April 13, 2026 (updated through April 16, 2026)
channelId). The same movie can legitimately carry differentuser and predicted ratings in different channels (separate history entries); the per-channel and “All” lists only show the slice that belongs to that island.Each round: LLM selects a movie/TV title and makes a hidden prediction. User rates with half-star red (seen) or blue (unseen interest) stars on a 1–5 scale (mapped to the model's 0–100 space). Prediction error feeds the chart. A rolling-window accuracy chart (last 5 decisions) tracks improvement over time. No title is ever shown twice — enforced both in the prompt and client-side.
The original number input felt like filling out a form. You asked for a slider, which turned out to feel much more like a judgment — drag to where it feels right, hit Submit. The live large-number display next to the label replaced the need for any typed value.
After submitting a rating, the original design paused to show a result screen with a Next button. You pointed out the extra click was friction: show the result info below the chart instead, and immediately load the next movie. This made the loop feel like a card swipe rather than a form submission.
After watching someone use the app, it was clear the two halves of the card were ambiguous. The rating slider is now inside a box labeled "I've seen it — rate it" and the two skip buttons are in a separate box labeled "Haven't seen it". "Want to watch" is green-tinted to signal it's a save action.
You asked for the main actors, a plot summary, director, release year, and a poster on each card. The LLM returns all metadata as structured JSON; the Serper image search API fetches a poster (year included in the query to avoid getting a different version). The card became a proper movie entry rather than a bare title.
You wanted a professional rating to show alongside yours after the reveal. The LLM returns the RT Tomatometer from its training knowledge (e.g. "91%"). A 🍅 / 💀 badge renders on the card, giving immediate external context for how your taste compares to critics.
You identified two distinct signals: films you want to see (LLM got your taste right — a positive outcome, score 85/100) vs films you have no interest in (LLM missed entirely — score 20/100). "Want to watch" adds to the watchlist. "Not interested" is discarded. Both are excluded from future picks.
Titles marked "Want to watch" are saved to a persistent watchlist with full metadata. On save, the selected LLM is asked which streaming services carry the title in the US; results appear as blue pills on the dedicated /watchlist page and in the Watchlist tab on /ratings.
A sticky nav bar at the top of every page (rendered in app/layout.tsx) links to App, Watchlist (/watchlist — global list), Channels, Settings, and Help. Help links to Dev Journal and Prompt History. The active page is highlighted. Ratings (/ratings) is not in the bar but lists Seen, Watchlist, and Not interested tabs in one place.
callLLM() was refactored from a single prompt argument to (systemPrompt, userMessage). The stable instruction block goes in the system prompt with cache_control: { type: 'ephemeral' } and the anthropic-beta: prompt-caching-2024-07-31 header — Anthropic only re-bills the system prompt on cache miss. OpenAI caches prefixes ≥1024 tokens automatically. Gemini uses the systemInstruction field. The split also makes the per-request payload smaller.
Each LLM request asks for 5 titles. Up to 3 replenishes run concurrently (daisy-chained). HIGH_WATER_MARK = 6 caps how many cards sit buffered ahead so new ratings influence upcoming picks sooner; tradeoff is a slightly higher chance of a short wait if the queue drains. Yield per batch is tracked for diagnostics; the client does not currently auto-scale request count from yield.
While the next title loads, the current card dims to 45% opacity so the layout doesn't jump. A fixed pill at the bottom reads "LLM is thinking…" with bouncing dots, visible regardless of scroll position. When the response arrives, the card fades out, content swaps, then fades back in. With the prefetch queue in place, the indicator almost never appears after the first load.
After adding the prefetch queue, advancing to the next card became nearly instant — the modal that showed your score / AI score / error flashed for only a few milliseconds before disappearing, which felt worse than nothing. Removed it. The last result is still shown inline in the chart panel.
On small screens the movie card now stacks vertically: the poster becomes a full-width banner image (cropped to a fixed height) above the metadata and rating controls. The nav bar hides the brand name on xs to give the four links room. Long movie titles in the recent ratings list truncate with ellipsis rather than overflowing.
A segmented control restricts picks to Movies, TV Series, or both. If the current card doesn't match a newly selected type, a fresh fetch fires immediately. A second control lists every LLM whose API key is present in .env (DeepSeek, Claude, GPT-4o, Gemini); the selection is live.
The poster renders at w-72 on the card. Clicking it opens a full-screen lightbox (black overlay, Escape to close). Page max-width is max-w-3xl to give the poster room without making the text too wide.
As ratings accumulate, sending the full history to the LLM overflows the context budget. Instead, the server selects the most informative subset: ratings where |user score − RT score| is largest (divergence from critic consensus reveals the most about taste per token), plus the most recent entries for freshness. The LLM still receives the full exclusion count but not the full title lists.
RatingEntry now stores rtScore. The server curates two extra taste signals beyond raw ratings: want-to-watch items with low RT scores (user liked what critics didn't), and not-interested items with high RT scores (user dismissed what critics loved). Both are stored in localStorage and sent with each request. These are the most informative signals the LLM can receive about how this user's taste differs from the mainstream.
A separate /api/taste-summary endpoint generates a 2–4 sentence taste profile addressed directly to the viewer in second person ('You tend to prefer…'). It runs in the background after the first rating and every 5 thereafter — decoupled from the movie batch so it doesn't slow down recommendations. The profile is stored in localStorage and displayed as a card with a purple left border between the accuracy chart and the movie card.
Without explicit direction the LLM defaults to the same ~300 culturally visible titles. The fix: each batch request carries a rotating 'diversity lens' — a hard constraint like 'films from the 1970s' or 'South Korean cinema' or 'overlooked gems with low name recognition'. 24 lenses cycle through decades, world regions, and genres. With 3 concurrent batches running, three different corners of cinema are explored simultaneously. This unlocked the full breadth of the LLM's knowledge.
The old approach triggered replenishment only when the queue dropped below a threshold. If LLM latency spiked or yield was low, the queue drained to empty and the user waited. The new approach daisy-chains: each completed batch immediately starts another if the queue is below the high-water mark (6 items) and a slot is free. Up to 3 concurrent fetches run continuously. A zero-yield streak counter (resets on any user action) stops the chain after 3 consecutive zero-yield batches to avoid an infinite loop when the LLM is stuck.
A race condition could allow a title to sit in the prefetch queue and then be rated or skipped before it was displayed. The fetchNext pop loop now checks each candidate against the live excluded set (history + skipped + watchlist) and silently discards any stale entry before showing it.
Any row in the All Ratings list or the Not Interested list is now clickable. Clicking a rated title removes it from history and loads it as the current card so you can change your score. Clicking a not-interested title removes it from the skipped and not-interested lists and loads it as the current card — you can then rate it or add it to the watchlist. Deleting a title from the Watchlist page now also moves it to Not Interested (adds to skipped + not-interested) instead of just discarding it.
When TMDB returns a YouTube trailer key for a title, the card shows the trailer instead of the poster. The trailer autoplays and a progress bar reflects watch time. A Trailers/Posters toggle in the controls lets you switch layouts. Volume is restored between cards (session-only, using a module-level variable).
Red stars (Seen it) submit in one tap when you’ve seen the film. If you haven’t, tap Not yet first, then blue stars (Interest); I have seen it switches back. Trailer and poster layouts share the same controls: one horizontal row with compact StarRow, Not yet, and Next (with horizontal scroll on very narrow screens). Blue 4–5 stars add to the global watchlist; blue 1–3 mark not interested. Trailer embed uses the YouTube IFrame API; no live watch-% auto-rating in the current build.
A free-text input below the controls steers every batch: 'Japanese cinema', 'slow-burn thrillers', '90s Hong Kong action'. When a request is set it replaces the diversity lens entirely and is phrased as a hard constraint — every item returned must match it. The prefetch queue is flushed on change (debounced 600ms) so the next card comes from a batch that already knew about the request.
Each channel is its own recommendation island: separate prefetch queue, separate activeChannel payload to the LLM, and separate history rows (channelId). The same title can exist more than once in movie-recs-history with different channelIds and different user vs predicted scores — e.g. loving a film in a narrow-genre channel but rating it lower in “All”. Users define channels on /channels; active channel is stored separately; queue key movie-recs-prefetch-queue:{id}. factory-channels.json seeds on first visit; Settings and the home row offer merge of starter channels without wiping data. Channel pills wrap; stronger labels; non-All chips get hover × + confirm to delete.
The /channels right panel is Channel history: seen rows match /ratings → Seen; unseen rows come from movie-recs-unseen-interest-log with pills (Added / Not on list vs Not interested). Add to watchlist promotes titles not already on the global watchlist that meet the minimum blue stars — high-interest skips and want rows you removed from the list — then strips skips from skipped + not-interested and runs streaming lookup.
The /ratings “Seen” tab and seen rows on /channels no longer show the Rotten Tomatoes badge on each row. Instead they show the user’s red stars plus a signed half-star delta (user rating minus predicted rating), e.g. +1.5 or −2, color-coded vs zero. A sort bar toggles between sorting by the user’s stars vs. by delta (vs predicted). Shared formatting lives in app/lib/ratingDelta.ts. On the “All” channel, seen history includes legacy rows with no channelId as well as channelId === "all".
When the card is in poster layout because there is no embedded trailer (no trailerKey), the title links to a YouTube search in a new tab (title + year + “movie trailer” / “TV series trailer”) so users can still find footage quickly.
The “Rate it” / “Level of interest” labels beside the star rows on the main card use larger, darker type (text-sm font-semibold text-zinc-800) so they are not lost against the background.
On first render the chart produced NaN for SVG attributes. Root causes: dividing by zero on a single data point, and stale localStorage entries with no error field. Fixed by filtering invalid entries, centering the single-point case, and rendering a <circle> for one data point.
LLMs occasionally ignore the exclusion list. The client now maintains a definitive excluded set (all rated + all skipped, case-insensitive) and retries up to 8 times, accumulating each duplicate into extraSkip. The card is never set unless a non-duplicate is confirmed.
The LLM returned predicted_rating (snake_case) but the TypeScript interface expected predictedRating (camelCase). A type assertion silently accepted the wrong shape. Fixed by explicitly mapping the raw field after parsing.
The Serper image search query did not include the year, so for remade titles the wrong version's poster appeared. Fixed by appending the release year to the query.
Serper occasionally returns http:// image URLs. Browsers block mixed content on HTTPS pages. Fixed by upgrading all poster URLs to https:// before returning them. A missing SERPER_API_KEY env var on Vercel was also a factor.
Claude sometimes thinks out loud and emits reasoning text between two JSON objects. A greedy regex captured the reasoning. Fixed with a brace-depth walker that collects every complete top-level object and takes the last one.
Shows accuracy (100 − error) so up is always better. Rated titles = blue bars. “Want to watch” = green diamonds at y=85. “Not interested” = red diamonds at y=20. Indigo line = rolling average over last 5 decisions (not cumulative). Reference lines mark the two thresholds. Hand-rolled SVG, no library.
Framework: Next.js 16 (App Router, Turbopack)
LLMs: DeepSeek, Claude, GPT-4o, Gemini — selectable at runtime
Poster search: Serper Images API
Persistence: localStorage
Styling: Tailwind CSS v4
Chart: Hand-rolled SVG
A complete specification you can paste into any coding agent to rebuild a near-identical app.