Skip to content
Open
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
88 changes: 88 additions & 0 deletions src/services/aiTools/definitions/export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { ToolDefinition } from '../types';

export const exportToolDefinitions: ToolDefinition[] = [
{
type: 'function',
function: {
name: 'startExport',
description: 'Start a video export of the current composition. Returns progress updates and triggers a browser download when complete. The export uses the WebGPU render pipeline, so all effects, transforms, and transitions are included.',
parameters: {
type: 'object',
properties: {
width: {
type: 'number',
description: 'Output width in pixels (default: composition width)',
},
height: {
type: 'number',
description: 'Output height in pixels (default: composition height)',
},
fps: {
type: 'number',
description: 'Frame rate (default: composition frame rate)',
},
codec: {
type: 'string',
enum: ['h264', 'h265', 'vp9', 'av1'],
description: 'Video codec (default: h264)',
},
container: {
type: 'string',
enum: ['mp4', 'webm'],
description: 'Container format (default: mp4)',
},
bitrate: {
type: 'number',
description: 'Video bitrate in bps (default: auto-calculated from resolution)',
},
startTime: {
type: 'number',
description: 'Export start time in seconds (default: 0 or In point)',
},
endTime: {
type: 'number',
description: 'Export end time in seconds (default: duration or Out point)',
},
exportMode: {
type: 'string',
enum: ['fast', 'precise'],
description: 'Export mode: "fast" uses WebCodecs sequential decode, "precise" uses HTMLVideoElement seeking (default: fast)',
},
includeAudio: {
type: 'boolean',
description: 'Include audio in export (default: true)',
},
filename: {
type: 'string',
description: 'Output filename without extension (default: "export")',
},
},
required: [],
},
},
},
{
type: 'function',
function: {
name: 'cancelExport',
description: 'Cancel a running export.',
parameters: {
type: 'object',
properties: {},
required: [],
},
},
},
{
type: 'function',
function: {
name: 'getExportStatus',
description: 'Get the current export status (progress, phase, etc.).',
parameters: {
type: 'object',
properties: {},
required: [],
},
},
},
];
3 changes: 3 additions & 0 deletions src/services/aiTools/definitions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { playbackToolDefinitions } from './playback';
import { transitionToolDefinitions } from './transitions';
import { maskToolDefinitions } from './masks';
import { statsToolDefinitions } from './stats';
import { exportToolDefinitions } from './export';

// Combined tool definitions array (OpenAI function calling format)
export const AI_TOOLS = [
Expand All @@ -33,6 +34,7 @@ export const AI_TOOLS = [
...transitionToolDefinitions,
...maskToolDefinitions,
...statsToolDefinitions,
...exportToolDefinitions,
];

// Re-export individual definition sets for selective use
Expand All @@ -52,4 +54,5 @@ export {
transitionToolDefinitions,
maskToolDefinitions,
statsToolDefinitions,
exportToolDefinitions,
};
156 changes: 156 additions & 0 deletions src/services/aiTools/handlers/export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// AI Tool Handlers - Export

import { useTimelineStore } from '../../../stores/timeline';
import { useMediaStore } from '../../../stores/mediaStore';
import { FrameExporter, downloadBlob, getRecommendedBitrate } from '../../../engine/export';
import type { VideoCodec, ContainerFormat, ExportMode, ExportProgress } from '../../../engine/export';
import type { ToolResult } from '../types';
import { Logger } from '../../logger';

const log = Logger.create('AIExport');

// Track active exporter for cancel
let activeExporter: FrameExporter | null = null;
let lastProgress: ExportProgress | null = null;

export async function handleStartExport(args: Record<string, unknown>): Promise<ToolResult> {
const timelineStore = useTimelineStore.getState();
const mediaStore = useMediaStore.getState();

// Check if already exporting
if (timelineStore.isExporting) {
return { success: false, error: 'Export already in progress. Use cancelExport first.' };
}

// Get composition info
const composition = mediaStore.getActiveComposition();
const compWidth = composition?.width ?? 1920;
const compHeight = composition?.height ?? 1080;
const compFps = composition?.frameRate ?? 30;
const compDuration = timelineStore.duration;

// Parse args with defaults
const width = (args.width as number) ?? compWidth;
const height = (args.height as number) ?? compHeight;
const fps = (args.fps as number) ?? compFps;
const codec = (args.codec as VideoCodec) ?? 'h264';
const container = (args.container as ContainerFormat) ?? 'mp4';
const bitrate = (args.bitrate as number) ?? getRecommendedBitrate(width);
const startTime = (args.startTime as number) ?? (timelineStore.inPoint ?? 0);
const endTime = (args.endTime as number) ?? (timelineStore.outPoint ?? compDuration);
const exportMode = (args.exportMode as ExportMode) ?? 'fast';
const includeAudio = (args.includeAudio as boolean) ?? true;
const filename = (args.filename as string) ?? 'export';
const fileExtension = container === 'webm' ? 'webm' : 'mp4';

log.info('AI-triggered export', { width, height, fps, codec, container, bitrate, startTime, endTime, exportMode });

const exporter = new FrameExporter({
width,
height,
fps,
codec,
container,
bitrate,
startTime,
endTime,
exportMode,
includeAudio,
audioSampleRate: 48000,
audioBitrate: 192000,
normalizeAudio: true,
});

activeExporter = exporter;
lastProgress = null;

// Start export tracking in timeline store
timelineStore.startExport(startTime, endTime);

try {
const blob = await exporter.export((p: ExportProgress) => {
lastProgress = p;
timelineStore.setExportProgress(p.percent, p.currentTime);
});

if (blob) {
downloadBlob(blob, `${filename}.${fileExtension}`);
log.info('Export complete', { filename: `${filename}.${fileExtension}`, size: blob.size });
return {
success: true,
data: {
filename: `${filename}.${fileExtension}`,
size: blob.size,
sizeFormatted: formatBytes(blob.size),
width,
height,
fps,
codec,
container,
duration: endTime - startTime,
message: 'Export complete. File download triggered in browser.',
},
};
} else {
return { success: false, error: 'Export produced no output (cancelled or failed)' };
}
} catch (e) {
log.error('AI export failed', e);
return { success: false, error: e instanceof Error ? e.message : 'Export failed' };
} finally {
activeExporter = null;
lastProgress = null;
timelineStore.endExport();
}
}

export async function handleCancelExport(): Promise<ToolResult> {
if (!activeExporter) {
return { success: false, error: 'No export in progress' };
}

activeExporter.cancel();
activeExporter = null;
lastProgress = null;

const timelineStore = useTimelineStore.getState();
timelineStore.endExport();

return { success: true, data: { message: 'Export cancelled' } };
}

export async function handleGetExportStatus(): Promise<ToolResult> {
const timelineStore = useTimelineStore.getState();

if (!timelineStore.isExporting) {
return {
success: true,
data: {
isExporting: false,
message: 'No export in progress',
},
};
}

return {
success: true,
data: {
isExporting: true,
progress: lastProgress ? {
phase: lastProgress.phase,
percent: lastProgress.percent,
currentFrame: lastProgress.currentFrame,
totalFrames: lastProgress.totalFrames,
currentTime: lastProgress.currentTime,
estimatedTimeRemaining: lastProgress.estimatedTimeRemaining,
} : null,
exportRange: timelineStore.exportRange,
},
};
}

function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
14 changes: 14 additions & 0 deletions src/services/aiTools/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ import {
handleGetStatsHistory,
} from './stats';

import {
handleStartExport,
handleCancelExport,
handleGetExportStatus,
} from './export';

// Handler registry - maps tool names to handler functions
const timelineHandlers: Record<string, (args: Record<string, unknown>, store: ReturnType<typeof useTimelineStore.getState>) => Promise<ToolResult>> = {
getTimelineState: handleGetTimelineState,
Expand Down Expand Up @@ -225,6 +231,10 @@ const selfContainedHandlers: Record<string, (args: Record<string, unknown>) => P
getStatsHistory: handleGetStatsHistory,
getLogs: handleGetLogs,
getPlaybackTrace: handleGetPlaybackTrace,
// Export
startExport: handleStartExport,
cancelExport: async () => handleCancelExport(),
getExportStatus: async () => handleGetExportStatus(),
};

// YouTube handlers - self-contained, fetch their own stores
Expand Down Expand Up @@ -363,4 +373,8 @@ export {
handleGetLogs,
handleGetPlaybackTrace,
handleGetStatsHistory,
// Export
handleStartExport,
handleCancelExport,
handleGetExportStatus,
};
5 changes: 5 additions & 0 deletions src/services/aiTools/policy/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,11 @@ const TOOL_POLICY_MAP = new Map<string, ToolPolicyEntry>([
// searchVideos is the definition name for the same handler as searchYouTube
['searchVideos', mutatingLow()],
['listVideoFormats', mutatingLow()],

// ── EXPORT ──────────────────────────────────────────────────────────
['startExport', mutatingHigh()],
['cancelExport', mutatingLow()],
['getExportStatus', readOnly()],
]);

/**
Expand Down
Loading