* ci(studio): run the pull request edit accuracy slice in 10 shards
* ci(studio): run the pull request edit accuracy slice in 15 shards
* ci(studio): pack edit accuracy shards by measured case time so they finish together
* ci(studio): load shard timings with require so happy-dom tests can import the grid
* ci(studio): the slow runner stop judges runner speed, not how long a shard's first cases are
* ci(studio): keep the slow runner comment on one line under 120 characters
* feat(studio): a preview reload that gives up on the runtime says how far the page got
* refactor(studio): read the reload's page phase in one place, before the frame is let go
* test(studio): every reload page phase is covered, and the hook gains no branch for it
* refactor(studio): the reload page phase reads the window only once the frame has a document
A composition with no GSAP timeline was treated as zero length after an edit, which dropped preview audio, and an edit made during playback left the transport stopped. The runtime now keeps the declared length when it rebinds, and the soft reload resumes a transport that was playing.
* fix(studio): an undo or redo counts as a preview change from its key, not from its reload
* refactor(studio): count a history step from its key with the page flag on noteIdle
* fix(studio): a tab's save keeps the newer video version another tab recorded
The thumbnail index merge let this tab's ETags win for every source, including
ones it only read at startup, so another tab's move to a new version was undone
by this tab's next save: the next session validated the old ETag, missed the
disk and decoded once more. Now another tab's ETag wins except for sources this
tab recorded a new version of since its last save.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): the ETag merge says whose moves it keeps in its names, not comments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(studio): the stored-thumbnail reset forgets this tab's drops and moves too
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* perf(studio): timeline thumbnails come back from disk in the next session
Reopening a project re-read every video's index and re-decoded every strip:
the frame cache and the source facts lived in memory, keyed by the session.
Now each source's facts (aspect, duration) and every decoded frame are kept
in Cache Storage under the file's ETag, the version the server says it is. A
new session checks each source once with a conditional one-byte read: a 304
means the stored frames are still that file's, and a strip they cover paints
without opening a reader. A new ETag drops what was stored for the old one.
Sources are dropped whole, least recently used first, past 256 MiB. A store
that is missing or fails (private window, quota) warns once and the strip
decodes as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): a reopened project checks its videos again, and the disk index counts a frame once
The stored version a session validated was kept per video for the whole page,
so reopening a project after one of its videos was replaced at the same path
painted the old file's frames until a full reload. It is now kept per video
and project open (its contentVersion): each open re-validates once.
The index now counts a frame stored again under its name once, and merges
with what other tabs saved before writing, keeping caches this tab dropped
out, so the 256 MiB budget holds across tabs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(core): a preview media load that fails is reported, not swallowed
* fix(core): an audio fed by source children reports its failed load; the shape lives in the protocol
* fix(core): the audio load watcher finds elements from any realm
* build(core): regenerate the inline position-edits render script
* fix(studio): a timeline edit on a clip the file lacks says how the clip was looked up
* fix(studio): name only the lookups a missing clip was really found by
* refactor(studio): split the missing-clip lookup words into small helpers
* refactor(studio): fit the missing-clip lookup words under the no-visible-change size
* fix(studio): a thumbnail reader forgets a start's failures after a minute
A pooled thumbnail reader lives for as long as clips keep asking it, and each start
byte has a retry budget of two. That budget never reset, so two transient failures at
one start early in a session made a third, twenty minutes later after the cache evicted
it, give up at once: the reader retired and the clip showed an error.
A start's failures now count for a minute. One bout of retries (8 s stalls, 0.5 s and
1 s waits) fits inside it, so a start that keeps failing still gives up; one met again
much later starts afresh. Clearing the count when a read from that start delivers bytes
would not do: a server that ignores Range sends bytes 0 onward again before it breaks at
the same byte, and would retry forever.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): the start-failure window says what it is in its name, not a comment
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): a start's retry budget resets when a read from it ends cleanly, not after a minute
The one-minute window only held while each attempt took under a minute. An attempt has
no limit before its answer, and a body that keeps sending never stalls, so a server that
ignores Range, sends slowly and breaks past a minute re-downloaded from 0 forever.
A start's failures are now forgotten when a read from it ends cleanly: its body finishes,
or mediabunny cancels it at its target. A server that breaks before the target never ends
a read cleanly, so its budget still runs out, however slowly it fails.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): a read mediabunny stops at its target ends cleanly and resets its start's budget
mediabunny's UrlSource never cancels the body: once a read reaches its target, _runWorker
aborts the signal it passed in, and the pending pull fails with an AbortError. So the reset
on cancel never fired, and any open bytes=N- read stopped at its target kept its start's
failures.
The request now listens on its own signal, which follows mediabunny's and is unlinked once
the read ends: an abort while live counts as a clean end only when mediabunny's signal is
the one aborted, never a stall or a dispose.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): a slow thumbnail job keeps a reader that other clips still decode from
A job that timed out judged the reader by its own progress only. A poster job that got
an already-open reader while a sibling was decoding on it, and took more than its
budget to reach its one frame (a slow link, Chrome's six connections held), logged a
timeout and retired a healthy reader, so the next clip re-read the index.
The pooled reader now counts the frames every job decodes from it, and a timed-out job
retires it only when no frame came from it while the job held it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): the reader's frame count says whose frames it counts in its name, not a comment
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(core): name why a choppy preview window dropped frames
The playback reading now says how much of the window was frames skipped
and how much was a playing video waiting for data, and carries the p50
and p95 gap between the runtime's ticks, so a report can tell a slow
compositor from a stalled decoder.
* chore(core): drop comments the ratchet counts
* perf(studio): keep released timeline video frames for scroll-back
A video thumbnail frame was revoked the moment no mounted strip held it, so
scrolling back past the scheduler's cache decoded every clip again: 80 decode
passes for 40 clips on a 44-minute source, the whole first pass repeated.
Released frames now stay shared in a least-recently-released list capped by
the new sharedFrameCacheBytes budget (32 MiB of JPEG, about 11 frames for
each of a 473-clip episode's clips). Object URLs are revoked only on
eviction, and a frame any strip still shows is never evicted. Which frame a
strip shows is unchanged: the acceptance window is the same predicate.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(studio): open each video source once for all its timeline clips
Every clip's thumbnail job opened its own mediabunny Input on the source URL,
so 40 clips cut from one 44-minute source opened it 80 times, re-reading and
re-parsing its 1.44 MB index each time (behind a 304, at 13-30 ms an open).
Jobs now share one pooled reader per source: the Input, its primary video
track and the track's size and duration, reference-counted and disposed
thumbnailSourceIdleMs (10 s) after the last job ends, or at once when the
source's contentVersion changes. Each job keeps its own CanvasSink: the sink
reuses one pooled canvas and a job blanks the canvases it saw on release, so
sharing one across jobs would corrupt a neighbour's frames.
A failed open leaves the pool at once, and a job that fails for any reason but
cancellation retires its reader, so the next clip opens afresh. Retries are
still counted per reader, as mediaUrlSourceOptions has done since #6477: two
jobs reading through one reader cannot tell whose read failed, and retiring a
reader whose retries are spent keeps that budget per reader rather than letting
a spent count fail later clips at once. The pool also owns the one
import("mediabunny"), which the decoder now takes from the opened source.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(studio): start no timeline video decodes while the preview plays
A playing preview shares the machine's video decoder with timeline
thumbnails, and a long many-clip timeline queues hundreds of thumbnail decodes.
While the player store reports isPlaying, the scheduler now starts no new
visible or overscan video decode; a scrub's interaction request still starts,
decodes already running finish, and pausing drains the queue. Image,
composition and waveform work is unaffected.
The new thumbnailPlaybackHold module subscribes to the store's state rather
than wrapping setIsPlaying, so a reset or a project switch, which write
isPlaying: false directly, also release the held work. VideoThumbnail binds it
to the app's scheduler where video thumbnails load, which is also the path
HyperFrames Desktop runs (it embeds StudioApp, not Studio's main.tsx).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(studio): bound thumbnail range reads and their read cache
mediabunny reads a URL with open-ended `Range: bytes=N-` requests and cancels
each once it has the bytes it wanted, by when a local server has streamed
megabytes past them: about 4 MB sent for each thumbnail frame decoded. The
thumbnail reader's fetch now asks for at most 512 KiB from N; a shorter answer
than mediabunny wanted makes it ask again from where it stopped (its worker
resumes a capped response when the file size is known, which the first
206's Content-Range supplies). Closed ranges and the probe reader are untouched,
and so is Studio's playback, which does not read through mediaUrlSourceOptions.
Measured on two 44-minute 1080p sources, 40 clips each (a poster and a
4-frame strip per clip), against the same branch unbounded: bytes sent fall
13-22% one decode at a time and 33% two at a time; first-pass wall time falls
4-20%. The frames decoded are byte-identical. Most of what remains is real:
a strip slot whose keyframe is outside its window decodes from that keyframe
forward through its GOP.
A pooled reader also now caches at most 16 MiB of what it read, not
mediabunny's default 64 MiB, since it outlives its clip by up to
thumbnailSourceIdleMs. 8 MiB cost requests when a clip's poster and strip
decode together; 16 MiB measured the same as 64.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): bound thumbnail ranges only once Content-Range is readable
A thumbnail reader bounded even its first range request to 512 KiB. When the
answer's Content-Range cannot be read, mediabunny sizes the file as the request
start plus the answer's Content-Length, assuming the answer runs to the end of
the file, so a bounded first answer made a 10 MB file end at 512 KiB. Browsers
hide Content-Range cross-origin unless the server lists it in
Access-Control-Expose-Headers, and Studio reads remote https sources such as
HeyGen's gen-os-static bucket, which does not. With the header hidden, every
format failed: faststart and fragmented MP4 decoded 1 of 12 thumbnails, and
moov-at-end MP4, MOV and WebM none ("no decodable video track").
A reader now bounds its ranges only after an answer of its own was a 206 whose
Content-Range it could read and that starts where the request asked; its
first request stays open-ended, as before the bound. Any answer without such
a Content-Range, a whole-file 200 included, keeps that reader unbounded for
good. Same-origin readers, where Content-Range is readable, still bound.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): count a thumbnail reader's read failures consecutively
A reader's retry budget (two retries, from #6477) counted every failure over
the reader's life. Pooled, a reader serves every clip of its source, so the
third transient failure anywhere on the source failed a clip with no retry.
The count now resets whenever a read delivers bytes, so a long-lived reader
keeps the per-clip tolerance a reader had when each clip opened its own, and
three failures in a row still give up. Bytes, not an ok status: a body that
breaks on its first read must still use up the budget. A reader that gives up
is retired, as before, so the next clip opens a fresh one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): a read stalled mid-response no longer hangs its video source
The thumbnail job's signal reached only the source's open: keyframe lookups and
the decode loop ignore it, so a job whose response stalled mid-body never ended
and never released its pooled reader. The reader was never idle, so never
closed, and every later clip that needed the same bytes waited on the same
stalled read for the rest of the session. Before pooling, each clip had its own
reader, so only the stalled clip hung.
The scheduler's timeout now aborts with a TimeoutError reason. A job that times
out drops its reader at once; a job cancelled any other way that has still not
ended a load timeout later drops it then; a cancel the job answers, a scroll
away, leaves the reader pooled. Dropping disposes the reader whoever still uses
it, which fails their pending reads too.
Disposing a mediabunny Input does not end a read already waiting on a stalled
body (its orchestrator only flags the worker, which never wakes), so
mediaUrlSourceOptions gains cancelReads(), which aborts every fetch the reader
has in flight; the worker's read then fails and mediabunny rejects the reads
waiting on it. Measured in Chromium with a server that stalls one response:
the stalled clip settles at its timeout, and the same clip asked again, now
and after 11 s idle, decodes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): a clip's own failed result keeps its source's reader
A thumbnail job that failed for any reason but a cancel retired its pooled
reader, so one clip whose frames all failed to decode, or whose canvas would
not encode, made the next clip re-read and re-parse the source's index. Those
two failures are now a ClipResultError, which leaves the reader pooled; fetch,
demux and decoder failures still retire it.
Also pins four pool behaviours mutation testing found unguarded: a reader
closed after sitting idle has left the pool, so the next clip opens a live
one; a version change closes the old reader only once the job still decoding
on it ends; a job cancelled while the track is read stops the open there; and
a scroll-away cancel keeps the reader (the last landed with the stall fix).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): keep a thumbnail reader's retry budget per start byte
ff16aed98 reset a pooled reader's retry count whenever any read delivered
bytes. That broke the #6477 give-up in two ways, both measured with real
mediabunny:
- A server that ignores Range answers 200 from byte 0, so mediabunny resumes
at the bytes it got and is sent byte 0 again. With a body that breaks after
64 KiB, those bytes reset the count every time: 61 fetches in 30 s and the
read still pending.
- mediabunny reads with two workers. A healthy stream in one region kept
forgiving a region that always answered 503: 41 fetches in 20 s, pending.
Failures are now counted per start byte, with no reset. A resume after
progress asks for a new start and gets its own two retries; asking for the
same start again uses them up whatever else the reader reads meanwhile. The
start is the byte the answer began at: the asked start for a 206 or a failed
request, 0 for a 200. Traced for the 200 case: requests at 0, 65536 and 65536
are all keyed 0, so the third failure gives up. Both repros now give up after
3 fetches ("network TypeError after 2 retries", "HTTP 503 after 2 retries"),
and failures at different starts, one per clip, never add up.
The fetch and the answer's typing move into typedFetch and typedResponse, so
fetchFn stays under the complexity gate.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): fail a stalled thumbnail read on silence, not on a timeout
dbd820706 dropped a reader when the job using it timed out, but only once the
reader had opened: a timeout during the shared open released the job and left
the stalled open pooled, so every clip of that source joined it and timed out
in turn while clips kept arriving. Dropping on a timeout was also wrong when
bytes were still coming (a slow link's 25 s open threw its parsed index away),
and dropping a reader other clips were using failed them with "Media reads
cancelled", leaving them on "no preview" until they remounted.
A thumbnail read now fails only on silence. A request waiting 8 s for its
answer, or for its next bytes, is aborted and fails as a MediaFetchError
"stalled", which mediabunny retries like any read failure: a resume after
progress asks for a new start and continues, and three silent waits at one
start (8 + 0.5 + 8 + 1 + 8 = 25.5 s, inside the 30 s load timeout) give up,
which retires the reader. This covers the open and the decode alike, keeps a
reader whose bytes are still arriving, and never disposes a reader another
clip is using, so the job-level abandon and the scheduler's TimeoutError
reason are removed. Measured in Chromium against a stalling server:
- one-shot stall in the open: the clip decodes at 8.7 s (was: it and the next
clip both timed out at 30 s);
- one-shot stall mid-decode: decodes at 8.7 s (main: hangs);
- a source that never sends a byte: each clip fails typed at 25.5 s and the
next opens a fresh reader.
Each request gets its own AbortController, which follows mediabunny's signal
and the reader's cancel without AbortSignal.any; every way a request ends
unlinks it from the reader's long-lived cancel signal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(studio): pin the thumbnail behaviours mutation testing left unguarded
- A bounded answer's Content-Range keeps the reader bounded: a closed-range
pattern that matched only open ranges flipped it unbounded after the first
bounded request, undoing cf8f4554e.
- A frame that will not encode, through toBlob or OffscreenCanvas
convertToBlob, is the clip's failure and keeps its reader. The
convertToBlob rejection is now a ClipResultError too; it escaped as the
browser's own error and retired the reader.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(studio): pin the stall watchdog's guarantees about its own requests
Four guarantees of the thumbnail reader's per-request watchdog had no test;
each mutant that broke one passed the suite:
- a silent body fails as "stalled" and aborts the fetch under it, so the
stalled connection is not left open;
- every way a request ends unlinks it from mediabunny's signal: an answered
body, a rejected fetch, a 503, no body, a broken body, a cancel;
- a probe reader's body is never timed, however slow;
- a request made after the reader's reads were cancelled starts aborted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): time a thumbnail read's silence only once its answer has started
The 8 s stall rule also timed the wait for a response's headers, and that
wait includes Chrome's queue for one of six HTTP/1.1 connections per host.
Desktop serves all media from one loopback host, where Studio's event stream
and streaming previews hold connections. Measured in Chromium against a
healthy server: with five of six connections held and 20 cancel/re-ask bursts
over the 40-clip pass, no false stall; with all six held for 40 s, two clips
failed as "stalled". The watchdog now times only a started body.
A server that never answers is now caught at the job's own timeout. The
scheduler's timeout again aborts with a TimeoutError reason. A job that timed
out without progress in its whole run (its source never finished opening, or
it decoded no frame) failed because of its source: a source that never
answers, or one that trickles bytes and never finishes. It logs one typed
MediaFetchError line ("network timeout") and retires its reader. A job that
waited for an open that finished, or decoded a frame, timed out on a slow link
and keeps the reader and its parsed index. Bytes alone are not progress, or a
trickling source would keep its reader for good.
An aborted job also ends its claim on the reader at the abort, rather than
when it settles: a job stuck in a read that ignores its signal no longer keeps
the reader open. A retired reader still waits for the clips decoding on it.
Measured in Chromium: a source that never answers fails each clip with one
typed line at 30 s and the next opens a fresh reader; with all six
connections held for 40 s, the two starved clips fail typed, none as a stall.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(studio): move the thumbnail reader's explanations into names
The comment ratchet (scripts/comment-ratchet.mjs) fails a source file whose
share of comment lines rises over its merge-base, and a new file above its
package's share. Five files of this branch did. The reasoning they carried is
in the commit messages and the PR description; what stays in code is named:
SILENT_BODY_STALL_MS, POOLED_READER_CACHE_BYTES, failuresByStart,
inFlightReads, decodedByJob, timeoutWithoutProgress and madeProgress. No
behaviour changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): keep the canvas outline off the scaled preview iframe
Co-authored-by: Ryan Heinl <ryan@siyglobal.com>
* fix(studio): keep NLEPreview under the file size limit
Co-authored-by: Ryan Heinl <ryan@siyglobal.com>
* fix(studio): bank the player's colour-literal baseline
Co-authored-by: Ryan Heinl <ryan@siyglobal.com>
---------
Co-authored-by: Ryan Heinl <ryan@siyglobal.com>
* fix(studio-server): preview loads root-path media, images and CSS from the project, as render does
* fix(studio-server): preview writes root paths project-relative, the form Studio reads back
* test(studio): record each edit-accuracy shard's file writes and reloads when HF_WRITE_TRACE is set
* style(studio): format the write trace
* fix(core): hash the write trace with sha256
* style(studio): wrap the trace hash helper
* style(studio): format the trace hash helper
* fix(studio): turn the reload log on in the Studio page the trace watches
* fix(studio): a timeline edit that lands on the clip's own values saves nothing instead of failing
* chore: re-run checks against the updated PR description
* feat(engine): encoders cap peak bitrate and place keyframes on a set interval
* style(engine): shorten the rate-cap comment
* style(engine): drop the doc lines the comment ratchet flagged
* feat(producer): mp4 renders honour a keyframe interval and a peak bitrate cap on every encoder path
* style(producer): shorten the delivery encoder file headers
* style(producer): drop a comment line from the segment manifest
* style(producer): shorten the integration test header
* test: delivery encoder tests follow the ffprobe argv contract and run in the integration lane
* feat: the render request, server and Lambda plan carry the keyframe interval and peak bitrate cap
* fix(studio): the Studio render route passes the keyframe interval and bitrate cap to the encoder
* chore(studio-server): drop a comment the helper's name already says
* feat(cli): render and lambda render take --keyframe-interval, --max-bitrate and --buffer-size
* fix(core): preview seeks land on the composition's own frame rate
* fix(studio): frame step, frame readout and ruler use the composition's frame rate
* fix(studio): an end trim previews the clip's last frame at the composition's frame rate
* refactor(studio): ruler labels follow the ticks' frame rate and fps parsing splits by form
* style(studio): format the ruler label helper and its test
* chore(inspector): raise the package size budget to the measured size plus headroom
* fix(studio): a clip that cannot be read gives up after two retries with one typed line
* fix(studio): a clip whose stream breaks mid-read also fails with the typed line
* perf(studio): the layer walk keeps its selector index, so scrubbing into new scenes is cheaper
* perf(studio): the layer walk keeps its selector index, so scrubbing into new scenes is cheaper
The off-canvas indicators (always mounted) and the Layers panel rebuild their layer list every
100 ms while a scrub mutates the preview, and each rebuild read the computed style of every element
of every scene, on screen or not. The runtime already hides everything inside a clip its visibility
pass hid, so those subtrees hold no layer: the walk now skips a clip marked data-hf-clip-hidden
with an inline visibility of hidden. The marker constant moves to studio-preview-mark so the runtime
and Studio share one owner.
* feat(studio-server): a copy being made says how far it has got, and can be retried
* feat(core): the preview reports its lighter copies' progress and retries failed ones
* feat(studio): the preview quality and its lighter copies' progress live in the player store
* feat(studio): every host forwards the preview quality, and the choice is kept per computer
* feat(studio): the preview quality knows if a person or the app chose it
* fix(core): the first report of the lighter copies is not held back by the throttle
* test(studio): the preview quality preference test writes under the real key
* fix(core): a quality change reports its copies at once, not after the throttle
* style: format the preview quality files
* feat(core): report how playback is going, and what the preview holds, to the host
* fix(studio): the preview quality levels come from a browser-safe core entry
* style: format the new entry's files
* style: drop two comments the file's ratchet and the citation check refuse
* feat(core): tell which lower levels would give a video a lighter copy
* fix(studio): drop the old level's playback health when the level changes
* feat(studio-server): preview facts carry the probed codec of every video
* refactor(studio): route the preview's reports through a table for the complexity gate
* fix(core): a copy's retry flag is sent on the first ask only, not on every poll
* fix(core): type the health report and restore the player hook's complexity directive
* style: cut four comments the comment ratchet refuses
* fix(studio): keep playerStore.ts within its 600-line cap
* fix(core): a scene mounting does not restart the playback health window
* fix(core): the playback health window restarts only when the display scale changes
* fix(core): a repeated display scale no longer restarts the playback health window
* fix(studio): route the preview's reports by an explicit list of message types
* test(studio): real-Chrome tests inline GSAP so their page waits on no request
* test(ci): smoke projects inline GSAP so required checks wait on no CDN