feat(studio): give an agent eyes with studio_frame - #3516
Conversation
Adds `studio_select` and `studio_seek`, so an agent and the human are looking at the same element and the same instant. Selecting reveals the inspector, exactly as a click does, which is what makes the agent's move visible. Selection is shared state, not a per-call argument, and that is forced rather than chosen. Most of Studio's edit handlers read the ambient React selection, and `applyDomSelection` only schedules a state update, so selecting and committing inside ONE call would write to whatever was selected before. Two tool calls are separated by a render, so the contract is select first, then act. That is also how a human works: click, then type. `studio_seek` uses `requestSeek`, not `setCurrentTime`. The latter only moves the timeline's displayed number and leaves the composition where it was. Two things the tools refuse to fake: Seek does not clamp. `seek()` already clamps against the adapter's duration, which can differ from the store's, and clamping again would give that invariant two owners that can disagree. The tool reports where the playhead actually landed instead, read back afterwards. `requestSeek` is fire-and-forget, so it cannot report that no adapter was mounted to receive it. The tool compares the playhead before and after and fails rather than claiming a seek that never happened. Select separates three failures that a single message would have merged: the preview is not mounted yet (wait), no element matches the handle (re-read), and the element cannot be selected (try a neighbour). The agent's next move differs for each, so collapsing them would cost it a round trip or a retry loop.
Renders the composition to a PNG at a given time and returns the URL. This is what turns the tool set from a remote control into a loop: author a change, capture the instant it affects, look, adjust. No agent can judge motion from source, because "what does this look like at 2.4 seconds" is not a question a file answers. Reuses Studio's existing capture endpoint via `buildFrameCaptureUrl` rather than inventing a second one. Two things this does not fake: It reports the time the playhead LANDED on, not the time requested. The player clamps, so those differ at the ends, and attaching the wrong time to a frame is how an agent draws a confident wrong conclusion about motion. It waits before capturing, by default 150ms. The frame is rendered from the file on disk, and the render cache is cleared by a file watcher with a 40ms write-stability threshold, so a capture that beats the watcher renders the PRE-edit composition. That exact staleness was a real bug here once. An agent reading a stale frame as "my edit failed" would thrash, so the wait is on by default, `settleMs` makes it tunable, and the tool description names the failure rather than leaving it to be rediscovered. It probes with HEAD before returning, so a URL that 404s comes back as a failure with a hint instead of as a link the agent cannot render.
68a32e9 to
f4e938f
Compare
d7ba77c to
57c9bb0
Compare
vanceingalls
left a comment
There was a problem hiding this comment.
Approve — the design does the work here.
- Return-time = landed-time is the right contract.
urlandtimeare built from the samereadPlayhead()after the seek/settle, so they can never disagree. The tool description names the failure mode ("attaching the wrong time to a frame is how an agent draws a confident wrong conclusion about motion"), and the clamping test inframeTools.test.tspins it. This is the kind of invariant that only survives if it is asserted; here it is. - Settle-before-probe has a call-order test.
expect(order).toEqual(["wait:150", "probe"])would fail if the settle silently became a no-op — most codebases test that the settle happens, not that it happens first. Given the whole point is masking the file-watcher window, that ordering is the load-bearing bit. { readOnlyHint: true, untrustedContentHint: true }on the tool annotations. The return is a URL a model will dereference into pixels. Flagging the payload as untrusted content is the right defensive shape and matches the trust boundary the tool actually crosses.
One small observation, not a blocker: unlike studio_seek in #3515, studio_frame does not verify the seek landed. A silently-failed seek (no adapter mounted) would return time: <pre-seek playhead> and the agent would likely read that as "player clamped" rather than "seek never fired." Cheap to add — compare readPlayhead() before and after wait() when input.time !== undefined, and fold into the failure surface — but happy to defer to a follow-up if you would rather keep this PR focused.
Reviewed head 57c9bb0
Review by Via
The base branch was changed.
…3517) Everything about one element in one call: resolved styles, text fields, box, data attributes, GSAP animations, and what the element will and will not accept. The point is to prevent a failed write rather than to satisfy curiosity. `can.reasonIfDisabled` is passed through verbatim from Studio's own capabilities, so an agent that reads first should never attempt an edit the element would refuse. Three things it refuses to get wrong: Animations are reported ONLY for the current selection, because that is the only element Studio parses them for. Attributing them to any other element would be reporting the wrong element's motion, which is worse than reporting none. When a handle names something else the field is empty and `animationEditingBlocked` says why. `animationEditingBlocked` also carries the two states where animation editing is off entirely, multiple timelines and an unsupported timeline pattern. Both live on the selection context. Learning them from a read costs one call; learning them from a failed write costs a retry loop. Inspecting a handle does NOT change what is selected. It is a read, and stealing the human's selection would be a side effect they did not ask for. There is a test asserting `applySelection` is never called. Nothing selected and no handle given is a failure, not an empty result. An empty result would assert "this element has nothing", which is a different and false claim.
Resolve conflicts from squash-merged #3515 (selection tools). Branch retains frame + inspect tools from the stack. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
🟢 hyperframes#3516 — feat(studio): give an agent eyes with studio_frame — at 0fffa662 — leaving as COMMENT (LGTM from my side, no blockers).
CI (green on latest attempt, 0fffa662)
27 SUCCESS / 10 SKIPPED / 0 FAILURE on the attempt that completed 2026-08-30 05:23–05:38. The 8 ruleset-required contexts all landed green on this attempt: Lint, Format, Typecheck, Build, Producer: unit tests, Producer: integration tests, Test, Studio: load smoke. Also green: Studio: timeline viewport gate, SDK: unit + contract + smoke, Test: runtime contract, Render on windows-latest, Tests on windows-latest, Preview parity, preview-regression, regression, player-perf, CodeQL (javascript-typescript / python / actions), File size check, Fallow audit, Smoke: global install, CLI smoke (required), Semantic PR title, CodeQL, WIP. Standard qualifier for this repo: dismiss_stale_reviews_on_push=false + require_last_push_approval=true, so a subsequent push needs a fresh pass; per-name status can hide a stale cancelled attempt.
Peer signals at HEAD
None at 0fffa662. Via's approval at 57c9bb0d was auto-dismissed when the branch rebased after #3515 merged — its non-blocker (seek-landing not verified) carries over (see note 2).
Traced claims (verified at source)
- Composition-boundary reuse is real, not surface-level.
studioFrameinframeTools.tscallsbuildFrameCaptureUrlinutils/frameCapture.ts, which builds the same/api/projects/:id/thumbnail/*URL thatCompositionThumbnail.tsxinplayer/components/andstudioSelectionSnapshot.tsinutils/build. Server-side,api.get("/projects/:id/thumbnail/*", ...)inpackages/studio-server/src/routes/thumbnail.tshandles both, gated by a single-slotthumbnailGenerationCoordinator(concurrency=1) shared with the human-UI thumbnail path. Same worker, same mutex, same cache dir — the reuse claim is not aspirational. - "Read-only" is honest from the client-visible surface.
annotations: { readOnlyHint: true, untrustedContentHint: true }on the tool inuseStudioAgentTools.ts. No client state is mutated — no forced re-mount, no selection change, no playhead motion unlessinput.timeis passed (in which caserequestSeekis the agent's own edit, not a hidden side effect). The server writes into.thumbnails/on cache miss, but that's a disk cache keyed on content-hash of the composition HTML — not composition state — and it's the same cache the human-UI populates. Untrusted-content annotation is the right defensive shape for a URL a model will dereference into pixels. - HEAD probe works. Hono's
#dispatchinhono/dist/hono-base.jsexplicitly re-dispatchesHEADthrough the registered GET handler and strips the body (new Response(null, await GET(...))), soprobeFramewithmethod: "HEAD"populates the.thumbnails/cache on miss and the agent's follow-upfetch(url)hits cache — the cache-busterv=Date.now()is the same in both calls (sameurlstring). Not double-render, contra a possible reading. - Return-time = landed-time. URL and
timeare both built from the samereadPlayhead()after the settle inframeTools.ts, so they can never disagree. Clamping test inframeTools.test.tspins it explicitly (t=10.000when player clampedtime: 999). - Settle-before-probe ordering pinned by call-order test. The
expect(order).toEqual(["wait:150", "probe"])assertion inframeTools.test.tswould fail if the settle silently became a no-op — most repos assert the settle happens, not that it happens first. Given the whole point is masking the file-watcher's write-stability window, ordering is the load-bearing bit and it's asserted. studio_inspecthas no TOCTOU ongetCurrentSelection.studioInspectininspectTools.tscapturesdeps.getCurrentSelection()once at the top of the function and threads it through — the reference-equality checkcurrent?.element === elementat the bottom uses the same captured value. Race between "human clicks another element" and "tool reads selection" resolves cleanly to whatever the tool saw at entry.
Non-blocking notes
-
PR body scope:
studio_inspectisn't mentioned. The description is entirely aboutstudio_frame, but the diff also addsstudioInspectininspectTools.ts(208 lines) + its test file (197 lines) + wiring inStudioAgentTools.tsxanduseStudioAgentTools.ts— a second undisclosed agent-tool. Reviewers who read the body will miss it; log/analytics readers keying onstudio_framein the tool list will seestudio_inspectland unannounced. Worth naming in the description, or splitting; not worth reworking merge on. -
Seek-landing still not verified (carried from Via's dismissed R1). In
studioFrame—deps.requestSeek(input.time)→wait(settleMs)→readPlayhead(). The tool is honest about "return the time actually captured," so a silently-failed seek (no adapter mounted) or a mid-settle human seek producestime: <playhead's-actual-value>and an agent reading motion draws the wrong conclusion. The failure mode "seek never fired" is indistinguishable from "player clamped to end" through this surface. Cheap defense: capturereadPlayhead().currentTimebeforerequestSeek, compare afterwait; ifinput.time !== undefinedand the delta is neither≈ input.timenor plausibly-clamped-to-duration, surface asfailedwith a hint.studio_seekin #3515 has a landing-verify pattern worth mirroring here. Worth-fixing on the same beat as #3515, defer-to-followup fine. -
Cache-buster
v=Date.now()fights the server-side content-hash cache.buildFrameCaptureUrlinframeCapture.tsalways setsv=<Date.now()>, and the server'scacheKeyinthumbnail.tsfoldsurlVersionKeyinto the cache key. But the samecacheKeyalready includessourceKey(SHA-1 of the composition HTML read viareadFileSync),manualEditsKey, andmotionKey— all read from the actual file on each request. Sovadds nothing to invalidation correctness (server always sees the fresh file bytes) but guarantees a cache miss on every repeated call. Twostudio_framecalls on unchanged content = two full Puppeteer renders, gated only by the concurrency=1 mutex. Not a blocker (mutex bounds the damage), but a resource smell — worth droppingvon thestudio_framepath, or trusting the content hash across the board. Nit-plus. -
studio_inspectempty-animations ambiguity. Indescribe()ininspectTools.ts, whenisCurrentSelection === false,animations: []andanimationEditingBlocked: "animations are only readable for the current selection". That combination is correct, but the empty array reads as "this element has no animations" to a superficial parse — same shape as a selection with genuinely zero animations. An agent that keys onanimations.length === 0first will miss the block reason. Consideranimations: nullwhen unavailable so a null check disambiguates authoritative-empty from unknown. Design nit. -
What's NOT in the diff. No test for
studio_frameunder a mid-settle human seek (thereadPlayheadTOCTOU class); no integration test at the studio-server boundary assertingprobeFrame's HEAD hits the same cache key the subsequent GET reads (protects against a future framework swap breaking Hono's HEAD-through-GET dispatch — see note 3 of traced claims); no distinguishing failure surface between "Chrome browser missing" (server logs it, returns 500) and "renderer crashed" (also 500) — the tool'sreasonis uniform"the renderer returned 500". None blocking, but each would sharpen the failure story an agent has to reason from.
Summary. The design does the work here: return-time = landed-time is the right contract, settle-before-probe is asserted at the order-of-operations level, readOnlyHint + untrustedContentHint correctly frame the trust boundary, and the endpoint-reuse claim holds up under a trace (same Puppeteer path, same coordinator, same cache dir). Cache-buster and seek-landing-verify are the two threads I'd pull on a follow-up; the PR-body scope gap and animations: null are cheap paperwork. Ready to ship from where I sit.
— Review by Rames D Jusso
What
studio_framerenders the composition to a PNG at a given time and returns its URL.Stacked on #3515.
Why
This is what turns the tool set from a remote control into a loop: author a change, capture the instant it affects, look, adjust.
No agent can judge motion from source. "What does this look like at 2.4 seconds" is not a question a file answers, and it is the question that matters when the thing you are building is a video.
How
Reuses Studio's existing capture endpoint through
buildFrameCaptureUrlrather than inventing a second one. The server renders with Puppeteer, so the frame reflects the file on disk.Two things the tool refuses to fake:
It reports the time the playhead LANDED on, not the time requested. The player clamps, so those differ at the ends, and attaching the wrong time to a frame is how an agent draws a confident wrong conclusion about motion.
It waits before capturing, 150ms by default. The render cache is cleared by a file watcher with a 40ms write-stability threshold, so a capture that beats the watcher renders the pre-edit composition. That exact staleness was a real bug in this repo once, and the fix is a watcher, not a synchronous invalidation, so the window still exists. An agent reading a stale frame as "my edit failed" would thrash. The wait is on by default,
settleMsmakes it tunable, and the tool description names the failure mode rather than leaving it to be rediscovered.It probes with HEAD before returning, so a URL that 404s comes back as a failure with a hint instead of as a link the agent cannot render.
Test plan
12 tests in
frameTools.test.ts:Full package suite 4555 passing across 410 files.
bunx tsc --noEmitclean.bunx fallow audit --fail-on-issuesclean.Not yet exercised against a real renderer; that is part of the end-to-end capture in the final unit.