Skip to content

Commit 88749f9

Browse files
committed
fix(video-studio): fold Kilo findings (delete rollback, seed validation, download href, timer/preload hygiene)
- Roll back the optimistic delete and surface an error when DELETE fails, instead of silently swallowing the failure. - Reset `latest` and clear any existing timer at the start of a generate run, so a prior result/timer can't leak into a new attempt. - Serve generated videos through a new GET /api/video/{filename} route and point the frontend at it instead of the backend's raw (unservable) `path` field. - Validate the seed input: reject NaN/negative values, clamp to the backend's accepted range, and ignore it when blank. - Use preload="none" for library grid thumbnails instead of "metadata". - Only fall back to the first video when there's no explicit library selection, instead of silently overriding the user's choice. - Auto-grow the Create prompt textarea with its content, up to a max height.
1 parent 6d97f51 commit 88749f9

4 files changed

Lines changed: 80 additions & 14 deletions

File tree

desktop/src/apps/VideoStudioApp.tsx

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,17 @@ function randomSeed(): number {
2828
return Math.floor(Math.random() * 1_000_000);
2929
}
3030

31+
// Max seed accepted by the backend (matches routes/video.py's
32+
// random.randint(0, 2**32 - 1) range).
33+
const MAX_SEED = 2 ** 32 - 1;
34+
3135
function toGeneratedVideo(raw: Record<string, unknown>): GeneratedVideo {
36+
const filename = (raw.filename as string) ?? "";
3237
return {
33-
filename: (raw.filename as string) ?? "",
34-
url: (raw.path as string) ?? "",
38+
filename,
39+
// `raw.path` is the backend's on-disk-style path, not a servable URL --
40+
// serve videos through the API's filename-based route instead.
41+
url: filename ? `/api/video/${encodeURIComponent(filename)}` : "",
3542
prompt: (raw.prompt as string) ?? "",
3643
model: (raw.model as string) ?? "",
3744
duration: (raw.duration as number) ?? 0,
@@ -108,6 +115,8 @@ export function VideoStudioApp({ windowId: _windowId }: { windowId: string }) {
108115
setGenerateError(null);
109116
setNeedsBackend(false);
110117
setElapsedSeconds(0);
118+
setLatest(null);
119+
if (timerRef.current) clearInterval(timerRef.current);
111120
timerRef.current = setInterval(() => {
112121
setElapsedSeconds((s) => s + 1);
113122
}, 1000);
@@ -118,9 +127,12 @@ export function VideoStudioApp({ windowId: _windowId }: { windowId: string }) {
118127
duration,
119128
resolution,
120129
};
121-
const parsedSeed = seed.trim() ? parseInt(seed, 10) : null;
122-
if (parsedSeed !== null && !Number.isNaN(parsedSeed)) {
123-
body.seed = parsedSeed;
130+
const trimmedSeed = seed.trim();
131+
if (trimmedSeed) {
132+
const parsedSeed = parseInt(trimmedSeed, 10);
133+
if (!Number.isNaN(parsedSeed) && parsedSeed >= 0) {
134+
body.seed = Math.min(parsedSeed, MAX_SEED);
135+
}
124136
}
125137

126138
try {
@@ -166,12 +178,32 @@ export function VideoStudioApp({ windowId: _windowId }: { windowId: string }) {
166178
/* ----------------------------- delete ---------------------------- */
167179

168180
const handleDelete = useCallback((filename: string) => {
169-
setVideos((prev) => prev.filter((v) => v.filename !== filename));
181+
let removed: GeneratedVideo | undefined;
182+
let removedIndex = -1;
183+
setVideos((prev) => {
184+
removedIndex = prev.findIndex((v) => v.filename === filename);
185+
removed = prev[removedIndex];
186+
return prev.filter((v) => v.filename !== filename);
187+
});
170188
setSelectedLibraryId((cur) => (cur === filename ? null : cur));
171189
setLatest((cur) => (cur?.filename === filename ? null : cur));
172-
fetch(`/api/video/${encodeURIComponent(filename)}`, {
173-
method: "DELETE",
174-
}).catch(() => {});
190+
191+
fetch(`/api/video/${encodeURIComponent(filename)}`, { method: "DELETE" })
192+
.then((res) => {
193+
if (!res.ok) throw new Error(`Delete failed (${res.status})`);
194+
})
195+
.catch((e) => {
196+
// Roll back the optimistic removal and surface the failure --
197+
// don't let a failed delete silently vanish the item.
198+
setVideos((prev) => {
199+
if (!removed || prev.some((v) => v.filename === filename)) {
200+
return prev;
201+
}
202+
const at = Math.min(removedIndex < 0 ? prev.length : removedIndex, prev.length);
203+
return [...prev.slice(0, at), removed, ...prev.slice(at)];
204+
});
205+
setLibraryError(`Failed to delete video: ${(e as Error).message}`);
206+
});
175207
}, []);
176208

177209
const handleDownload = useCallback((video: GeneratedVideo) => {

desktop/src/apps/videostudio/CreateView.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useEffect, useRef } from "react";
12
import { Clapperboard, Download, ExternalLink } from "lucide-react";
23
import {
34
Slider,
@@ -78,6 +79,16 @@ export function CreateView(props: CreateViewProps) {
7879

7980
const selectedModel = MODEL_OPTIONS.find((m) => m.id === modelId);
8081

82+
// Auto-grow the prompt textarea with its content, up to the max-h set
83+
// below (CSS then clamps and scrolls beyond that).
84+
const promptRef = useRef<HTMLTextAreaElement>(null);
85+
useEffect(() => {
86+
const el = promptRef.current;
87+
if (!el) return;
88+
el.style.height = "auto";
89+
el.style.height = `${el.scrollHeight}px`;
90+
}, [prompt]);
91+
8192
return (
8293
<div className="flex min-h-0 flex-1 flex-col">
8394
{/* view header */}
@@ -197,6 +208,7 @@ export function CreateView(props: CreateViewProps) {
197208
Video prompt
198209
</label>
199210
<textarea
211+
ref={promptRef}
200212
id="video-prompt"
201213
value={prompt}
202214
onChange={(e) => onPromptChange(e.target.value)}
@@ -208,7 +220,7 @@ export function CreateView(props: CreateViewProps) {
208220
}}
209221
placeholder="Describe the video you want to create…"
210222
rows={1}
211-
className="min-h-[50px] flex-1 resize-none rounded-2xl border border-shell-border bg-shell-surface px-4 py-3.5 text-[13.5px] text-shell-text placeholder:text-shell-text-tertiary focus-visible:outline-none focus-visible:border-accent/40 focus-visible:ring-2 focus-visible:ring-accent/20"
223+
className="min-h-[50px] max-h-40 flex-1 resize-none overflow-y-auto rounded-2xl border border-shell-border bg-shell-surface px-4 py-3.5 text-[13.5px] text-shell-text placeholder:text-shell-text-tertiary focus-visible:outline-none focus-visible:border-accent/40 focus-visible:ring-2 focus-visible:ring-accent/20"
212224
/>
213225
<button
214226
type="button"

desktop/src/apps/videostudio/LibraryView.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,11 @@ export function LibraryView({
4242
onDownload,
4343
onDelete,
4444
}: LibraryViewProps) {
45-
const selected =
46-
videos.find((v) => v.filename === selectedId) ?? videos[0] ?? null;
45+
// Only fall back to the first video when there is no explicit selection --
46+
// don't silently swap in a different clip if the selected one disappears.
47+
const selected = selectedId
48+
? (videos.find((v) => v.filename === selectedId) ?? null)
49+
: (videos[0] ?? null);
4750

4851
return (
4952
<div className="flex min-h-0 flex-1 flex-col">
@@ -99,7 +102,7 @@ export function LibraryView({
99102
<video
100103
src={v.url}
101104
muted
102-
preload="metadata"
105+
preload="none"
103106
className="h-full w-full object-cover"
104107
/>
105108
) : (

tinyagentos/routes/video.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import httpx
1010
from fastapi import APIRouter, Request
11-
from fastapi.responses import JSONResponse
11+
from fastapi.responses import FileResponse, JSONResponse
1212
from pydantic import BaseModel
1313

1414
router = APIRouter()
@@ -193,6 +193,25 @@ async def list_videos(request: Request):
193193
return {"videos": _list_videos(videos_dir)}
194194

195195

196+
@router.get("/api/video/{filename}")
197+
async def serve_video(request: Request, filename: str):
198+
"""Serve a generated video file for playback/download.
199+
200+
The ``path`` field returned by generate/list is not itself a mounted
201+
static route -- the frontend hits this endpoint by filename instead.
202+
"""
203+
videos_dir = _videos_dir(request)
204+
205+
if "/" in filename or "\\" in filename or ".." in filename:
206+
return JSONResponse({"error": "Invalid filename"}, status_code=400)
207+
208+
video_path = videos_dir / filename
209+
if not video_path.exists():
210+
return JSONResponse({"error": f"Video '{filename}' not found"}, status_code=404)
211+
212+
return FileResponse(video_path, media_type="video/mp4", filename=filename)
213+
214+
196215
@router.delete("/api/video/{filename}")
197216
async def delete_video(request: Request, filename: str):
198217
"""Delete a generated video and its metadata sidecar."""

0 commit comments

Comments
 (0)