Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 124 additions & 16 deletions apps/web/src/components/chat/message-deliverables.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
type OutputImagePreviewState,
useLazyOutputImagePreview,
} from "@/components/chat/output-image-preview";
import type { OutputPreviewState } from "@/components/chat/output-preview-support";
import { useLazyOutputVideoPreview } from "@/components/chat/output-video-preview";
import {
Code,
Download,
Expand All @@ -35,7 +37,7 @@ interface DeliverablesBlockProps {
}

export function DeliverablesBlock({ items, threadId }: DeliverablesBlockProps) {
useAutoOpenLatestImage(items, threadId);
useAutoOpenLatestMedia(items, threadId);
return (
<div data-chat-deliverables="true">
<MessageDoubleShell innerClassName="p-3">
Expand Down Expand Up @@ -65,9 +67,99 @@ function DeliverableCard({ data, threadId }: { data: ArtifactData; threadId: str
/>
);
}
if (isVideoArtifact(data)) {
return (
<VideoDeliverableCard
data={data}
download={download}
getToken={getToken}
threadId={threadId}
/>
);
}
return <FileDeliverableCard data={data} download={download} />;
}

function VideoDeliverableCard({
data,
download,
getToken,
threadId,
}: {
data: ArtifactData;
download: DeliverableDownload;
getToken: () => Promise<null | string>;
threadId: string;
}) {
const preview = useLazyOutputVideoPreview(data, getToken);
const openInFiles = useOpenMediaInFiles(data, threadId, "video");
return (
<div className="overflow-hidden rounded-[12px] border border-border bg-background">
<VideoPreview data={data} preview={preview} />
<div className="flex flex-wrap items-center gap-2 px-2.5 py-2.5">
<Video aria-hidden="true" className="h-4 w-4 shrink-0 text-fg-secondary" />
<DeliverableMetadata data={data} />
<button
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full px-2 text-[12px] text-fg-secondary transition-colors hover:bg-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={openInFiles}
type="button"
>
<FolderOpen aria-hidden="true" className="h-3.5 w-3.5" />
Open in Files
</button>
<DownloadButton download={download} filename={data.filename} />
</div>
</div>
);
}

function VideoPreview({ data, preview }: { data: ArtifactData; preview: OutputPreviewState }) {
return (
<div
className="relative aspect-video w-full overflow-hidden bg-secondary"
ref={preview.hostRef}
>
{preview.url ? (
// Generated assets do not include a timed-text sidecar to attach here.
// biome-ignore lint/a11y/useMediaCaption: No caption track exists for arbitrary provider output.
<video
aria-label={`Generated video: ${readableFilename(data.filename)}`}
className="h-full w-full bg-black object-contain"
controls
playsInline
preload="metadata"
src={preview.url}
/>
) : (
<VideoPreviewPlaceholder message={preview.message} status={preview.status} />
)}
</div>
);
}

function VideoPreviewPlaceholder({
message,
status,
}: Pick<OutputPreviewState, "message" | "status">) {
const isLoading = status === "idle" || status === "loading";
return (
<div
aria-live="polite"
className={cn(
"flex h-full w-full flex-col items-center justify-center gap-2 p-5 text-center text-[12px] text-fg-secondary",
isLoading && "motion-safe:animate-pulse",
)}
>
{isLoading ? (
<Loader2 aria-hidden="true" className="h-5 w-5 animate-spin motion-reduce:animate-none" />
) : (
<Video aria-hidden="true" className="h-5 w-5" />
)}
<span>{isLoading ? "Preparing video…" : (message ?? "Video preview unavailable")}</span>
</div>
);
}

function FileDeliverableCard({
data,
download,
Expand Down Expand Up @@ -98,7 +190,7 @@ function ImageDeliverableCard({
}) {
const [isViewerOpen, setViewerOpen] = useState(false);
const preview = useLazyOutputImagePreview(data, getToken);
const openInFiles = useOpenImageInFiles(data, threadId, () => setViewerOpen(false));
const openInFiles = useOpenMediaInFiles(data, threadId, "image", () => setViewerOpen(false));
return (
<div className="overflow-hidden rounded-[12px] border border-border bg-background">
<ImageThumbnail data={data} onOpen={() => setViewerOpen(true)} preview={preview} />
Expand Down Expand Up @@ -349,14 +441,19 @@ function useDeliverableDownload(
return { isPreparing, start };
}

function useOpenImageInFiles(data: ArtifactData, threadId: string, afterOpen: () => void) {
function useOpenMediaInFiles(
data: ArtifactData,
threadId: string,
type: "image" | "video",
afterOpen: () => void = () => undefined,
) {
const requestFileOpen = useAppStore((state) => state.requestFileOpen);
const setActiveComputerTab = useAppStore((state) => state.setActiveComputerTab);
const setPreviewPanelOpen = useAppStore((state) => state.setPreviewPanelOpen);
return () => {
const path = imageWorkspacePath(data);
const path = mediaWorkspacePath(data, type);
if (!path) {
toast.error("This image does not have a safe workspace path.");
toast.error(`This ${type} does not have a safe workspace path.`);
return;
}
requestFileOpen(threadId, path);
Expand All @@ -366,38 +463,49 @@ function useOpenImageInFiles(data: ArtifactData, threadId: string, afterOpen: ()
};
}

function useAutoOpenLatestImage(items: readonly ArtifactData[], threadId: string): void {
function useAutoOpenLatestMedia(items: readonly ArtifactData[], threadId: string): void {
const activeComputerTab = useAppStore((state) => state.activeComputerTab);
const previewPanelOpen = useAppStore((state) => state.previewPanelOpen);
const requestFileOpen = useAppStore((state) => state.requestFileOpen);
const latestImage = items.findLast(isImageArtifact);
const latestImagePath = latestImage ? imageWorkspacePath(latestImage) : null;
const latestMedia = items.findLast((item) => isImageArtifact(item) || isVideoArtifact(item));
const latestMediaPath = latestMedia
? mediaWorkspacePath(latestMedia, isVideoArtifact(latestMedia) ? "video" : "image")
: null;
useEffect(() => {
if (!previewPanelOpen || activeComputerTab !== "files" || !latestImagePath) return;
requestFileOpen(threadId, latestImagePath);
}, [activeComputerTab, latestImagePath, previewPanelOpen, requestFileOpen, threadId]);
if (!previewPanelOpen || activeComputerTab !== "files" || !latestMediaPath) return;
requestFileOpen(threadId, latestMediaPath);
}, [activeComputerTab, latestMediaPath, previewPanelOpen, requestFileOpen, threadId]);
}

function imageWorkspacePath(data: ArtifactData): string | null {
if (!isImageArtifact(data) || /[/\\]/u.test(data.filename) || data.filename.includes("..")) {
function mediaWorkspacePath(data: ArtifactData, type: "image" | "video"): string | null {
const matchesType = type === "image" ? isImageArtifact(data) : isVideoArtifact(data);
if (!matchesType || /[/\\]/u.test(data.filename) || data.filename.includes("..")) {
return null;
}
return `.cheatcode/assets/images/${data.filename}`;
return `.cheatcode/assets/${type === "image" ? "images" : "videos"}/${data.filename}`;
}

function imageAlt(filename: string): string {
const readable = filename
const readable = readableFilename(filename);
return readable ? `Generated image: ${readable}` : "Generated image";
}

function readableFilename(filename: string): string {
return filename
.replace(/\.[^.]+$/u, "")
.replace(/[-_]+/gu, " ")
.replace(/\s+/gu, " ")
.trim();
return readable ? `Generated image: ${readable}` : "Generated image";
}

function isImageArtifact(data: ArtifactData): boolean {
return data.kind === "image" || data.mimeType.startsWith("image/");
}

function isVideoArtifact(data: ArtifactData): boolean {
return data.kind === "video" || data.mimeType.startsWith("video/");
}

function deliverableIcon(kind: ArtifactData["kind"], mimeType: string) {
if (kind === "slide") return Presentation;
if (kind === "xlsx") return FileSpreadsheet;
Expand Down
43 changes: 8 additions & 35 deletions apps/web/src/components/chat/output-image-preview.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,17 @@
"use client";

import { type RefObject, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
type OutputPreviewIdentity,
type OutputPreviewState,
useNearViewport,
} from "@/components/chat/output-preview-support";
import { loadOutputImagePreview } from "@/lib/api/outputs";

export interface OutputImagePreviewState {
hostRef: RefObject<HTMLDivElement | null>;
message: string | null;
status: "error" | "idle" | "loading" | "ready";
url: string | null;
}

interface OutputImageIdentity {
outputId: string;
sizeBytes: number;
}
export type OutputImagePreviewState = OutputPreviewState;

export function useLazyOutputImagePreview(
data: OutputImageIdentity,
data: OutputPreviewIdentity,
getToken: () => Promise<null | string>,
): OutputImagePreviewState {
const hostRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -63,25 +58,3 @@ export function useLazyOutputImagePreview(

return { ...preview, hostRef };
}

function useNearViewport(ref: RefObject<HTMLElement | null>): boolean {
const [isNear, setNear] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element || typeof IntersectionObserver === "undefined") {
setNear(true);
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting) return;
setNear(true);
observer.disconnect();
},
{ rootMargin: "320px" },
);
observer.observe(element);
return () => observer.disconnect();
}, [ref]);
return isNear;
}
37 changes: 37 additions & 0 deletions apps/web/src/components/chat/output-preview-support.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"use client";

import { type RefObject, useEffect, useState } from "react";

export interface OutputPreviewIdentity {
outputId: string;
sizeBytes: number;
}

export interface OutputPreviewState {
hostRef: RefObject<HTMLDivElement | null>;
message: string | null;
status: "error" | "idle" | "loading" | "ready";
url: string | null;
}

export function useNearViewport(ref: RefObject<HTMLElement | null>): boolean {
const [isNear, setNear] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element || typeof IntersectionObserver === "undefined") {
setNear(true);
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting) return;
setNear(true);
observer.disconnect();
},
{ rootMargin: "320px" },
);
observer.observe(element);
return () => observer.disconnect();
}, [ref]);
return isNear;
}
48 changes: 48 additions & 0 deletions apps/web/src/components/chat/output-video-preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"use client";

import { useEffect, useRef, useState } from "react";
import {
type OutputPreviewIdentity,
type OutputPreviewState,
useNearViewport,
} from "@/components/chat/output-preview-support";
import { createOutputDownloadUrl } from "@/lib/api/outputs";

export function useLazyOutputVideoPreview(
data: OutputPreviewIdentity,
getToken: () => Promise<null | string>,
): OutputPreviewState {
const hostRef = useRef<HTMLDivElement | null>(null);
const getTokenRef = useRef(getToken);
const isNearViewport = useNearViewport(hostRef);
const [preview, setPreview] = useState<Omit<OutputPreviewState, "hostRef">>({
message: null,
status: "idle",
url: null,
});

useEffect(() => {
getTokenRef.current = getToken;
}, [getToken]);

useEffect(() => {
if (!isNearViewport) return;
const controller = new AbortController();
setPreview({ message: null, status: "loading", url: null });
void createOutputDownloadUrl(() => getTokenRef.current(), data.outputId, controller.signal)
.then((capability) => {
setPreview({ message: null, status: "ready", url: capability.downloadUrl });
})
.catch((error: unknown) => {
if (controller.signal.aborted) return;
setPreview({
message: error instanceof Error ? error.message : "Preview unavailable",
status: "error",
url: null,
});
});
return () => controller.abort();
}, [data.outputId, isNearViewport]);

return { ...preview, hostRef };
}
20 changes: 14 additions & 6 deletions packages/agent-core/src/tools/media/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,16 +207,24 @@ async function generateVideo(
}

function videoConfig(input: GenerateOrExtendVideoInput, references: MediaReference[]) {
if (input.reference_video) {
return {
numberOfVideos: 1,
resolution: "720p",
};
}
return {
aspectRatio: input.aspect_ratio ?? "16:9",
durationSeconds: input.duration ?? 8,
generateAudio: true,
numberOfVideos: 1,
referenceImages: references.map((reference) => ({
image: referenceImage(reference),
referenceType: VideoGenerationReferenceType.ASSET,
})),
resolution: "1080p",
...(references.length > 0
? {
referenceImages: references.map((reference) => ({
image: referenceImage(reference),
referenceType: VideoGenerationReferenceType.ASSET,
})),
}
: {}),
};
}

Expand Down
Loading