diff --git a/.changeset/assembly-input-url.md b/.changeset/assembly-input-url.md new file mode 100644 index 00000000..068c0bc7 --- /dev/null +++ b/.changeset/assembly-input-url.md @@ -0,0 +1,9 @@ +--- +'@transloadit/node': minor +'transloadit': minor +'@transloadit/types': minor +'@transloadit/zod': minor +'@transloadit/mcp-server': patch +--- + +Contract: add the `input_url` Assembly parameter (transloadit/api2#10292), which imports one or more URLs as the input files of a Builtin Template selected with `template_id`, and share the Builtins that accept it with the MCP Builtin manifest. diff --git a/.changeset/builtin-input-url.md b/.changeset/builtin-input-url.md new file mode 100644 index 00000000..d728b3aa --- /dev/null +++ b/.changeset/builtin-input-url.md @@ -0,0 +1,5 @@ +--- +'@transloadit/mcp-server': minor +--- + +Builtin shortcuts send URL inputs to Transloadit as `input_url` with `template_id` (transloadit/api2#10292) when every file and attachment of the call is a URL and the Builtin accepts it. Transloadit fetches the files itself, so the Assembly is created at once, the MCP server downloads nothing and large files no longer run into the wait budget. Mixed inputs, named inputs, `upload_behavior: 'none'` and other Builtins keep the download-and-upload path; this needs an API2 with `input_url` support. diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index e7f8048b..5ecc8a2a 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -526,6 +526,18 @@ For URL inputs, behavior depends on the template/instructions: - If the template does not take input files, URL inputs are ignored and a warning is returned. - If `allow_steps_override=false` and only `/http/import` would work, URL inputs are rejected. +The Builtin shortcuts for `caption-video`, `convert-to-pdf`, `encode-video`, `extract-text`, +`prepare-image`, `remove-background`, `transcribe` and `video-covers` send URLs to API2 as +[`input_url`](https://github.com/transloadit/api2/pull/10292) next to `template_id` when every file +and attachment of the call is a URL (up to 10, one for `caption-video`). Transloadit then fetches +the files itself, so the Assembly exists at once and nothing is downloaded by the MCP server or held +up by a large file. API2 names the files after their URLs (or redirects and `Content-Disposition`), +so inputs given a file name, such as host attachments, keep the download path above, as do calls +that mix URLs with base64 files or sandbox uploads, calls with `upload_behavior: 'none'`, and URLs +that API2 would refuse (whitespace, `|`, `${…}`, over 4096 characters). If API2 refuses +`input_url` for the request, its error is returned like any other. The eligible Builtins are listed +in the [alphalib Builtin manifest](../node/src/alphalib/types/builtinTemplates.ts). + ## Local vs hosted file access - MCP accepts base64 and URL inputs in both self-hosted and hosted deployments. Local filesystem diff --git a/packages/mcp-server/src/builtin-shortcuts.ts b/packages/mcp-server/src/builtin-shortcuts.ts index c1ff7880..109ce14a 100644 --- a/packages/mcp-server/src/builtin-shortcuts.ts +++ b/packages/mcp-server/src/builtin-shortcuts.ts @@ -1,9 +1,10 @@ -import type { Transloadit } from '@transloadit/node' +import type { InputFile, Transloadit } from '@transloadit/node' import type { BuiltinTemplateCatalogItem } from '@transloadit/zod/v4/builtinTemplates' import type { ValidateFunction } from 'ajv' import { builtinTemplateCatalogItemSchema, + mcpBuiltinInputUrlMaxCounts, mcpBuiltinTemplateManifest, } from '@transloadit/zod/v4/builtinTemplates' import { Ajv } from 'ajv' @@ -167,6 +168,42 @@ export const validateBuiltinFields = ( return merged } +// API2's literal-URL rules for `input_url`; a URL it would refuse keeps today's download path. +const isLiteralHttpUrl = (value: string): boolean => { + if ([...value].length > 4096 || /\s/u.test(value) || value.includes('${') || value.includes('|')) + return false + if (!URL.canParse(value)) return false + const { protocol } = new URL(value) + return protocol === 'http:' || protocol === 'https:' +} + +/** + * The URLs a shortcut call hands to API2 as `input_url`, so API2 imports them itself and nothing + * is downloaded here. Only for eligible Builtins and calls whose every input is such a URL; + * anything else (local, base64 or sandbox inputs, too many URLs, given file names) + * returns undefined and keeps the download-and-upload path. + */ +export const selectBuiltinInputUrls = ( + templateId: string, + inputs: readonly InputFile[], + expectedUploads = 0, +): string[] | undefined => { + const maxUrls = mcpBuiltinInputUrlMaxCounts[templateId] + if (maxUrls === undefined || inputs.length === 0 || inputs.length > maxUrls) return undefined + // expected_uploads counts every input; more than the URLs means sandbox uploads follow. + if (expectedUploads > inputs.length) return undefined + const urls: string[] = [] + for (const input of inputs) { + if (input.kind !== 'url' || !isLiteralHttpUrl(input.url)) return undefined + // API2 names an imported file after its URL, its redirects or Content-Disposition. A given + // name (of a host attachment, say) may carry the extension processing depends on, and only an + // upload keeps it. + if (input.filename !== undefined) return undefined + urls.push(input.url) + } + return urls +} + /** Read approved Builtins with a dedicated, retry-disabled client; callers never share catalogs. */ export const loadBuiltinShortcuts = async ( client: Transloadit, diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 499a24de..789c80be 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -34,6 +34,7 @@ import { BuiltinFieldValidationError, builtinShortcuts, loadBuiltinShortcuts, + selectBuiltinInputUrls, validateBuiltinFields, } from './builtin-shortcuts.ts' import { buildBearerChallenge, extractBearerToken } from './http-helpers.ts' @@ -1920,8 +1921,28 @@ export const createTransloaditMcpServer = ( } return buildShortcutError('mcp_invalid_builtin_fields', error.message, { path: 'fields' }) } + // upload_behavior none asks for reserved upload URLs, which only the upload path hands out. + const inputUrls = + args.upload_behavior === 'none' + ? undefined + : selectBuiltinInputUrls( + template.id, + [...(args.files ?? []), ...toAttachmentInputs(args.attachments ?? [])], + args.expected_uploads, + ) + // API2 imports eligible URLs itself (api2#10292), so the Assembly exists at once and nothing + // is downloaded here. A refusal is API2's to explain, so it surfaces like any other error. const result = await createAssembly( - { ...args, fields, instructions: { template_id: template.id } }, + inputUrls + ? { + ...args, + files: undefined, + attachments: undefined, + expected_uploads: undefined, + fields, + instructions: { template_id: template.id, input_url: inputUrls }, + } + : { ...args, fields, instructions: { template_id: template.id } }, extra, true, startedAt, diff --git a/packages/mcp-server/test/e2e/url-import.test.ts b/packages/mcp-server/test/e2e/url-import.test.ts index 3fd00b58..0ea65a43 100644 --- a/packages/mcp-server/test/e2e/url-import.test.ts +++ b/packages/mcp-server/test/e2e/url-import.test.ts @@ -1,5 +1,6 @@ import type { Client } from '@modelcontextprotocol/sdk/client' +import { Transloadit } from '@transloadit/node' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { createMcpClient, isRecord, parseToolPayload } from './mcp-client.ts' @@ -7,6 +8,7 @@ import { createMcpClient, isRecord, parseToolPayload } from './mcp-client.ts' const shouldRun = process.env.TRANSLOADIT_KEY != null && process.env.TRANSLOADIT_SECRET != null const maybeDescribe = shouldRun ? describe : describe.skip +const prepareImage = 'builtin/prepare-image@0.0.1' const demoImage = 'https://demos.transloadit.com/66/01604e7d0248109df8c7cc0f8daef8/snowflake.jpg' maybeDescribe('mcp-server URL inputs (stdio)', { timeout: 60000 }, () => { @@ -105,4 +107,46 @@ maybeDescribe('mcp-server URL inputs (stdio)', { timeout: 60000 }, () => { expect(Array.isArray(resized)).toBe(true) expect(resized).toHaveLength(2) }) + + // Until api2#10292 runs on every API2 host, an older host ignores input_url and completes the + // Builtin without input files. Asking API2 directly first tells that apart from an MCP bug. Only + // that exact old-host outcome skips; any error fails. The release waits for the rollout, so once + // it is done, drop the skip and assert the direct import instead. + it('lets API2 import a URL for a Builtin shortcut', async (context) => { + const api = new Transloadit({ + authKey: process.env.TRANSLOADIT_KEY ?? '', + authSecret: process.env.TRANSLOADIT_SECRET ?? '', + endpoint: process.env.TRANSLOADIT_ENDPOINT, + }) + const direct = await api.createAssembly({ + params: { template_id: prepareImage, input_url: [demoImage], fields: { w: 64, h: 64 } }, + waitForCompletion: true, + }) + expect(direct.error).toBeUndefined() + expect(direct.ok).toBe('ASSEMBLY_COMPLETED') + context.skip( + direct.num_input_files === 0, + `API2 build ${direct.build_id ?? 'unknown'} ignored input_url (api2#10292 not on this host yet). Re-run after the rollout.`, + ) + expect(direct.num_input_files).toBe(1) + + const result = await client.callTool({ + name: 'transloadit_prepare_image', + arguments: { + fields: { w: 64, h: 64 }, + files: [{ kind: 'url', field: 'remote', url: demoImage }], + wait_for_completion: true, + }, + }) + + const payload = parseToolPayload(result) + expect(payload.status).toBe('ok') + // Nothing went through the MCP server: API2's imported job supplied the input file. + expect(payload.upload).toMatchObject({ total_files: 0 }) + const assembly = isRecord(payload.assembly) ? payload.assembly : {} + expect(assembly.ok).toBe('ASSEMBLY_COMPLETED') + expect(assembly.num_input_files).toBe(1) + expect(assembly.uploads).toEqual([]) + expect(Object.values(isRecord(assembly.results) ? assembly.results : {}).flat()).not.toEqual([]) + }) }) diff --git a/packages/mcp-server/test/unit/builtin-input-url.test.ts b/packages/mcp-server/test/unit/builtin-input-url.test.ts new file mode 100644 index 00000000..30491660 --- /dev/null +++ b/packages/mcp-server/test/unit/builtin-input-url.test.ts @@ -0,0 +1,310 @@ +import type { AssemblyStatus, CreateAssemblyOptions, InputFile } from '@transloadit/node' + +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { ApiError, Transloadit } from '@transloadit/node' +import { + mcpBuiltinInputUrlMaxCounts, + mcpBuiltinTemplateManifest, +} from '@transloadit/zod/v4/builtinTemplates' +import nock from 'nock' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { selectBuiltinInputUrls } from '../../src/builtin-shortcuts.ts' +import { createTransloaditMcpServer } from '../../src/server.ts' + +const assemblyId = '0123456789abcdef0123456789abcdef' +const executing: AssemblyStatus = { + ok: 'ASSEMBLY_EXECUTING', + assembly_id: assemblyId, + assembly_ssl_url: `https://api2.transloadit.com/assemblies/${assemblyId}`, + num_input_files: 0, +} +const completed: AssemblyStatus = { ...executing, ok: 'ASSEMBLY_COMPLETED' } +// TEST-NET-2: public to the download guard, and never resolved through DNS. +const origin = 'http://198.51.100.10' +const photo = `${origin}/photo.jpg` +const other = `${origin}/other.jpg` + +const catalogEntry = (id: string) => ({ + id, + name: id, + require_signature_auth: 0, + fields_schema: { type: 'object', properties: {} }, + content: { steps: { ':original': { robot: '/upload/handle' } } }, +}) + +const url = (value: string, field = 'file'): InputFile => ({ kind: 'url', field, url: value }) + +describe('Builtin input_url eligibility', () => { + it('pins the API2 Builtins that accept input_url to approved MCP versions', () => { + // api2#10292 builtinInputImportTemplateNames; capture-webpage keeps its own URL field. + expect(mcpBuiltinInputUrlMaxCounts).toEqual({ + 'builtin/caption-video@0.0.1': 1, + 'builtin/convert-to-pdf@0.0.1': 10, + 'builtin/encode-video@0.0.1': 10, + 'builtin/extract-text@0.0.1': 10, + 'builtin/prepare-image@0.0.1': 10, + 'builtin/remove-background@0.0.1': 10, + 'builtin/transcribe@0.0.1': 10, + 'builtin/video-covers@0.0.1': 10, + }) + for (const id of Object.keys(mcpBuiltinInputUrlMaxCounts)) { + expect(mcpBuiltinTemplateManifest).toHaveProperty([id]) + } + }) + + it('selects URLs only when every input is a literal HTTP(S) URL within the limit', () => { + const id = 'builtin/prepare-image@0.0.1' + expect(selectBuiltinInputUrls(id, [url(photo, 'a'), url(other, 'b')])).toEqual([photo, other]) + expect(selectBuiltinInputUrls(id, [url(photo)], 1)).toEqual([photo]) + expect(selectBuiltinInputUrls(id, [])).toBeUndefined() + expect(selectBuiltinInputUrls(id, [url(photo)], 2)).toBeUndefined() + expect( + selectBuiltinInputUrls(id, [ + url(photo), + { kind: 'base64', field: 'b', base64: 'aGk=', filename: 'b.txt' }, + ]), + ).toBeUndefined() + expect(selectBuiltinInputUrls(id, [{ kind: 'path', field: 'p', path: '/tmp/p' }])).toBe( + undefined, + ) + expect( + selectBuiltinInputUrls( + id, + Array.from({ length: 11 }, (_, i) => url(`${origin}/${i}.jpg`, `f${i}`)), + ), + ).toBeUndefined() + expect(selectBuiltinInputUrls('builtin/capture-webpage@0.0.1', [url(photo)])).toBeUndefined() + expect(selectBuiltinInputUrls('builtin/prepare-image@0.0.2', [url(photo)])).toBeUndefined() + }) + + it('keeps only one URL for caption-video', () => { + const id = 'builtin/caption-video@0.0.1' + expect(selectBuiltinInputUrls(id, [url(photo)])).toEqual([photo]) + expect(selectBuiltinInputUrls(id, [url(photo, 'a'), url(other, 'b')])).toBeUndefined() + }) + + it.each([ + 'ftp://example.com/a.jpg', + '/relative.jpg', + 'https://example.com/a b.jpg', + 'https://example.com/${fields.secret}', + 'https://example.com/a.jpg|https://example.com/b.jpg', + `https://example.com/${'a'.repeat(4096)}`, + ])('leaves URLs API2 would refuse to the download path: %s', (value) => { + expect(selectBuiltinInputUrls('builtin/prepare-image@0.0.1', [url(value)])).toBeUndefined() + }) +}) + +describe('Builtin shortcuts with URL inputs', () => { + let client: Client | undefined + let server: ReturnType | undefined + const creations: CreateAssemblyOptions[] = [] + + const call = async (name: string, id: string, args: Record) => { + vi.spyOn(Transloadit.prototype, 'listTemplates').mockResolvedValue({ + items: [catalogEntry(id)], + count: 1, + }) + vi.spyOn(Transloadit.prototype, 'getTemplate').mockResolvedValue({ + ok: 'TEMPLATE_FOUND', + message: 'Found', + id, + name: id, + require_signature_auth: 0, + content: { + allow_steps_override: false, + steps: { ':original': { robot: '/upload/handle' } }, + }, + }) + server = createTransloaditMcpServer({ authKey: 'key', authSecret: 'secret' }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + client = new Client({ name: 'builtin-input-url', version: '1.0.0' }) + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) + return client.callTool({ name, arguments: args }) + } + + /** Each creation answers with the next status; a function rejects instead. */ + const createReturns = (...statuses: Array never)>) => { + let index = 0 + vi.spyOn(Transloadit.prototype, 'createAssembly').mockImplementation((options) => { + creations.push(options ?? {}) + const next = statuses[Math.min(index, statuses.length - 1)] + index += 1 + if (typeof next === 'function') { + return Object.assign(Promise.resolve().then(next), { assemblyId }) + } + if (!next) throw new Error('Expected a status for every creation') + options?.onAssemblyCreated?.(next) + return Object.assign(Promise.resolve(next), { assemblyId }) + }) + } + + afterEach(async () => { + await client?.close() + await server?.close() + vi.restoreAllMocks() + nock.cleanAll() + creations.length = 0 + }) + + it('sends URL files and attachments as input_url with template_id and downloads nothing', async () => { + const download = nock(origin).get(/.*/).reply(200, 'bytes') + createReturns(executing) + const status = vi.spyOn(Transloadit.prototype, 'getAssembly') + + const result = await call('transloadit_prepare_image', 'builtin/prepare-image@0.0.1', { + fields: { w: 320 }, + files: [url(photo)], + attachments: [{ download_url: other, file_id: 'file_1' }], + expected_uploads: 2, + }) + + expect(result.isError).toBeUndefined() + expect(result.structuredContent).toMatchObject({ + status: 'ok', + assembly: { assembly_id: assemblyId }, + upload: { status: 'none', total_files: 0 }, + }) + expect(creations).toHaveLength(1) + expect(creations[0]).toMatchObject({ files: {}, uploads: {}, expectedUploads: undefined }) + expect(creations[0]?.params).toEqual({ + template_id: 'builtin/prepare-image@0.0.1', + input_url: [photo, other], + fields: { w: 320 }, + }) + expect(download.isDone()).toBe(false) + // API2 with input_url runs on every host, so nothing checks whether it was used. + expect(status).not.toHaveBeenCalled() + }) + + it('sends one caption-video URL as input_url but downloads two', async () => { + createReturns({ ...completed, num_input_files: 1 }) + await call('transloadit_caption_video', 'builtin/caption-video@0.0.1', { + files: [url(photo)], + wait_for_completion: true, + }) + expect(creations[0]?.params).toMatchObject({ input_url: [photo] }) + + await client?.close() + await server?.close() + vi.restoreAllMocks() + creations.length = 0 + const downloads = nock(origin) + .get(/\.jpg$/) + .times(2) + .reply(200, 'bytes') + createReturns({ ...completed, num_input_files: 2 }) + await call('transloadit_caption_video', 'builtin/caption-video@0.0.1', { + files: [url(photo, 'a'), url(other, 'b')], + wait_for_completion: true, + }) + expect(downloads.isDone()).toBe(true) + expect(creations).toHaveLength(1) + expect(creations[0]?.params).not.toHaveProperty('input_url') + expect(Object.keys(creations[0]?.files ?? {})).toEqual(['a', 'b']) + }) + + it.each([ + { + name: 'a URL next to a base64 file', + args: { + files: [url(photo), { kind: 'base64', field: 'inline', base64: 'aGk=', filename: 'a.txt' }], + }, + files: ['file', 'inline'], + }, + { name: 'a URL with sandbox uploads', args: { files: [url(photo)], expected_uploads: 2 } }, + ])('keeps the download path for $name', async ({ args, files = ['file'] }) => { + const download = nock(origin).get('/photo.jpg').reply(200, 'bytes') + createReturns(executing) + await call('transloadit_prepare_image', 'builtin/prepare-image@0.0.1', args) + expect(download.isDone()).toBe(true) + expect(creations).toHaveLength(1) + expect(creations[0]?.params).not.toHaveProperty('input_url') + expect(Object.keys(creations[0]?.files ?? {})).toEqual(files) + }) + + it('keeps the download path for Builtins that do not accept input_url', async () => { + const download = nock(origin).get('/photo.jpg').reply(200, 'bytes') + createReturns(executing) + await call('transloadit_capture_webpage', 'builtin/capture-webpage@0.0.1', { + files: [url(photo)], + }) + expect(download.isDone()).toBe(true) + expect(creations[0]?.params).not.toHaveProperty('input_url') + }) + + it('returns an Assembly that ran without input files as API2 reported it', async () => { + const download = nock(origin).get('/photo.jpg').reply(200, 'bytes') + createReturns(completed) + const result = await call('transloadit_prepare_image', 'builtin/prepare-image@0.0.1', { + files: [url(photo)], + wait_for_completion: true, + }) + expect(creations).toHaveLength(1) + expect(download.isDone()).toBe(false) + expect(result.structuredContent).toMatchObject({ + status: 'ok', + assembly: { assembly_id: assemblyId, ok: 'ASSEMBLY_COMPLETED', num_input_files: 0 }, + }) + }) + + it('surfaces API2’s refusal of input_url instead of downloading the URLs', async () => { + const download = nock(origin).get('/photo.jpg').reply(200, 'bytes') + const reason = + '“input_url” is only supported with Builtin Templates that accept an input file. Select one with “template_id”, or import the URLs with an /http/import Step instead.' + createReturns(() => { + throw new ApiError({ body: { error: 'INVALID_FORM_DATA', reason } }) + }, executing) + const result = await call('transloadit_prepare_image', 'builtin/prepare-image@0.0.1', { + files: [url(photo)], + }) + expect(result.isError).toBe(true) + expect(creations).toHaveLength(1) + expect(creations[0]?.params).toMatchObject({ input_url: [photo] }) + expect(download.isDone()).toBe(false) + expect(JSON.stringify(result.content)).toContain('INVALID_FORM_DATA') + }) + + it('keeps upload reservations for upload_behavior none', async () => { + nock(origin).get('/photo.jpg').reply(200, 'bytes') + createReturns(executing) + await call('transloadit_prepare_image', 'builtin/prepare-image@0.0.1', { + files: [url(photo)], + upload_behavior: 'none', + }) + expect(creations).toHaveLength(1) + expect(creations[0]?.params).not.toHaveProperty('input_url') + expect(Object.keys(creations[0]?.files ?? {})).toEqual(['file']) + }) + + it.each([ + { name: 'a file', args: { files: [{ ...url(`${origin}/doc`), filename: 'notes.md' }] } }, + { + name: 'an attachment', + args: { + attachments: [{ download_url: `${origin}/doc`, file_id: 'f', file_name: 'notes.md' }], + }, + }, + ])('downloads $name whose given name differs from its URL, to keep that name', async ({ + args, + }) => { + const download = nock(origin).get('/doc').reply(200, '# Notes') + createReturns(executing) + await call('transloadit_convert_to_pdf', 'builtin/convert-to-pdf@0.0.1', args) + expect(download.isDone()).toBe(true) + expect(creations[0]?.params).not.toHaveProperty('input_url') + }) + + it('downloads a URL given a name even when its URL ends in that name', async () => { + // Redirects or Content-Disposition can rename an imported file; only a download keeps it. + const download = nock(origin).get('/notes.md').reply(200, '# Notes') + createReturns(executing) + await call('transloadit_convert_to_pdf', 'builtin/convert-to-pdf@0.0.1', { + attachments: [{ download_url: `${origin}/notes.md`, file_id: 'f', file_name: 'notes.md' }], + }) + expect(download.isDone()).toBe(true) + expect(creations[0]?.params).not.toHaveProperty('input_url') + }) +}) diff --git a/packages/node/src/alphalib/types/builtinTemplates.ts b/packages/node/src/alphalib/types/builtinTemplates.ts index 8141ed1b..a0756e0c 100644 --- a/packages/node/src/alphalib/types/builtinTemplates.ts +++ b/packages/node/src/alphalib/types/builtinTemplates.ts @@ -59,3 +59,20 @@ export const mcpBuiltinTemplateManifest: Readonly< 'builtin/transcribe@0.0.1': {}, 'builtin/video-covers@0.0.1': {}, } + +/** + * MCP-approved Builtins whose upload input API2 replaces with `input_url` (api2#10292), with the + * most URLs each takes. API2 opts Builtins in explicitly (`allowInputImport`) without publishing + * that in the catalog, so this mirrors its `builtinInputImportTemplateNames` for the approved + * versions. `caption-video` pairs outputs per original and therefore takes exactly one URL. + */ +export const mcpBuiltinInputUrlMaxCounts: Readonly> = { + 'builtin/caption-video@0.0.1': 1, + 'builtin/convert-to-pdf@0.0.1': 10, + 'builtin/encode-video@0.0.1': 10, + 'builtin/extract-text@0.0.1': 10, + 'builtin/prepare-image@0.0.1': 10, + 'builtin/remove-background@0.0.1': 10, + 'builtin/transcribe@0.0.1': 10, + 'builtin/video-covers@0.0.1': 10, +} diff --git a/packages/node/src/alphalib/types/template.ts b/packages/node/src/alphalib/types/template.ts index 8905f653..307d70d6 100644 --- a/packages/node/src/alphalib/types/template.ts +++ b/packages/node/src/alphalib/types/template.ts @@ -172,14 +172,44 @@ const assemblyInstructionsSharedShape = { template_id: templateIdSchema, } as const +/** Most URLs one Assembly may import through `input_url`. */ +export const assemblyInputUrlMaxCount = 10 +/** Longest URL `input_url` accepts, in Unicode code points. */ +export const assemblyInputUrlMaxLength = 4096 + +/** One `input_url` entry; API2 additionally requires a literal absolute HTTP(S) URL. */ +export const assemblyInputUrlValueSchema = z + .string() + .min(1) + .refine( + (value) => [...value].length <= assemblyInputUrlMaxLength, + `Must be at most ${assemblyInputUrlMaxLength} characters`, + ) + +// Request-only: Template content cannot declare it, so it stays out of the shared shape. +const assemblyInputUrlSchema = z + .union([ + assemblyInputUrlValueSchema, + z.array(assemblyInputUrlValueSchema).min(1).max(assemblyInputUrlMaxCount), + ]) + .optional() + .describe( + `One HTTP(S) URL, or a list of up to ${assemblyInputUrlMaxCount}, that Transloadit downloads as the input files of the Builtin Template selected with \`template_id\`, instead of your client uploading them. Only Builtin Templates that accept an input file support it, and the request must be signed or Bearer-authenticated. It cannot be combined with \`steps\` or with uploads. URLs are used literally: they must be absolute, at most ${assemblyInputUrlMaxLength} characters, and must not contain whitespace, \`|\` or Assembly variables such as \`\${fields.url}\`. Private-network addresses are refused.`, + ) + +/** URLs a request imports as a Builtin Template's input files. */ +export type AssemblyInputUrl = z.input + export const assemblyInstructionsSchema = z.object({ auth: assemblyAuthInstructionsSchema.optional(), ...assemblyInstructionsSharedShape, + input_url: assemblyInputUrlSchema, }) export const assemblyInstructionsSchemaWithRequiredAuth = z.object({ auth: assemblyAuthInstructionsSchema, ...assemblyInstructionsSharedShape, + input_url: assemblyInputUrlSchema, }) export type AssemblyInstructions = z.infer