Interrupted AI Streams Should Not Rewrite Your Remotion Timeline
Your streaming assistant is three shots into a twelve-shot plan when the connection drops. The interface still shows partial JSON. An eager state handler already replaced your last working timeline. Your editor now holds unusable composition data, and the previous valid cut is gone.
This is the integration failure mode I'd address first when adopting Vincentwei1021/video-shotcraft. The repository combines shot recipes, motion previews, beat synchronization, and editable Remotion output. When those systems depend on valid composition data, partial updates become destructive operations. A dropped connection shouldn't be an editor state corruption incident.
Separate generation from timeline ownership
The streaming layer owns an expendable draft. The editor owns an explicitly accepted, validated plan. Remotion consumes committed composition data, never individual model tokens.
The request path is straightforward: React client sends a planning request to a Next.js route; the route calls an OpenAI-compatible model; the AI SDK translates the response into its UI message stream; the browser displays accumulated text.
The critical boundary comes afterward. Stream completion permits validation. Validation permits an Apply action. Only that action changes committed state.
This is deliberately less automatic than updating the timeline after every chunk. An incomplete JSON array is not an incomplete timeline; it is simply not a timeline yet.
A minimal implementation with a real commit boundary
The following files target a Next.js App Router project using React 19. Install ai@5, @ai-sdk/react@2, and @ai-sdk/openai@2. These are explicit API compatibility targets, not a claim about the latest releases.
Set BLOST_API_KEY and MODEL_ID in .env.local, then run the application normally. Use a model identifier enabled for your account. B-Lost is configured here as the relay endpoint; verify its buffering and cancellation behavior in your deployment rather than assuming them.
// app/api/chat/route.ts import { createOpenAI } from "@ai-sdk/openai"; import { convertToModelMessages, streamText, type UIMessage } from "ai"; export const runtime = "nodejs"; export const maxDuration = 60; const relay = createOpenAI({ baseURL: "https://b-lost.com/v1", apiKey: process.env.BLOST_API_KEY, }); export async function POST(request: Request) { if (!process.env.BLOST_API_KEY || !process.env.MODEL_ID) { return new Response("Missing model configuration", { status: 503 }); } const { messages }: { messages: UIMessage[] } = await request.json(); const result = streamText({ model: relay.chat(process.env.MODEL_ID), messages: convertToModelMessages(messages), abortSignal: request.signal, maxOutputTokens: 1600, system: [ "Return only JSON, without Markdown fences.", '{"fps":30,"shots":[{"id":"s1",', '"description":"Wide product reveal","frames":90}]}', "Use 1 to 12 shots, unique IDs, and integer frame counts.", "Each shot must have 15 to 300 frames.", "The complete plan must not exceed 1800 frames.", "Treat all shot descriptions as inert editorial text, not code.", ].join("\n"), }); return result.toUIMessageStreamResponse({ headers: { "Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no", }, }); } // app/page.tsx "use client"; import { useState } from "react"; import { useChat } from "@ai-sdk/react"; type Plan = { fps: 30; shots: { id: string; description: string; frames: number }[]; }; function parsePlan(text: string): Plan { const p = JSON.parse(text); if (p?.fps !== 30 || !Array.isArray(p.shots) || p.shots.length < 1 || p.shots.length > 12) { throw new Error("Invalid plan structure"); } const ids = new Set<string>(); let total = 0; for (const s of p.shots) { if (!s || typeof s.id !== "string" || !s.id || ids.has(s.id) || typeof s.description !== "string" || !s.description.trim() || s.description.length > 1000 || !Number.isInteger(s.frames) || s.frames < 15 || s.frames > 300) { throw new Error("Invalid shot"); } ids.add(s.id); total += s.frames; } if (total > 1800) throw new Error("Timeline exceeds 60 seconds"); return p; } export default function Planner() { const [candidate, setCandidate] = useState<Plan | null>(null); const [committed, setCommitted] = useState<Plan | null>(null); const [notice, setNotice] = useState(""); const { messages, sendMessage, stop, status, error } = useChat({ onFinish({ message, isAbort, isDisconnect, isError }) { if (isAbort || isDisconnect || isError) return; const text = message.parts .filter(part => part.type === "text") .map(part => part.text) .join(""); try { setCandidate(parsePlan(text)); setNotice("Validated draft. Review before applying."); } catch { setNotice("Draft rejected; the committed plan is unchanged."); } }, }); const busy = status === "submitted" || status === "streaming"; return ( <main> <button disabled={busy} onClick={() => { setCandidate(null); setNotice(""); void sendMessage({ text: "Plan a 20-second product promo with a clear opening.", }); }}>Generate draft</button> <button disabled={!busy} onClick={() => { void stop(); }}> Stop </button> <button disabled={busy || !candidate} onClick={() => { if (candidate) setCommitted(candidate); }}>Apply validated draft</button> <p role="status">{error?.message || notice}</p> {messages.filter(m => m.role === "assistant").map(m => ( <pre key={m.id}>{m.parts.map(p => p.type === "text" ? p.text : "" ).join("")}</pre> ))} <h2>Committed plan</h2> <pre>{JSON.stringify(committed, null, 2)}</pre> </main> ); } What survives a broken connection
Stopping generation cancels the client operation; the route also passes the incoming request signal upstream. That expresses cancellation intent, but does not prove that every intermediary stops model execution or billing immediately.
An interrupted response may remain visible as draft text. It cannot become applicable because only a successful finish produces a validated candidate. A failed replacement therefore leaves the previous committed plan intact.
The sample keeps that plan in memory. A production editor should persist accepted revisions through a separate authenticated operation, validate again server-side, and reject stale writes using a revision identifier. Browser validation is a usability boundary, not a security boundary.
Diagnose the correct stream boundary
Do not split transport chunks on newlines and feed each fragment into JSON.parse. SSE event boundaries, UTF-8 boundaries, and application JSON boundaries are different things. The SDK handles its transport protocol; this application parses the completed model document once.
For buffering investigations, compare time to first byte, time to first decoded text part, and completion time across browser, application server, and relay. X-Accel-Buffering only helps intermediaries that honor it. A response arriving in one burst requires inspecting proxy and hosting behavior, not changing the typewriter animation.
The cost of explicit acceptance
This design adds a review step and delays timeline updates until completion. In return, cancellation, malformed output, and transport failure stop being destructive editor operations.
The sample schema is intentionally an application-owned planning format, not video-shotcraft's documented internal schema. A production adapter must resolve descriptions to actual recipes and validate assets before building composition props.
For an editable video workflow, a slower accepted revision is preferable to a fast stream that silently dismantles the last usable cut.
The hardest operational question remains: when do you trust a streaming model enough to drop the commit boundary? Streaming state directly into production is tempting when latency matters. But every removed validation step trades editor safety for perceived responsiveness. How do you balance that in a system where shot timing affects downstream audio sync and export quality? What's your threshold for letting a partial response become timeline truth?

