Repository navigation
Conversation
… the page, hold it in the block until Clips can take it, and show its progress, making and unfinished states
…ed check while a Clip is made, count a lost last-chunk answer as sent, and tell the agent when Clips couldn't make an uploaded Clip
|
Here's a visual recap of what changed: Open the full interactive recap |
…ing made, and refuse video types Clips won't take
|
On the review's LOW item (unsupported dropped or pasted formats leaving empty blocks): fixed in fb52423. While uploads go to Clips, drop and paste now take only the video types Clips accepts (MP4, WebM and MOV, from
|
# Conflicts: # templates/content/app/components/editor/clip-embed/ClipEmbedBlock.tsx # templates/content/app/components/editor/clip-embed/use-clip-playback.ts
…ock is gone, and upload nothing for a file removed while the gate checks
|
Round 2 is at a908891. It merges #8182 at dad59d2, which brings in #8147 at 6554fcb. Review 5639723530, LOW: a deleted block left its upload in Tests delete the block while the Clip is being made and after a failure. In both, the entry and its Thread replies:
From the merge: the checks on a Clip being made now use #8147's |
…p Clips already has, keep a removed block's five seconds running after the editor closes, and ask again while Clips is still making the first start's Clip
There was a problem hiding this comment.
Builder reviewed your changes and found 2 potential issues 🟡
Review Details
Incremental Review Summary
The latest changes fix the prior deleted-block cleanup issue: grace timers now outlive an editor watcher, check page state on expiry, and are covered by unmount/remount tests. I verified the fix and resolved the prior review thread. The PR still uses server-authorized, page-scoped Clips uploads and bounded status polling; the cross-app upload boundary remains high risk. I found two new medium issues and two lower-severity correctness issues.
Findings
- 🟡 MEDIUM — A stalled chunk request has no timeout: The upload can remain in
uploadingindefinitely while its block remains present. - 🟡 MEDIUM — Storage hint claims videos are saved as Clips without storage: For
none/account_required(and before the status query resolves), the UI displays success-style copy although no upload is made yet. - LOW — Malformed completed recording IDs can enter page content: The
already_finishedresponse validates only non-emptiness, unlike the started path. - LOW — Agent-visible upload state can outlive its editor or tab memory:
view-screenmay return a stalemaking/failedsnapshot after the page is closed or the tab reloads.
🧪 Browser testing: Skipped — the browser-test-planner agent is unavailable in this environment.
| // A body with no type and no headers keeps this a simple CORS request: | ||
| // Clips' auth layer answers every preflight, and the ticket in the URL is | ||
| // the only authority the request carries. | ||
| const response = await fetch(url, { |
There was a problem hiding this comment.
🟡 Chunk upload requests can hang without a timeout
The chunk fetch only uses the user-controlled abort signal and has no request timeout. If Clips accepts a chunk but never settles the response, sendClipChunks never reaches its bounded retry loop and the block remains in the uploading state indefinitely while it stays in the page; apply a per-chunk timeout and treat it as retryable.
| : t("clipEmbed.savedAsClipFreeNone") | ||
| : storage?.state === "used_up" | ||
| ? t("clipEmbed.savedAsClipFreeNone") | ||
| : t("clipEmbed.savedAsClip"); |
There was a problem hiding this comment.
🟡 Storage hint says videos are saved when Clips has no storage
This fallback displays “Saved as a Clip” for none, account_required, and while storage is still undefined. In the first two states the file is held and no upload starts, so this contradicts the setup gate; show neutral/loading copy or a message that accurately describes the pending setup instead.

A video uploaded into a Content page should become a Clip: played with Clips' own player, saved in Clips rather than in Content, and visible to exactly the people who can see the page. With Clips in pages turned on (#8147), a pasted Clips link already plays as a Clip. Uploading a file into a video block, though, still saves a plain video in Content's own storage. That upload is capped at 25 MB, needs storage set up in Content with a public URL, and the video is served from that URL with none of a Clip's privacy, player or comments.
This is the Content side of step A4 of "Clips videos embedded natively in Content docs". It is stacked on the Clips side (#8182), which stacks on #8147, #8090, #8088 and #8059, and it will be rebased onto
mainonce those merge. Everything new is behind thecontent.clip-embedflag, off by default. With the flag off, uploads work exactly as they do onmain.Approach
The Upload tab says where the video will go before anyone picks a file. While the flag is on, the video block's Upload tab asks Clips through the new
get-clip-upload-statusaction and shows its answer as it is: "Saved as a Clip · 2 free clips left", "Saved as a Clip" when the editor's Clips workspace has its own storage, or which setup step is missing. Content never counts free clips itself.Only the page's editors can start an upload, and Content checks that before Clips hears anything. Picking, dropping or pasting a video calls
create-clip-upload. Both actions run the same check (authorizeClipUploader) before minting a token:Only then does Content mint a one-minute
content-clip:uploadtoken with the page-access secret it shares with Clips. The token names the editor and the page, and carries the browser's address from Netlify'sx-nf-client-connection-ipso Clips' free storage counts per network. Content sends Clips the file's description only (name, size, type, the block id the Clip will have, and theuploadKeyit made for this picked file), never the file. Clips makes a private Clip bound to the page and answers with a ticketed chunk URL. Content accepts that URL only if it is on one of Clips' own origins and names that recording.A start repeated for one picked file reaches the upload it made. Each picked file gets one
uploadKeyand one block id, sent with every start of it, so Clips makes at most one Clip per pick (#8182). The tab repeats a start only for a file it holds after a storage refusal, from Check again or from coming back to the tab. A storage refusal leaves nothing in Clips, so the repeat reaches an earlier upload only when an earlier start of the file got to Clips and its answer was lost. Clips then answers with that upload:upload_startingwhile Clips is still making the first start's Clip: the tab repeats the start after 2, 5 and 10 seconds, then says "The video didn't upload. Try again." and keeps the file.The browser sends the file straight to Clips, and the file stays in that tab's memory. Once the upload starts, the video block becomes
<ClipEmbed … origin="upload" />in the page. The tab then posts the file to Clips in the ticket's chunks, one at a time. The chunks go withcredentials: "omit", no headers and no content type, so the browser sends no CORS preflight and no cookie. A chunk that fails to send, or that Clips answers with a 429 or a server error, is sent again after 1, 3, 10 and 30 seconds. Any other refusal, or a chunk still failing after the last retry, ends the upload. If the answer to the last chunk was lost and a resend finds the Clip already processing or ready, that counts as sent. While the file is being sent, leaving or reloading the page asks first.An upload this tab gives up is cancelled in Clips right away. Clips' start answer carries a cancel URL that takes the same ticket (#8182), which Content checks like the chunk URL. The tab cancels the upload, and the person is told, when:
The cancel releases the upload's free clip in Clips. Clips' answer says what happened (#8182), and the message follows it:
A tab closed mid-upload can't cancel; Clips fails that upload about an hour later.
Each stage shows in the block, for the uploader and for everyone else.
While a Clip is being made, each open block asks Clips about it after 4 seconds, then twice as long each time up to once a minute, and from 4 seconds again whenever the status changes. It never asks while its tab is hidden. After 15 minutes with no change it stops and shows Check again, and asks once more when the tab gets focus or becomes visible. The uploading tab follows its own send instead, and starts asking once the send ends. An upload that stops sending, or that Clips marks failed (including when its lease runs out), ends as "didn't finish". Removing that block takes it out of the page for everyone.
When Clips can't take the file yet, the file waits in the block. Nothing goes into the page until the upload starts.
The links open Clips' own setup pages in a new tab. The block checks again when the tab regains focus or becomes visible, never while it is hidden, and there is also a Check again button and Remove file. As soon as Clips can take the file, the upload starts by itself. Remove file wins over a check or a start already under way: nothing uploads for the removed file, it doesn't come back, and a ticket Clips already issued for it is cancelled. A video that isn't MP4, WebM or MOV is refused before Clips is asked. Dropped or pasted, it gets a message naming those types and no block, while the supported files in the same drop still go. One larger than the editor's storage takes is refused by Clips before anything is made, and a toast says how large a video can be.
Uploads are UI-only. Both actions are
agentTool: falseandpersistInBrowser: false: an upload needs the file's bytes in the editor's tab and a ticket only that tab can use. The agent sees this tab's uploads throughview-screen'sclipUploads(app statecontent-clip-uploads, holding each upload'srecordingId,documentId,fileName,phaseandpercent, and never the file). An upload whose block has been gone for 5 seconds drops out of it, whatever its phase. It can still link an existing Clip by writing theClipEmbedtag withedit-document.What changed
templates/content/actions/:get-clip-upload-status(GET, read-only) andcreate-clip-upload, with_clip-upload.tsfor the storage schema, the uploader's address, and the check that the chunk and cancel URLs are Clips' own, for that recording.create-clip-uploadsends theuploadKeyand reads a repeat'supload_already_finishedandupload_startinganswers.view-screenaddsclipUploads.actions/_clip-embed-access.tsgainsauthorizeClipUploader.server/lib/clips-peer.tsmints the upload token (withclient_ip) and gainsrequestClipsUpload, which maps Clips' answers to typed results.shared/clips-peer-contract.tsadds the upload scope, storage states, refusal codes (with a repeat'supload_already_finished,upload_startingandupload_key_used),CONTENT_CLIP_UPLOAD_KEY,CLIP_UPLOAD_ERRORS, the ticket'scancelUrl, and the cancel's outcomes (CLIP_CANCEL_OUTCOME).ClipEmbedBlock.tsxgains the uploading, making and didn't-finish states, and a Check again link once the checks on a Clip being made stop. It splits out the player so its hooks stay in the same order from upload to playback.use-clip-playback.tschecks again while a Clip is being made, on the schedule inCLIP_MAKING_CHECK_MS, and leaves the grant-expiry refresh until the Clip is made. Merged with Play a pasted Clips link inside a Content page, with its comment markers #8147's latest playback changes, those checks use its"asked"refresh, and the Clip block passes itsresumeRefthrough to the player.use-clips-links.tsadds Clips' setup and recording URLs.clip-embed-content.tslets a new Clip block take the block id Clips bound it to.templates/content/app/components/editor/clip-embed/clip-uploads.ts: this tab's uploads, the chunk sender, the cancel and its typed answer, the watch for a removed Clip block (it gives up an upload still sending and forgets any other; its 5-second timers live with the uploads, not with an editor), and dropped files waiting for their block.clip-upload-notices.tssays what a cancel did, and that a Clip was already saved.ClipUploadGate.tsxis the Upload tab's hint and the waiting gate, and makes each pick's key and block id.extensions/VideoBlock.tsxandVisualEditor.tsx: the flag-on Upload tab, a cancel when the block can't become a Clip block, a Clip Clips already had placed with nothing uploaded, and dropped or pasted videos sent to Clips, with types Clips won't take refused.VisualEditorstarts the removed-block watch once the page's content has synced. With the flag off, both paths aremain's.app/clip-embed-messages.ts: every new string, in all 11 locales..agents/skills/document-editing/references/document-behavior.md: "Video uploads become Clips", for the agent.parity/matrix.tsandmatrix.md: a row for the two hidden actions.Safety and operations
content.clip-embed, off by default. With it off, the Upload tab, drops and pastes use Content's file storage as onmain. Turning it off stops new uploads; Clip blocks already in pages show "Clips in pages aren't turned on." (Play a pasted Clips link inside a Content page, with its comment markers #8147).CONTENT_CLIPS_PEER_SECRETon both apps (from Let Content document readers play and comment on Clips uploaded to the document #8090). Without it the Upload tab says Clips isn't connected, and nothing is minted.<ClipEmbed … origin="upload" />tag when an upload starts. The file never reaches Content's server or storage.<ClipEmbed … origin="upload" />tags with nothing to render them.Verification
At ea7b351, in Content, round 3. Its tests were written first. On the Content code from before round 3 (a908891), 12 of the 140 tests in the 4 changed test files fail; all of them pass now. The 12 cover:
create-clip-uploadsending theuploadKeyand readingupload_already_finishedandupload_starting. 4 failed: these two, plus the existing ticket and validation tests, which now check the key.upload_startingafter 2, 5 and 10 seconds, and starts the upload once a ticket comes.upload_starting, it shows "The video didn't upload. Try again."Three more tests guard against cancelling too much, and pass before and after: a block back in time, one put back by an outside merge, and an editor closing with the block in place.
At the earlier head a908891, in Content: the
clip-embedfolder and the video block's upload tests pass, 137 tests in 5 files. What round 2 adds:cancelClipUploadmaps nine answers tocancelled,already_finishedornot_cancelled. A failed send whose cancel finds the Clip made ends as making, and a given-up upload reports what its cancel did. The video block shows the cancel message, the saved-as-Clip message with Open in Clips, or the unconfirmed message;view-screenline after 5 seconds, with no cancel; one back sooner keeps it. With the oldclip-uploads.ts, 15 of these tests failed.At the earlier head fb52423, in Content: the 8 test files round 1 touched pass, 171 tests. What they add:
At the earlier head 11e9b0f, in Content: the touched test files pass, 262 tests in 23 files (the
clip-embedfolder,clips-peer, the upload actions, both video-block upload test files, andparity). They cover:authorizeClipUploader: viewers and commenters, a page the caller can't read, a page in Trash, a signed-out caller and the flag off are each refused before Clips is asked;The full Content suite at ea7b351: 7827 passed, and 67 failed in 8 files.
mainfails by design and CI's gate leaves out (the same count as round 1's run). Run alone, those 5 files fail these 22 and nothing else.github-source.db.test.ts,DocumentEditor.recovery-save-state.test.tsxanddocument-search-ranking.perf.test.ts, where one test timed out under the full run's load and the rest followed. Run alone, all three files pass.pnpm typecheckandoxfmt --checkare clean in Content at ea7b351 and in Clips at 66e6440.pnpm guardsat ea7b351: all 93 checks pass, includingguard:i18n-catalogsandguard:i18n-changed-copy. Round 3 adds no copy: a start that never gets pastupload_startingreuses "The video didn't upload. Try again."An independent model review of the whole A4 diff found six issues, all fixed with tests:
Manual QA, on local Content and Clips from this branch at 5ec7bfc251fc (only the parity matrix changed after). It used fake accounts (an editor, a viewer of the page with no Clips account, and an outsider), fake S3 standing in for Builder's free storage with a 2-clip limit, and ffmpeg test videos. The recordings are real-speed, silent 1440x900 takes of
mainand of this branch. They stay local and aren't attached here. Everything below passed:main's Upload tab, storage gate, 25 MB limit and plain video, matching themaintakes.That pass came before the review rounds' changes, which have tests but no manual pass:
Review focus
authorizeClipUploaderand both actions: is there any path that mints an upload token, or reaches Clips, before the edit, Trash and flag checks pass?postChunk,cancelClipUploadandisClipUploadUrl: the browser sends the file wherever the chunk URL points, and the cancel wherever the cancel URL points, so the URL check and the credential-free simple request are what keep both going only to Clips.watchClipUploadBlocks: can a block that is still in the page, or comes back within the 5 seconds, get its upload cancelled or forgotten? With the timers now outliving the editor, can a page that is still loading, or open in a second editor, look like it lost the block?useClipFileUpload: can anything a Remove interrupted still start an upload or bring the file back, including during theupload_startingretries? Does every start of one pick send the same key and block id?cancelClipUploadandannounceClipCancel: analready_finishedor unconfirmed answer must never show as cancelled.clip-uploads.ts's phases andClipEmbedBlock's states: can a block get stuck in "Uploading" or "Making", or show "didn't finish" for a Clip Clips made?VideoBlock.tsxandVisualEditor.tsxwith the flag off: they should bemain's paths exactly.Known limits and follow-ups
upload_key_usedstays held. That happens once a held file's lost start is cancelled or fails in Clips, or goes ten minutes without its recording. Check again then shows "The video didn't upload. Try again." each time, and the editor removes the file and picks it again, which makes a new key.trasheduntil A6.guard:no-pnpm-patchesscans gitignored.tmp/folders, so a local Python virtualenv there fails it.🤖 Generated with Claude Code