Skip to content

Commit ba6fe69

Browse files
committed
fix(media): preflight all writable outputs
1 parent 1abcde7 commit ba6fe69

10 files changed

Lines changed: 100 additions & 45 deletions

File tree

apps/sim/lib/copilot/generated/tool-catalog-v1.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1630,7 +1630,7 @@ export const GenerateAudio: ToolCatalogEntry = {
16301630
},
16311631
voice: { type: 'string', description: 'Optional voice name or id for speech.' },
16321632
},
1633-
required: ['prompt'],
1633+
required: ['prompt', 'outputs'],
16341634
},
16351635
requiredPermission: 'write',
16361636
capabilities: ['file_input', 'file_output', 'generated_media'],
@@ -1713,7 +1713,7 @@ export const GenerateImage: ToolCatalogEntry = {
17131713
'Detailed text description of the image to generate, or editing instructions when editing the image(s) passed in `inputs.files`.',
17141714
},
17151715
},
1716-
required: ['prompt'],
1716+
required: ['prompt', 'outputs'],
17171717
},
17181718
requiredPermission: 'write',
17191719
capabilities: ['file_input', 'file_output', 'generated_media'],
@@ -1836,7 +1836,7 @@ export const GenerateVideo: ToolCatalogEntry = {
18361836
enum: ['720p', '1080p', '4k'],
18371837
},
18381838
},
1839-
required: ['prompt'],
1839+
required: ['prompt', 'outputs'],
18401840
},
18411841
requiredPermission: 'write',
18421842
capabilities: ['file_input', 'file_output', 'generated_media'],

apps/sim/lib/copilot/generated/tool-schemas-v1.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1441,7 +1441,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
14411441
description: 'Optional voice name or id for speech.',
14421442
},
14431443
},
1444-
required: ['prompt'],
1444+
required: ['prompt', 'outputs'],
14451445
},
14461446
resultSchema: undefined,
14471447
},
@@ -1518,7 +1518,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
15181518
'Detailed text description of the image to generate, or editing instructions when editing the image(s) passed in `inputs.files`.',
15191519
},
15201520
},
1521-
required: ['prompt'],
1521+
required: ['prompt', 'outputs'],
15221522
},
15231523
resultSchema: undefined,
15241524
},
@@ -1635,7 +1635,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
16351635
enum: ['720p', '1080p', '4k'],
16361636
},
16371637
},
1638-
required: ['prompt'],
1638+
required: ['prompt', 'outputs'],
16391639
},
16401640
resultSchema: undefined,
16411641
},

apps/sim/lib/copilot/tools/server/image/generate-image.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,11 @@ describe('generateImageServerTool', () => {
172172

173173
it('rejects explicitly empty input and output containers before generation', async () => {
174174
const emptyInputs = await generateImageServerTool.execute(
175-
{ prompt: 'Edit the supplied image', inputs: {} },
175+
{
176+
prompt: 'Edit the supplied image',
177+
inputs: {},
178+
outputs: { files: [{ path: 'files/result.png' }] },
179+
},
176180
CONTEXT
177181
)
178182
const emptyOutputs = await generateImageServerTool.execute(

apps/sim/lib/copilot/tools/server/image/generate-image.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,10 +163,9 @@ export const generateImageServerTool: BaseServerTool<GenerateImageArgs, Generate
163163
}
164164
}
165165

166-
const ext = mimeType.includes('jpeg') || mimeType.includes('jpg') ? '.jpg' : '.png'
167-
const resolvedOutputPath = outputFile?.path || `files/generated-image${ext}`
166+
const resolvedOutputPath = outputFile.path
168167
const imageBuffer = Buffer.from(imageBase64, 'base64')
169-
const mode = outputFile?.mode ?? 'create'
168+
const mode = outputFile.mode
170169

171170
assertServerToolNotAborted(context)
172171
const written = await writeWorkspaceFileByPath({
@@ -175,7 +174,7 @@ export const generateImageServerTool: BaseServerTool<GenerateImageArgs, Generate
175174
target: {
176175
path: resolvedOutputPath,
177176
mode,
178-
mimeType: outputFile?.mimeType,
177+
mimeType: outputFile.mimeType,
179178
},
180179
buffer: imageBuffer,
181180
inferredMimeType: mimeType,

apps/sim/lib/copilot/tools/server/media/ffmpeg.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,14 @@ export const ffmpegServerTool: BaseServerTool<FfmpegArgs, FfmpegResult> = {
8282
}
8383

8484
try {
85-
const outputFile = await prepareMediaOutput({
86-
output: params.outputs,
87-
workspaceId,
88-
userId: context.userId,
89-
})
85+
const outputFile =
86+
params.operation === 'probe'
87+
? undefined
88+
: await prepareMediaOutput({
89+
output: params.outputs,
90+
workspaceId,
91+
userId: context.userId,
92+
})
9093

9194
const mediaFiles: MediaFile[] = []
9295
for (const filePath of inputPaths) {
@@ -127,18 +130,21 @@ export const ffmpegServerTool: BaseServerTool<FfmpegArgs, FfmpegResult> = {
127130
}
128131
}
129132

130-
if (!result.buffer || !result.ext) {
133+
if (!result.buffer) {
131134
return { success: false, message: `ffmpeg ${params.operation} produced no output` }
132135
}
136+
if (!outputFile) {
137+
return { success: false, message: `ffmpeg ${params.operation} requires an output file` }
138+
}
133139

134-
const outputPath = outputFile?.path || `files/ffmpeg-${params.operation}.${result.ext}`
135-
const mode = outputFile?.mode ?? 'create'
140+
const outputPath = outputFile.path
141+
const mode = outputFile.mode
136142

137143
assertServerToolNotAborted(context)
138144
const written = await writeWorkspaceFileByPath({
139145
workspaceId,
140146
userId: context.userId,
141-
target: { path: outputPath, mode, mimeType: outputFile?.mimeType },
147+
target: { path: outputPath, mode, mimeType: outputFile.mimeType },
142148
buffer: result.buffer,
143149
inferredMimeType: result.contentType || 'application/octet-stream',
144150
})

apps/sim/lib/copilot/tools/server/media/file-paths.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ vi.mock('@/lib/copilot/vfs/resource-writer', () => ({
2323

2424
import {
2525
getSingleMediaFileDeclaration,
26+
prepareMediaOutput,
2627
resolveMediaInputFile,
2728
validateMediaOutputFile,
2829
} from '@/lib/copilot/tools/server/media/file-paths'
@@ -50,6 +51,20 @@ describe('media file paths', () => {
5051
expect(mocks.resolveWorkspaceFileReference).not.toHaveBeenCalled()
5152
})
5253

54+
it('resolves uploads/ inputs with the read-compatible /content suffix', async () => {
55+
mocks.resolveChatUpload.mockResolvedValue(FILE_RECORD)
56+
57+
await expect(
58+
resolveMediaInputFile({
59+
workspaceId: 'workspace-1',
60+
chatId: 'chat-1',
61+
path: 'uploads/My%20Portrait.png/content',
62+
})
63+
).resolves.toBe(FILE_RECORD)
64+
65+
expect(mocks.resolveChatUpload).toHaveBeenCalledWith('My%20Portrait.png', 'chat-1')
66+
})
67+
5368
it('rejects unresolved inputs instead of dropping them', async () => {
5469
mocks.resolveWorkspaceFileReference.mockResolvedValue(null)
5570

@@ -155,4 +170,15 @@ describe('media file paths', () => {
155170
'Output requires exactly one file; received 0'
156171
)
157172
})
173+
174+
it('requires an explicit output before provider work can begin', async () => {
175+
await expect(
176+
prepareMediaOutput({
177+
workspaceId: 'workspace-1',
178+
userId: 'user-1',
179+
})
180+
).rejects.toThrow('Output requires exactly one file; received 0')
181+
182+
expect(mocks.validateWorkspaceFileWriteTarget).not.toHaveBeenCalled()
183+
})
158184
})

apps/sim/lib/copilot/tools/server/media/file-paths.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ export async function resolveMediaInputFile(args: {
4949
let file: WorkspaceFileRecord | null
5050

5151
if (path.startsWith(UPLOADS_PREFIX)) {
52-
const filename = path.slice(UPLOADS_PREFIX.length)
53-
if (!filename || filename.includes('/')) {
52+
const filename = path.slice(UPLOADS_PREFIX.length).split('/')[0]
53+
if (!filename) {
5454
throw new Error(`Upload input path must identify a file: ${args.path}`)
5555
}
5656
if (!args.chatId) {
@@ -68,7 +68,7 @@ export async function resolveMediaInputFile(args: {
6868
}
6969

7070
/**
71-
* Preflight a user-supplied media output through the canonical workspace writer policy.
71+
* Preflight a media output through the canonical workspace writer policy.
7272
*/
7373
export async function validateMediaOutputFile(args: {
7474
workspaceId: string
@@ -93,10 +93,8 @@ export async function prepareMediaOutput<T extends MediaOutputDeclaration>(args:
9393
output?: { files?: T[] }
9494
workspaceId: string
9595
userId: string
96-
}): Promise<(T & { mode: WorkspaceFileWriteMode }) | undefined> {
97-
if (!args.output) return undefined
98-
99-
const file = getSingleMediaFileDeclaration(args.output.files, 'Output')
96+
}): Promise<T & { mode: WorkspaceFileWriteMode }> {
97+
const file = getSingleMediaFileDeclaration(args.output?.files, 'Output')
10098
const output = { ...file, mode: file.mode ?? 'create' }
10199
await validateMediaOutputFile({
102100
workspaceId: args.workspaceId,

apps/sim/lib/copilot/tools/server/media/generate-audio.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,6 @@ interface GenerateAudioResult {
4646
_serviceCost?: { service: string; cost: number }
4747
}
4848

49-
function audioExtFromContentType(contentType: string): string {
50-
if (contentType.includes('wav')) return 'wav'
51-
if (contentType.includes('mp4') || contentType.includes('m4a')) return 'm4a'
52-
if (contentType.includes('ogg')) return 'ogg'
53-
if (contentType.includes('flac')) return 'flac'
54-
if (contentType.includes('aac')) return 'aac'
55-
if (contentType.includes('opus')) return 'opus'
56-
return 'mp3'
57-
}
58-
5949
export const generateAudioServerTool: BaseServerTool<GenerateAudioArgs, GenerateAudioResult> = {
6050
name: GenerateAudio.id,
6151

@@ -123,15 +113,14 @@ export const generateAudioServerTool: BaseServerTool<GenerateAudioArgs, Generate
123113
voiceSampleDataUri,
124114
})
125115

126-
const ext = audioExtFromContentType(result.contentType)
127-
const outputPath = outputFile?.path || `files/generated-audio.${ext}`
128-
const mode = outputFile?.mode ?? 'create'
116+
const outputPath = outputFile.path
117+
const mode = outputFile.mode
129118

130119
assertServerToolNotAborted(context)
131120
const written = await writeWorkspaceFileByPath({
132121
workspaceId,
133122
userId: context.userId,
134-
target: { path: outputPath, mode, mimeType: outputFile?.mimeType },
123+
target: { path: outputPath, mode, mimeType: outputFile.mimeType },
135124
buffer: result.buffer,
136125
inferredMimeType: result.contentType,
137126
})

apps/sim/lib/copilot/tools/server/media/generate-video.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export const generateVideoServerTool: BaseServerTool<GenerateVideoArgs, Generate
6666
workspaceId,
6767
userId: context.userId,
6868
})
69-
const outputPath = outputFile?.path ?? 'files/generated-video.mp4'
69+
const outputPath = outputFile.path
7070

7171
let imageDataUri: string | undefined
7272
const inputFile = params.inputs
@@ -101,13 +101,13 @@ export const generateVideoServerTool: BaseServerTool<GenerateVideoArgs, Generate
101101
imageDataUri,
102102
})
103103

104-
const mode = outputFile?.mode ?? 'create'
104+
const mode = outputFile.mode
105105

106106
assertServerToolNotAborted(context)
107107
const written = await writeWorkspaceFileByPath({
108108
workspaceId,
109109
userId: context.userId,
110-
target: { path: outputPath, mode, mimeType: outputFile?.mimeType },
110+
target: { path: outputPath, mode, mimeType: outputFile.mimeType },
111111
buffer: result.buffer,
112112
inferredMimeType: result.contentType,
113113
})

apps/sim/lib/copilot/tools/server/media/media-tools-validation.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,11 @@ describe('media tool path validation', () => {
9494
mocks.resolveWorkspaceFileReference.mockResolvedValue(null)
9595

9696
const result = await generateAudioServerTool.execute(
97-
{ prompt: 'Clone this voice', inputs: { files: [{ path: '' }] } },
97+
{
98+
prompt: 'Clone this voice',
99+
inputs: { files: [{ path: '' }] },
100+
outputs: { files: [{ path: 'files/voice.mp3' }] },
101+
},
98102
CONTEXT
99103
)
100104

@@ -140,6 +144,34 @@ describe('media tool path validation', () => {
140144
expect(mocks.runFfmpegOperation).not.toHaveBeenCalled()
141145
})
142146

147+
it('ignores unused output declarations for ffmpeg probes', async () => {
148+
mocks.resolveWorkspaceFileReference.mockResolvedValue(UPLOAD_RECORD)
149+
mocks.runFfmpegOperation.mockResolvedValue({ probe: { duration: 3 } })
150+
151+
const result = await ffmpegServerTool.execute(
152+
{
153+
operation: 'probe',
154+
inputs: { files: [{ path: 'files/input.mov' }] },
155+
outputs: { files: [{ path: 'uploads/unused.json' }] },
156+
},
157+
CONTEXT
158+
)
159+
160+
expect(result.success).toBe(true)
161+
expect(mocks.validateWorkspaceFileWriteTarget).not.toHaveBeenCalled()
162+
expect(mocks.writeWorkspaceFileByPath).not.toHaveBeenCalled()
163+
})
164+
165+
it('rejects missing generated-media outputs before calling the provider', async () => {
166+
const result = await generateVideoServerTool.execute(
167+
{ prompt: 'Create a launch video' },
168+
CONTEXT
169+
)
170+
171+
expect(result.success).toBe(false)
172+
expect(mocks.generateFalVideo).not.toHaveBeenCalled()
173+
})
174+
143175
it('passes uploads/ media files to ffmpeg', async () => {
144176
mocks.resolveChatUpload.mockResolvedValue(UPLOAD_RECORD)
145177

@@ -166,6 +198,7 @@ describe('media tool path validation', () => {
166198
{
167199
prompt: 'Animate these portraits',
168200
inputs: { files: [{ path: 'files/one.png' }, { path: 'files/two.png' }] },
201+
outputs: { files: [{ path: 'files/output.mp4' }] },
169202
},
170203
CONTEXT
171204
)

0 commit comments

Comments
 (0)